Skip to content

fix: wire 9 unwired plugin hooks to core code (openclaw#14882) thanks…#1

Merged
PeterTheSavage merged 9 commits intoPeterTheSavage:mainfrom
openclaw:main
Feb 13, 2026
Merged

fix: wire 9 unwired plugin hooks to core code (openclaw#14882) thanks…#1
PeterTheSavage merged 9 commits intoPeterTheSavage:mainfrom
openclaw:main

Conversation

@PeterTheSavage
Copy link
Owner

@PeterTheSavage PeterTheSavage commented Feb 13, 2026

@shtse8

Verified:

  • GitHub CI checks green (non-skipped)

Summary by Sourcery

Wire previously unused plugin hooks into core message delivery, session lifecycle, tool execution, compaction, gateway startup/shutdown, and heartbeat cron handling, and add targeted tests to validate these integrations.

New Features:

  • Enable message_sending and message_sent plugin hooks during outbound payload delivery so plugins can modify, cancel, or observe message sends.
  • Trigger session_start and session_end plugin hooks when sessions are created or replaced so plugins can react to lifecycle changes.
  • Invoke after_tool_call plugin hooks after embedded tool executions with timing, args, and error metadata for observability and instrumentation.
  • Run before_compaction and after_compaction plugin hooks around automatic history compaction to expose message and compaction counts to plugins.
  • Fire gateway_start and gateway_stop plugin hooks on server startup and shutdown, providing port and shutdown reason context to plugins.

Bug Fixes:

  • Improve heartbeat cron handling by filtering out heartbeat noise and exec completion markers so scheduled reminders only fire on real cron content and embed the actual reminder text in the prompt.

Enhancements:

  • Adjust heartbeat cron prompts to embed concrete event content, ensuring models see the reminder text directly instead of relying on upstream system messages.
  • Update the changelog with recent heartbeat, agents, Discord, and provider-related behavior changes.

Tests:

  • Add unit tests covering after_tool_call hook wiring for tool execution handlers.
  • Add unit tests for before_compaction and after_compaction hook wiring in the embedded lifecycle handlers.
  • Add unit tests for message_sending and message_sent hook runner methods, including content mutation and cancellation behavior.
  • Add unit tests for session_start and session_end hook runner methods and hasHooks behavior for session hooks.
  • Add unit tests for gateway_start and gateway_stop hook runner methods and hasHooks behavior for gateway hooks.
  • Add tests for heartbeat cron system event filtering and ghost reminder avoidance behavior.

Chores:

  • Introduce a package-lock.json lockfile to capture dependency versions.

Verified:
- GitHub CI checks green (non-skipped)

Co-authored-by: shtse8 <8020099+shtse8@users.noreply.github.com>
Copilot AI review requested due to automatic review settings February 13, 2026 00:14
pvtclawn and others added 8 commits February 12, 2026 16:14
The heartbeat runner was incorrectly triggering CRON_EVENT_PROMPT
whenever ANY system events existed during a cron heartbeat, even if
those events were unrelated (e.g., HEARTBEAT_OK acks, exec completions).

This caused phantom 'scheduled reminder' notifications with no actual
reminder content.

Fix: Only treat as cron event if pending events contain actual
cron-related messages, excluding standard heartbeat acks and
exec completion messages.

Fixes #13317
- Add resetSystemEventsForTest() in beforeEach/afterEach
- Fix hardcoded status assertions (use toBeDefined + conditional checks)
- Prevents cross-test pollution of global system event queue

Addresses Greptile feedback on PR #15059
Combines two complementary fixes for ghost reminder bug:

1. Filter HEARTBEAT_OK/exec messages (previous commit)
2. Embed actual event content in prompt (this commit)

Instead of static 'shown above' message, dynamically build prompt
with actual reminder text. Ensures model sees event content directly.

Credit: Approach inspired by @nyx-rymera's analysis in #13317

Fixes #13317
@sourcery-ai
Copy link

sourcery-ai bot commented Feb 13, 2026

Reviewer's Guide

Wires previously-unconnected plugin hooks into core heartbeat, messaging, session, compaction, and gateway flows, adds cron system-event filtering to avoid ghost reminders, and introduces focused tests to validate hook wiring and new cron behavior.

Sequence diagram for outbound message delivery with message_sending and message_sent hooks

sequenceDiagram
  participant OutboundDelivery
  participant HookRunner
  participant ChannelHandler

  OutboundDelivery->>HookRunner: getGlobalHookRunner
  Note over OutboundDelivery,HookRunner: HookRunner may be undefined if no plugins

  loop For each normalizedPayload
    OutboundDelivery->>OutboundDelivery: Build payloadSummary

    alt message_sending hooks registered
      OutboundDelivery->>HookRunner: hasHooks(message_sending)
      HookRunner-->>OutboundDelivery: true

      OutboundDelivery->>HookRunner: runMessageSending(to, content, metadata, context)
      HookRunner-->>OutboundDelivery: sendingResult

      alt sendingResult.cancel
        OutboundDelivery->>OutboundDelivery: Skip delivery and continue loop
      else sendingResult.content provided
        OutboundDelivery->>OutboundDelivery: Override payload.text with sendingResult.content
      end
    else no message_sending hooks
      OutboundDelivery->>HookRunner: hasHooks(message_sending)
      HookRunner-->>OutboundDelivery: false
    end

    OutboundDelivery->>OutboundDelivery: params.onPayload(payloadSummary)?

    alt handler.sendPayload available
      OutboundDelivery->>ChannelHandler: sendPayload(effectivePayload)
      ChannelHandler-->>OutboundDelivery: result
    else send text/media via handler
      OutboundDelivery->>ChannelHandler: sendText / sendMedia
      ChannelHandler-->>OutboundDelivery: result
    end

    alt send succeeded
      OutboundDelivery->>OutboundDelivery: Push result
      alt message_sent hooks registered
        OutboundDelivery->>HookRunner: hasHooks(message_sent)
        HookRunner-->>OutboundDelivery: true
        OutboundDelivery-->>HookRunner: runMessageSent({success: true}, context) fire-and-forget
      else no message_sent hooks
        OutboundDelivery->>HookRunner: hasHooks(message_sent)
        HookRunner-->>OutboundDelivery: false
      end
    else send throws error
      OutboundDelivery->>OutboundDelivery: Catch err
      alt message_sent hooks registered
        OutboundDelivery->>HookRunner: hasHooks(message_sent)
        HookRunner-->>OutboundDelivery: true
        OutboundDelivery-->>HookRunner: runMessageSent({success: false, error}, context) fire-and-forget
      else no message_sent hooks
        OutboundDelivery->>HookRunner: hasHooks(message_sent)
        HookRunner-->>OutboundDelivery: false
      end
      alt bestEffort is false
        OutboundDelivery->>OutboundDelivery: Rethrow error
      else
        OutboundDelivery->>OutboundDelivery: Continue loop
      end
    end
  end
Loading

Sequence diagram for session initialization with session_start and session_end hooks

sequenceDiagram
  participant SessionInit
  participant HookRunner

  SessionInit->>HookRunner: getGlobalHookRunner

  alt isNewSession is true and hookRunner exists
    alt previousSessionEntry.sessionId differs from new sessionId
      SessionInit->>HookRunner: hasHooks(session_end)
      alt session_end hooks registered
        HookRunner-->>SessionInit: true
        SessionInit-->>HookRunner: runSessionEnd({sessionId, messageCount: 0}, {sessionId, agentId}) fire-and-forget
      else no session_end hooks
        HookRunner-->>SessionInit: false
      end
    end

    SessionInit->>HookRunner: hasHooks(session_start)
    alt session_start hooks registered
      HookRunner-->>SessionInit: true
      SessionInit-->>HookRunner: runSessionStart({sessionId, resumedFrom}, {sessionId, agentId}) fire-and-forget
    else no session_start hooks
      HookRunner-->>SessionInit: false
    end
  else not a new session or no hookRunner
    SessionInit->>SessionInit: Skip session hooks
  end

  SessionInit-->>SessionInit: Return sessionCtx and sessionEntry
Loading

Sequence diagram for tool execution with after_tool_call hook

sequenceDiagram
  participant AgentRuntime
  participant ToolHandlers
  participant HookRunner

  Note over AgentRuntime,ToolHandlers: handleToolExecutionStart
  AgentRuntime->>ToolHandlers: handleToolExecutionStart(ctx, event)
  ToolHandlers->>ToolHandlers: Derive toolName and args
  ToolHandlers->>ToolHandlers: toolStartData.set(toolCallId, {startTime, args})

  Note over AgentRuntime,ToolHandlers: Tool executes asynchronously

  Note over AgentRuntime,ToolHandlers: handleToolExecutionEnd
  AgentRuntime->>ToolHandlers: handleToolExecutionEnd(ctx, event)
  ToolHandlers->>ToolHandlers: Lookup toolCallId metadata
  ToolHandlers->>ToolHandlers: Emit summaries/outputs as needed

  ToolHandlers->>HookRunner: getGlobalHookRunner
  alt after_tool_call hooks registered
    ToolHandlers->>HookRunner: hasHooks(after_tool_call)
    HookRunner-->>ToolHandlers: true

    ToolHandlers->>ToolHandlers: startData = toolStartData.get(toolCallId)
    ToolHandlers->>ToolHandlers: durationMs = now - startData.startTime
    ToolHandlers->>ToolHandlers: params = startData.args as object
    ToolHandlers->>ToolHandlers: error = isError ? extractToolErrorMessage(result) : undefined
    ToolHandlers->>ToolHandlers: toolStartData.delete(toolCallId)

    ToolHandlers-->>HookRunner: runAfterToolCall({toolName, params, result, error, durationMs}, {toolName, agentId, sessionKey}) fire-and-forget
  else no after_tool_call hooks
    ToolHandlers->>HookRunner: hasHooks(after_tool_call)
    HookRunner-->>ToolHandlers: false
    ToolHandlers->>ToolHandlers: toolStartData.delete(toolCallId)
  end
Loading

Sequence diagram for auto-compaction with before_compaction and after_compaction hooks

sequenceDiagram
  participant CompactionLogic
  participant HookRunner

  Note over CompactionLogic,HookRunner: handleAutoCompactionStart
  CompactionLogic->>HookRunner: getGlobalHookRunner
  alt before_compaction hooks registered
    CompactionLogic->>HookRunner: hasHooks(before_compaction)
    HookRunner-->>CompactionLogic: true
    CompactionLogic-->>HookRunner: runBeforeCompaction({messageCount}, {}) fire-and-forget
  else no before_compaction hooks
    CompactionLogic->>HookRunner: hasHooks(before_compaction)
    HookRunner-->>CompactionLogic: false
  end

  CompactionLogic->>CompactionLogic: Perform compaction work

  Note over CompactionLogic,HookRunner: handleAutoCompactionEnd
  alt willRetry is false
    CompactionLogic->>HookRunner: getGlobalHookRunner
    alt after_compaction hooks registered
      CompactionLogic->>HookRunner: hasHooks(after_compaction)
      HookRunner-->>CompactionLogic: true
      CompactionLogic->>CompactionLogic: compactedCount = getCompactionCount()
      CompactionLogic-->>HookRunner: runAfterCompaction({messageCount, compactedCount}, {}) fire-and-forget
    else no after_compaction hooks
      CompactionLogic->>HookRunner: hasHooks(after_compaction)
      HookRunner-->>CompactionLogic: false
    end
  else willRetry is true
    CompactionLogic->>CompactionLogic: Skip after_compaction hook
  end
Loading

Sequence diagram for gateway lifecycle with gateway_start and gateway_stop hooks

sequenceDiagram
  participant GatewayBootstrap
  participant HookRunner

  Note over GatewayBootstrap,HookRunner: startGatewayServer
  GatewayBootstrap->>HookRunner: getGlobalHookRunner
  alt gateway_start hooks registered
    GatewayBootstrap->>HookRunner: hasHooks(gateway_start)
    HookRunner-->>GatewayBootstrap: true
    GatewayBootstrap-->>HookRunner: runGatewayStart({port}, {port}) fire-and-forget
  else no gateway_start hooks
    GatewayBootstrap->>HookRunner: hasHooks(gateway_start)
    HookRunner-->>GatewayBootstrap: false
  end

  GatewayBootstrap->>GatewayBootstrap: Start HTTP server and subsystems

  Note over GatewayBootstrap,HookRunner: close handler
  GatewayBootstrap->>HookRunner: getGlobalHookRunner
  alt gateway_stop hooks registered
    GatewayBootstrap->>HookRunner: hasHooks(gateway_stop)
    HookRunner-->>GatewayBootstrap: true
    GatewayBootstrap->>HookRunner: runGatewayStop({reason}, {port})
    HookRunner-->>GatewayBootstrap: completion or error
    GatewayBootstrap->>GatewayBootstrap: Log warning on hook failure
  else no gateway_stop hooks
    GatewayBootstrap->>HookRunner: hasHooks(gateway_stop)
    HookRunner-->>GatewayBootstrap: false
  end

  GatewayBootstrap->>GatewayBootstrap: Stop diagnostics and server
Loading

File-Level Changes

Change Details Files
Make cron heartbeat handling event-aware so only real reminder content affects prompts.
  • Replace static cron prompt with buildCronEventPrompt that inlines pending cron event text or falls back to HEARTBEAT_OK when empty.
  • Introduce helpers to classify heartbeat ack, heartbeat noise, and exec completion events, and export isCronSystemEvent to identify real cron reminder events.
  • Update runHeartbeatOnce to filter pending system events with isCronSystemEvent, detect exec completion via isExecCompletionEvent, and choose between exec, cron, and standard heartbeat prompts accordingly.
src/infra/heartbeat-runner.ts
src/infra/heartbeat-runner.cron-system-event-filter.test.ts
src/infra/heartbeat-runner.ghost-reminder.test.ts
Wire message_sending and message_sent plugin hooks into outbound delivery and validate runners.
  • Instantiate global hook runner in deliverOutboundPayloads and, when message_sending hooks are present, run them before send to optionally cancel or mutate text while keeping metadata in sync.
  • After successful or failed sends, fire-and-forget message_sent hooks with success flag and optional error string without affecting delivery outcome.
  • Add hook-runner unit tests that verify runMessageSending and runMessageSent invoke registered hooks, can modify content, and surface cancellation and error information.
src/infra/outbound/deliver.ts
src/plugins/wired-hooks-message.test.ts
Wire session_start and session_end hooks into session lifecycle and test runner behavior.
  • During initSessionState, obtain global hook runner and, on new sessions, emit session_end for a replaced previous session (if any) and session_start for the new session using resolved agentId context.
  • Ensure hooks are invoked fire-and-forget with basic session metadata (sessionId, resumedFrom, messageCount).
  • Add tests for runSessionStart and runSessionEnd as well as hasHooks reporting for session hooks via a mock PluginRegistry.
src/auto-reply/reply/session.ts
src/plugins/wired-hooks-session.test.ts
Wire after_tool_call hooks into tool execution lifecycle with timing and error context.
  • Track tool execution start time and args in a module-level map keyed by toolCallId when handleToolExecutionStart runs.
  • On handleToolExecutionEnd, compute durationMs, derive params and error string, then fire-and-forget runAfterToolCall with sanitized result and context; clean up tracking map whether or not hooks exist.
  • Log failures of after_tool_call hooks without impacting main tool handling path.
  • Add tests that verify runAfterToolCall is invoked when hooks are registered, includes error details for tool failures, and is skipped otherwise.
src/agents/pi-embedded-subscribe.handlers.tools.ts
src/plugins/wired-hooks-after-tool-call.test.ts
Wire before_compaction and after_compaction hooks into auto-compaction lifecycle and test runners.
  • Call global hook runner from handleAutoCompactionStart to fire-and-forget before_compaction hooks with current message count.
  • From handleAutoCompactionEnd, when willRetry is false, invoke after_compaction hooks with messageCount and compactedCount; skip hooks on retry and log errors without throwing.
  • Add tests confirming before_compaction and after_compaction hooks are invoked with correct payloads, and that after_compaction is not called when compaction will retry.
src/agents/pi-embedded-subscribe.handlers.lifecycle.ts
src/plugins/wired-hooks-compaction.test.ts
Wire gateway_start and gateway_stop hooks around gateway server lifecycle and validate hook runner methods.
  • On startGatewayServer, after creating the app but before hot-reload wiring, fire-and-forget gateway_start hook with port in both event and context, logging failures.
  • In the returned close() method, synchronously await gateway_stop hook (if present) before diagnostics shutdown, passing a reason and port, while swallowing/logging hook failures.
  • Add tests for runGatewayStart and runGatewayStop hook runner methods and hasHooks behavior with a mock PluginRegistry.
src/gateway/server.impl.ts
src/plugins/wired-hooks-gateway.test.ts
Document new behavior in changelog and add lockfile stub.
  • Add changelog entries for heartbeat cron noise filtering and several unrelated features that shipped alongside these changes.
  • Introduce a new package-lock.json file placeholder (contents not shown in diff).
CHANGELOG.md
package-lock.json

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@gemini-code-assist
Copy link

Summary of Changes

Hello @PeterTheSavage, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the system's extensibility by wiring nine previously unwired plugin hooks into various core functionalities. These integrations allow plugins to intercept and react to critical events such as agent lifecycle, tool execution, session management, gateway startup/shutdown, and message delivery, thereby enabling more powerful and flexible customizations and observability for external modules.

Highlights

  • Agent Lifecycle Hooks: Wired before_compaction and after_compaction plugin hooks into the agent's automatic message compaction process.
  • Tool Execution Hook: Integrated the after_tool_call plugin hook to fire after tool executions, capturing execution details and potential errors.
  • Session Lifecycle Hooks: Connected session_start and session_end plugin hooks to session initialization and termination events.
  • Gateway Lifecycle Hooks: Implemented gateway_start and gateway_stop plugin hooks for server startup and shutdown events.
  • Message Delivery Hooks: Added message_sending and message_sent plugin hooks to outbound message delivery, allowing content modification, cancellation, and tracking of delivery success/failure.
  • New Test Coverage: Introduced dedicated test files to verify the correct wiring and behavior of all newly integrated plugin hooks.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • src/agents/pi-embedded-subscribe.handlers.lifecycle.ts
    • Wired before_compaction hook
    • Wired after_compaction hook
  • src/agents/pi-embedded-subscribe.handlers.tools.ts
    • Wired after_tool_call hook
    • Added tracking for tool execution start times and arguments
  • src/auto-reply/reply/session.ts
    • Wired session_end hook
    • Wired session_start hook
  • src/gateway/server.impl.ts
    • Wired gateway_start hook
    • Wired gateway_stop hook
  • src/infra/outbound/deliver.ts
    • Wired message_sending hook, allowing content modification or cancellation
    • Wired message_sent hook for both success and failure scenarios
  • src/plugins/wired-hooks-after-tool-call.test.ts
    • Added new test file for after_tool_call hook wiring
  • src/plugins/wired-hooks-compaction.test.ts
    • Added new test file for compaction hooks wiring
  • src/plugins/wired-hooks-gateway.test.ts
    • Added new test file for gateway hooks wiring
  • src/plugins/wired-hooks-message.test.ts
    • Added new test file for message sending/sent hooks wiring
  • src/plugins/wired-hooks-session.test.ts
    • Added new test file for session hooks wiring
Activity
  • The author, PeterTheSavage, created this pull request.
  • GitHub CI checks passed successfully.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@PeterTheSavage PeterTheSavage merged commit 8baf10b into PeterTheSavage:main Feb 13, 2026
6 of 8 checks passed
Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The global toolStartData map in pi-embedded-subscribe.handlers.tools.ts is only cleaned up in handleToolExecutionEnd, so if a tool call never emits an *_end event (e.g., aborted runs) entries will accumulate; consider adding a timeout/cleanup or a defensive delete on handleToolExecutionStart to avoid leaks across long-lived processes.
  • Hook failures are silently swallowed in several places (message_sending / message_sent in deliverOutboundPayloads, and session hooks in initSessionState) while similar hooks elsewhere log warnings; for easier debugging and consistent behavior you may want to at least log a warning in these catch blocks.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The global `toolStartData` map in `pi-embedded-subscribe.handlers.tools.ts` is only cleaned up in `handleToolExecutionEnd`, so if a tool call never emits an `*_end` event (e.g., aborted runs) entries will accumulate; consider adding a timeout/cleanup or a defensive delete on `handleToolExecutionStart` to avoid leaks across long-lived processes.
- Hook failures are silently swallowed in several places (`message_sending` / `message_sent` in `deliverOutboundPayloads`, and session hooks in `initSessionState`) while similar hooks elsewhere log warnings; for easier debugging and consistent behavior you may want to at least log a warning in these `catch` blocks.

## Individual Comments

### Comment 1
<location> `src/infra/outbound/deliver.ts:379-367` </location>
<code_context>
       params.onPayload?.(payloadSummary);
-      if (handler.sendPayload && payload.channelData) {
-        results.push(await handler.sendPayload(payload));
+      if (handler.sendPayload && effectivePayload.channelData) {
+        results.push(await handler.sendPayload(effectivePayload));
         continue;
       }
       if (payloadSummary.mediaUrls.length === 0) {
</code_context>

<issue_to_address>
**issue (bug_risk):** message_sent hook is never called for handlers using sendPayload due to early continue

In the `handler.sendPayload && effectivePayload.channelData` branch, the function `continue`s before invoking the `message_sent` hook, so plugins relying on that hook won’t see messages sent via `sendPayload`, unlike the media/text paths below. To keep hook behavior consistent across all successful send paths, move the `message_sent` invocation before the `continue` or refactor so all branches go through a shared post-send section that triggers it.
</issue_to_address>

### Comment 2
<location> `src/infra/heartbeat-runner.ts:119` </location>
<code_context>
+
+const HEARTBEAT_OK_PREFIX = HEARTBEAT_TOKEN.toLowerCase();
+
+// Detect heartbeat-specific noise so cron reminders don't trigger on non-reminder events.
+function isHeartbeatAckEvent(evt: string): boolean {
+  const trimmed = evt.trim();
</code_context>

<issue_to_address>
**issue (complexity):** Consider replacing the multiple small predicate helpers with a single event classifier plus an analyzer so the heartbeat flow reads as classify → analyze → choose prompt.

You can reduce the mental overhead by collapsing the tiny predicates into a single classifier + analyzer, and drive `runHeartbeatOnce` from that. This keeps behavior while making the flow “read” like the domain: classify events → analyze → choose prompt.

### 1. Collapse predicates into a classifier

Instead of `isHeartbeatAckEvent`, `isHeartbeatNoiseEvent`, `isExecCompletionEvent`, and `isCronSystemEvent` all composing each other, fold them into one `classifySystemEvent` and a small analyzer. You can keep `HEARTBEAT_OK_PREFIX` and the regex logic, just move it inside.

```ts
type SystemEventKind = "cron" | "exec-completion" | "heartbeat-noise" | "other";

function classifySystemEvent(evt: string): SystemEventKind {
  const trimmed = evt.trim();
  if (!trimmed) return "other";

  const lower = trimmed.toLowerCase();

  // exec completion detection (previous isExecCompletionEvent)
  if (lower.includes("exec finished")) {
    return "exec-completion";
  }

  // heartbeat noise detection (previous isHeartbeatAckEvent + isHeartbeatNoiseEvent)
  const isHeartbeatAck = (() => {
    if (!lower.startsWith(HEARTBEAT_OK_PREFIX)) return false;
    const suffix = lower.slice(HEARTBEAT_OK_PREFIX.length);
    if (!suffix.length) return true;
    return !/[a-z0-9_]/.test(suffix[0]);
  })();

  if (
    isHeartbeatAck ||
    lower.includes("heartbeat poll") ||
    lower.includes("heartbeat wake")
  ) {
    return "heartbeat-noise";
  }

  // Anything else is treated as cron content
  return "cron";
}

// Preserve the existing export but simplify it to use the classifier.
export function isCronSystemEvent(evt: string) {
  return classifySystemEvent(evt) === "cron";
}
```

This removes layered negations and consolidates “what kind of system event is this?” into one place.

### 2. Centralize analysis of pending system events

You can keep `buildCronEventPrompt` as‑is, but co‑locate it with a small analyzer so `runHeartbeatOnce` doesn’t need to know all the details:

```ts
function analyzeSystemEvents(
  reason: string | undefined,
  pendingEvents: string[]
): { cronEvents: string[]; hasExecCompletion: boolean; hasCronEvents: boolean } {
  const isExecEvent = reason === "exec-event";
  const isCronEvent = Boolean(reason?.startsWith("cron:"));

  let cronEvents: string[] = [];
  let hasExecCompletion = false;

  for (const evt of pendingEvents) {
    const kind = classifySystemEvent(evt);
    if (kind === "exec-completion") {
      hasExecCompletion = true;
    } else if (kind === "cron") {
      cronEvents.push(evt);
    }
  }

  const hasCronEvents = isCronEvent && cronEvents.length > 0;
  return { cronEvents, hasExecCompletion, hasCronEvents };
}
```

### 3. Simplify `runHeartbeatOnce` call site

Now `runHeartbeatOnce` can be simpler and more self‑descriptive, without multiple low‑level helpers:

```ts
const isExecEvent = opts.reason === "exec-event";
const isCronEvent = Boolean(opts.reason?.startsWith("cron:"));
const pendingEvents = isExecEvent || isCronEvent ? peekSystemEvents(sessionKey) : [];

const { cronEvents, hasExecCompletion, hasCronEvents } = analyzeSystemEvents(
  opts.reason,
  pendingEvents
);

const prompt = hasExecCompletion
  ? EXEC_EVENT_PROMPT
  : hasCronEvents
    ? buildCronEventPrompt(cronEvents)
    : resolveHeartbeatPrompt(cfg, heartbeat);

const ctx = {
  Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),
  From: sender,
  To: sender,
  Provider: hasExecCompletion ? "exec-event" : hasCronEvents ? "cron-event" : "heartbeat",
  SessionKey: sessionKey,
};
```

Behavior is preserved:

- Exec completion detection still uses the same `"exec finished"` logic.
- Heartbeat noise (ack, poll, wake) is still filtered out with the same normalization and regex.
- Cron events are now the explicit `"cron"` classification, not “not noise and not exec”.

But the complexity at the top level is reduced to “classify → analyze → prompt”, and the string‑level nuance is contained in one place.
</issue_to_address>

### Comment 3
<location> `src/infra/outbound/deliver.ts:342` </location>
<code_context>
     return normalized ? [normalized] : [];
   });
+  const hookRunner = getGlobalHookRunner();
   for (const payload of normalizedPayloads) {
     const payloadSummary: NormalizedOutboundPayload = {
       text: payload.text ?? "",
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the hook-handling logic into small helper functions so the delivery loop focuses on core behavior and is easier to read.

You can keep the new behavior but reduce cognitive load in the loop by pushing the hook logic into small helpers and consolidating the duplicated `message_sent` calls.

### 1. Extract `message_sending` hook into a helper

Encapsulate the try/catch, cancel semantics, and mutation of payload into a dedicated function:

```ts
type MessageSendingInput = {
  to: string;
  channel: string;
  accountId?: string | null;
  payload: NormalizedOutboundPayloadInputType; // whatever `payload` is
  summary: NormalizedOutboundPayload;
};

async function runMessageSendingHook(
  hookRunner: ReturnType<typeof getGlobalHookRunner> | undefined,
  { to, channel, accountId, payload, summary }: MessageSendingInput,
): Promise<{ canceled: boolean; effectivePayload: typeof payload }> {
  if (!hookRunner?.hasHooks("message_sending")) {
    return { canceled: false, effectivePayload: payload };
  }

  try {
    const sendingResult = await hookRunner.runMessageSending(
      {
        to,
        content: summary.text,
        metadata: { channel, accountId, mediaUrls: summary.mediaUrls },
      },
      { channelId: channel, accountId: accountId ?? undefined },
    );

    if (sendingResult?.cancel) {
      return { canceled: true, effectivePayload: payload };
    }

    if (sendingResult?.content != null) {
      summary.text = sendingResult.content;
      return { canceled: false, effectivePayload: { ...payload, text: sendingResult.content } };
    }

    return { canceled: false, effectivePayload: payload };
  } catch {
    // Don't block delivery on hook failure
    return { canceled: false, effectivePayload: payload };
  }
}
```

Then the loop becomes easier to scan:

```ts
for (const payload of normalizedPayloads) {
  const payloadSummary: NormalizedOutboundPayload = { /* ... */ };

  try {
    throwIfAborted(abortSignal);

    const { canceled, effectivePayload } = await runMessageSendingHook(hookRunner, {
      to,
      channel,
      accountId,
      payload,
      summary: payloadSummary,
    });
    if (canceled) continue;

    params.onPayload?.(payloadSummary);

    if (handler.sendPayload && effectivePayload.channelData) {
      results.push(await handler.sendPayload(effectivePayload));
      continue;
    }
    // rest of delivery logic unchanged...
```

### 2. Consolidate `message_sent` success/failure into a helper

The success/failure paths are nearly identical; a helper makes the intent explicit and removes duplication:

```ts
function fireMessageSentHook(
  hookRunner: ReturnType<typeof getGlobalHookRunner> | undefined,
  {
    to,
    channel,
    accountId,
    content,
    success,
    error,
  }: {
    to: string;
    channel: string;
    accountId?: string | null;
    content: string;
    success: boolean;
    error?: unknown;
  },
): void {
  if (!hookRunner?.hasHooks("message_sent")) return;

  const errorMessage =
    !success || error != null
      ? error instanceof Error
        ? error.message
        : String(error)
      : undefined;

  void hookRunner
    .runMessageSent(
      { to, content, success, error: errorMessage },
      { channelId: channel, accountId: accountId ?? undefined },
    )
    .catch(() => {});
}
```

Then in the loop:

```ts
// on success
fireMessageSentHook(hookRunner, {
  to,
  channel,
  accountId,
  content: payloadSummary.text,
  success: true,
});

// in catch
} catch (err) {
  fireMessageSentHook(hookRunner, {
    to,
    channel,
    accountId,
    content: payloadSummary.text,
    success: false,
    error: err,
  });

  if (!params.bestEffort) {
    throw err;
  }
  params.onError?.(err, payloadSummary);
}
```

This keeps all existing behavior (including fire‑and‑forget semantics and error swallowing for hooks) but makes the core loop mostly about delivery, with hook behavior clearly named and localized.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

},
);
if (sendingResult?.cancel) {
continue;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): message_sent hook is never called for handlers using sendPayload due to early continue

In the handler.sendPayload && effectivePayload.channelData branch, the function continues before invoking the message_sent hook, so plugins relying on that hook won’t see messages sent via sendPayload, unlike the media/text paths below. To keep hook behavior consistent across all successful send paths, move the message_sent invocation before the continue or refactor so all branches go through a shared post-send section that triggers it.


const HEARTBEAT_OK_PREFIX = HEARTBEAT_TOKEN.toLowerCase();

// Detect heartbeat-specific noise so cron reminders don't trigger on non-reminder events.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider replacing the multiple small predicate helpers with a single event classifier plus an analyzer so the heartbeat flow reads as classify → analyze → choose prompt.

You can reduce the mental overhead by collapsing the tiny predicates into a single classifier + analyzer, and drive runHeartbeatOnce from that. This keeps behavior while making the flow “read” like the domain: classify events → analyze → choose prompt.

1. Collapse predicates into a classifier

Instead of isHeartbeatAckEvent, isHeartbeatNoiseEvent, isExecCompletionEvent, and isCronSystemEvent all composing each other, fold them into one classifySystemEvent and a small analyzer. You can keep HEARTBEAT_OK_PREFIX and the regex logic, just move it inside.

type SystemEventKind = "cron" | "exec-completion" | "heartbeat-noise" | "other";

function classifySystemEvent(evt: string): SystemEventKind {
  const trimmed = evt.trim();
  if (!trimmed) return "other";

  const lower = trimmed.toLowerCase();

  // exec completion detection (previous isExecCompletionEvent)
  if (lower.includes("exec finished")) {
    return "exec-completion";
  }

  // heartbeat noise detection (previous isHeartbeatAckEvent + isHeartbeatNoiseEvent)
  const isHeartbeatAck = (() => {
    if (!lower.startsWith(HEARTBEAT_OK_PREFIX)) return false;
    const suffix = lower.slice(HEARTBEAT_OK_PREFIX.length);
    if (!suffix.length) return true;
    return !/[a-z0-9_]/.test(suffix[0]);
  })();

  if (
    isHeartbeatAck ||
    lower.includes("heartbeat poll") ||
    lower.includes("heartbeat wake")
  ) {
    return "heartbeat-noise";
  }

  // Anything else is treated as cron content
  return "cron";
}

// Preserve the existing export but simplify it to use the classifier.
export function isCronSystemEvent(evt: string) {
  return classifySystemEvent(evt) === "cron";
}

This removes layered negations and consolidates “what kind of system event is this?” into one place.

2. Centralize analysis of pending system events

You can keep buildCronEventPrompt as‑is, but co‑locate it with a small analyzer so runHeartbeatOnce doesn’t need to know all the details:

function analyzeSystemEvents(
  reason: string | undefined,
  pendingEvents: string[]
): { cronEvents: string[]; hasExecCompletion: boolean; hasCronEvents: boolean } {
  const isExecEvent = reason === "exec-event";
  const isCronEvent = Boolean(reason?.startsWith("cron:"));

  let cronEvents: string[] = [];
  let hasExecCompletion = false;

  for (const evt of pendingEvents) {
    const kind = classifySystemEvent(evt);
    if (kind === "exec-completion") {
      hasExecCompletion = true;
    } else if (kind === "cron") {
      cronEvents.push(evt);
    }
  }

  const hasCronEvents = isCronEvent && cronEvents.length > 0;
  return { cronEvents, hasExecCompletion, hasCronEvents };
}

3. Simplify runHeartbeatOnce call site

Now runHeartbeatOnce can be simpler and more self‑descriptive, without multiple low‑level helpers:

const isExecEvent = opts.reason === "exec-event";
const isCronEvent = Boolean(opts.reason?.startsWith("cron:"));
const pendingEvents = isExecEvent || isCronEvent ? peekSystemEvents(sessionKey) : [];

const { cronEvents, hasExecCompletion, hasCronEvents } = analyzeSystemEvents(
  opts.reason,
  pendingEvents
);

const prompt = hasExecCompletion
  ? EXEC_EVENT_PROMPT
  : hasCronEvents
    ? buildCronEventPrompt(cronEvents)
    : resolveHeartbeatPrompt(cfg, heartbeat);

const ctx = {
  Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),
  From: sender,
  To: sender,
  Provider: hasExecCompletion ? "exec-event" : hasCronEvents ? "cron-event" : "heartbeat",
  SessionKey: sessionKey,
};

Behavior is preserved:

  • Exec completion detection still uses the same "exec finished" logic.
  • Heartbeat noise (ack, poll, wake) is still filtered out with the same normalization and regex.
  • Cron events are now the explicit "cron" classification, not “not noise and not exec”.

But the complexity at the top level is reduced to “classify → analyze → prompt”, and the string‑level nuance is contained in one place.

return normalized ? [normalized] : [];
});
const hookRunner = getGlobalHookRunner();
for (const payload of normalizedPayloads) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the hook-handling logic into small helper functions so the delivery loop focuses on core behavior and is easier to read.

You can keep the new behavior but reduce cognitive load in the loop by pushing the hook logic into small helpers and consolidating the duplicated message_sent calls.

1. Extract message_sending hook into a helper

Encapsulate the try/catch, cancel semantics, and mutation of payload into a dedicated function:

type MessageSendingInput = {
  to: string;
  channel: string;
  accountId?: string | null;
  payload: NormalizedOutboundPayloadInputType; // whatever `payload` is
  summary: NormalizedOutboundPayload;
};

async function runMessageSendingHook(
  hookRunner: ReturnType<typeof getGlobalHookRunner> | undefined,
  { to, channel, accountId, payload, summary }: MessageSendingInput,
): Promise<{ canceled: boolean; effectivePayload: typeof payload }> {
  if (!hookRunner?.hasHooks("message_sending")) {
    return { canceled: false, effectivePayload: payload };
  }

  try {
    const sendingResult = await hookRunner.runMessageSending(
      {
        to,
        content: summary.text,
        metadata: { channel, accountId, mediaUrls: summary.mediaUrls },
      },
      { channelId: channel, accountId: accountId ?? undefined },
    );

    if (sendingResult?.cancel) {
      return { canceled: true, effectivePayload: payload };
    }

    if (sendingResult?.content != null) {
      summary.text = sendingResult.content;
      return { canceled: false, effectivePayload: { ...payload, text: sendingResult.content } };
    }

    return { canceled: false, effectivePayload: payload };
  } catch {
    // Don't block delivery on hook failure
    return { canceled: false, effectivePayload: payload };
  }
}

Then the loop becomes easier to scan:

for (const payload of normalizedPayloads) {
  const payloadSummary: NormalizedOutboundPayload = { /* ... */ };

  try {
    throwIfAborted(abortSignal);

    const { canceled, effectivePayload } = await runMessageSendingHook(hookRunner, {
      to,
      channel,
      accountId,
      payload,
      summary: payloadSummary,
    });
    if (canceled) continue;

    params.onPayload?.(payloadSummary);

    if (handler.sendPayload && effectivePayload.channelData) {
      results.push(await handler.sendPayload(effectivePayload));
      continue;
    }
    // rest of delivery logic unchanged...

2. Consolidate message_sent success/failure into a helper

The success/failure paths are nearly identical; a helper makes the intent explicit and removes duplication:

function fireMessageSentHook(
  hookRunner: ReturnType<typeof getGlobalHookRunner> | undefined,
  {
    to,
    channel,
    accountId,
    content,
    success,
    error,
  }: {
    to: string;
    channel: string;
    accountId?: string | null;
    content: string;
    success: boolean;
    error?: unknown;
  },
): void {
  if (!hookRunner?.hasHooks("message_sent")) return;

  const errorMessage =
    !success || error != null
      ? error instanceof Error
        ? error.message
        : String(error)
      : undefined;

  void hookRunner
    .runMessageSent(
      { to, content, success, error: errorMessage },
      { channelId: channel, accountId: accountId ?? undefined },
    )
    .catch(() => {});
}

Then in the loop:

// on success
fireMessageSentHook(hookRunner, {
  to,
  channel,
  accountId,
  content: payloadSummary.text,
  success: true,
});

// in catch
} catch (err) {
  fireMessageSentHook(hookRunner, {
    to,
    channel,
    accountId,
    content: payloadSummary.text,
    success: false,
    error: err,
  });

  if (!params.bestEffort) {
    throw err;
  }
  params.onError?.(err, payloadSummary);
}

This keeps all existing behavior (including fire‑and‑forget semantics and error swallowing for hooks) but makes the core loop mostly about delivery, with hook behavior clearly named and localized.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request effectively wires up several previously unused plugin hooks across different parts of the application, such as message delivery, session lifecycle, and tool execution. The changes are well-structured and include corresponding tests to validate the new integrations.

My review focuses on a few areas for improvement:

  • A potential concurrency issue with module-level state for tool execution tracking.
  • Opportunities to improve error handling for hooks by adding logging instead of silently swallowing errors.
  • A minor code deduplication suggestion.

Overall, this is a solid contribution that significantly enhances the plugin system's capabilities.

import { normalizeToolName } from "./tool-policy.js";

/** Track tool execution start times and args for after_tool_call hook */
const toolStartData = new Map<string, { startTime: number; args: unknown }>();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The toolStartData map is a module-level variable. This can lead to race conditions and incorrect behavior if multiple agent runs are processed concurrently within the same process, as they would share this state. For example, two concurrent tool calls with the same toolCallId from different runs would conflict.

To ensure proper isolation, this state should be moved into the EmbeddedPiSubscribeState and accessed through the ctx object (e.g., ctx.state.toolStartData). This will scope the tool execution tracking to the specific agent subscription context.

Comment on lines +237 to +266
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("after_tool_call")) {
const startData = toolStartData.get(toolCallId);
toolStartData.delete(toolCallId);
const durationMs = startData?.startTime != null ? Date.now() - startData.startTime : undefined;
const toolArgs = startData?.args;
void hookRunner
.runAfterToolCall(
{
toolName,
params: (toolArgs && typeof toolArgs === "object" ? toolArgs : {}) as Record<
string,
unknown
>,
result: sanitizedResult,
error: isToolError ? extractToolErrorMessage(sanitizedResult) : undefined,
durationMs,
},
{
toolName,
agentId: undefined,
sessionKey: undefined,
},
)
.catch((err) => {
ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);
});
} else {
toolStartData.delete(toolCallId);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's a small code duplication here. The call to toolStartData.delete(toolCallId) is present in both the if and else blocks. You can simplify this by moving the deletion out of the conditional, since it should happen regardless of whether hooks are registered.

  const hookRunner = getGlobalHookRunner();
  const startData = toolStartData.get(toolCallId);
  toolStartData.delete(toolCallId);

  if (hookRunner?.hasHooks("after_tool_call")) {
    const durationMs = startData?.startTime != null ? Date.now() - startData.startTime : undefined;
    const toolArgs = startData?.args;
    void hookRunner
      .runAfterToolCall(
        {
          toolName,
          params: (toolArgs && typeof toolArgs === "object" ? toolArgs : {}) as Record<
            string,
            unknown
          >,
          result: sanitizedResult,
          error: isToolError ? extractToolErrorMessage(sanitizedResult) : undefined,
          durationMs,
        },
        {
          toolName,
          agentId: undefined,
          sessionKey: undefined,
        },
      )
      .catch((err) => {
        ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);
      });
  }

Comment on lines +391 to +424
// If replacing an existing session, fire session_end for the old one
if (previousSessionEntry?.sessionId && previousSessionEntry.sessionId !== effectiveSessionId) {
if (hookRunner.hasHooks("session_end")) {
void hookRunner
.runSessionEnd(
{
sessionId: previousSessionEntry.sessionId,
messageCount: 0,
},
{
sessionId: previousSessionEntry.sessionId,
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
},
)
.catch(() => {});
}
}

// Fire session_start for the new session
if (hookRunner.hasHooks("session_start")) {
void hookRunner
.runSessionStart(
{
sessionId: effectiveSessionId,
resumedFrom: previousSessionEntry?.sessionId,
},
{
sessionId: effectiveSessionId,
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
},
)
.catch(() => {});
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The .catch(() => {}) blocks for runSessionEnd and runSessionStart hooks silently swallow any errors from plugins. While it's important not to block session initialization on hook failures, logging these errors is crucial for debugging plugins. Please consider adding a console.warn to log the error, similar to how it's handled in other parts of the codebase.

    // If replacing an existing session, fire session_end for the old one
    if (previousSessionEntry?.sessionId && previousSessionEntry.sessionId !== effectiveSessionId) {
      if (hookRunner.hasHooks("session_end")) {
        void hookRunner
          .runSessionEnd(
            {
              sessionId: previousSessionEntry.sessionId,
              messageCount: 0,
            },
            {
              sessionId: previousSessionEntry.sessionId,
              agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
            },
          )
          .catch((err) => {
            console.warn(`session_end hook failed: ${String(err)}`);
          });
      }
    }

    // Fire session_start for the new session
    if (hookRunner.hasHooks("session_start")) {
      void hookRunner
        .runSessionStart(
          {
            sessionId: effectiveSessionId,
            resumedFrom: previousSessionEntry?.sessionId,
          },
          {
            sessionId: effectiveSessionId,
            agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
          },
        )
        .catch((err) => {
          console.warn(`session_start hook failed: ${String(err)}`);
        });
    }

Comment on lines +354 to +375
try {
const sendingResult = await hookRunner.runMessageSending(
{
to,
content: payloadSummary.text,
metadata: { channel, accountId, mediaUrls: payloadSummary.mediaUrls },
},
{
channelId: channel,
accountId: accountId ?? undefined,
},
);
if (sendingResult?.cancel) {
continue;
}
if (sendingResult?.content != null) {
effectivePayload = { ...payload, text: sendingResult.content };
payloadSummary.text = sendingResult.content;
}
} catch {
// Don't block delivery on hook failure
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The catch block for the message_sending hook is empty, which silently swallows errors. While not blocking delivery is important, logging these errors via console.warn is crucial for debugging plugins.

        try {
          const sendingResult = await hookRunner.runMessageSending(
            {
              to,
              content: payloadSummary.text,
              metadata: { channel, accountId, mediaUrls: payloadSummary.mediaUrls },
            },
            {
              channelId: channel,
              accountId: accountId ?? undefined,
            },
          );
          if (sendingResult?.cancel) {
            continue;
          }
          if (sendingResult?.content != null) {
            effectivePayload = { ...payload, text: sendingResult.content };
            payloadSummary.text = sendingResult.content;
          }
        } catch (err) {
          // Don't block delivery on hook failure, but log it.
          console.warn(`message_sending hook failed: ${String(err)}`);
        }

Comment on lines +404 to +418
if (hookRunner?.hasHooks("message_sent")) {
void hookRunner
.runMessageSent(
{
to,
content: payloadSummary.text,
success: true,
},
{
channelId: channel,
accountId: accountId ?? undefined,
},
)
.catch(() => {});
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The .catch(() => {}) for the message_sent hook silently swallows errors. This makes debugging plugins difficult. Please add logging to report hook failures. This also applies to the message_sent hook in the error handling block below (lines 421-436).

      if (hookRunner?.hasHooks("message_sent")) {
        void hookRunner
          .runMessageSent(
            {
              to,
              content: payloadSummary.text,
              success: true,
            },
            {
              channelId: channel,
              accountId: accountId ?? undefined,
            },
          )
          .catch((err) => {
            console.warn(`message_sent hook (on success) failed: ${String(err)}`);
          });
      }

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request wires 9 previously unwired plugin hooks into the core codebase and fixes a ghost reminder bug in the heartbeat runner. The hooks enable plugins to intercept and observe key lifecycle events including session lifecycle, message delivery, gateway operations, tool execution, and compaction events.

Changes:

  • Wires 9 plugin hooks (session_start, session_end, message_sending, message_sent, gateway_start, gateway_stop, before_compaction, after_compaction, after_tool_call) with corresponding test coverage
  • Fixes ghost reminder bug (openclaw#13317) where heartbeat noise events triggered false cron reminder notifications
  • Adds comprehensive test suites for all new hook integrations

Reviewed changes

Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/plugins/wired-hooks-session.test.ts Unit tests for session_start and session_end hook runner methods
src/plugins/wired-hooks-message.test.ts Unit tests for message_sending and message_sent hook runner methods
src/plugins/wired-hooks-gateway.test.ts Unit tests for gateway_start and gateway_stop hook runner methods
src/plugins/wired-hooks-compaction.test.ts Integration tests for before_compaction and after_compaction hooks in lifecycle handlers
src/plugins/wired-hooks-after-tool-call.test.ts Integration tests for after_tool_call hook in tool execution handlers
src/infra/outbound/deliver.ts Wires message_sending (with content modification/cancellation) and message_sent hooks into message delivery flow
src/infra/heartbeat-runner.ts Adds filtering logic to distinguish real cron events from heartbeat noise, fixing ghost reminder bug
src/infra/heartbeat-runner.ghost-reminder.test.ts End-to-end tests verifying ghost reminder bug fix with various event scenarios
src/infra/heartbeat-runner.cron-system-event-filter.test.ts Unit tests for cron event filtering helper function
src/gateway/server.impl.ts Wires gateway_start and gateway_stop hooks into gateway server lifecycle
src/auto-reply/reply/session.ts Wires session_start and session_end hooks into session initialization flow
src/agents/pi-embedded-subscribe.handlers.tools.ts Wires after_tool_call hook with duration tracking via module-level Map
src/agents/pi-embedded-subscribe.handlers.lifecycle.ts Wires before_compaction and after_compaction hooks into compaction lifecycle handlers
CHANGELOG.md Adds changelog entries for bug fixes and features

Comment on lines +10 to +32
function createMockRegistry(
hooks: Array<{ hookName: string; handler: (...args: unknown[]) => unknown }>,
): PluginRegistry {
return {
hooks: hooks as never[],
typedHooks: hooks.map((h) => ({
pluginId: "test-plugin",
hookName: h.hookName,
handler: h.handler,
priority: 0,
source: "test",
})),
tools: [],
httpHandlers: [],
httpRoutes: [],
channelRegistrations: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
providers: [],
commands: [],
} as unknown as PluginRegistry;
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createMockRegistry helper function is duplicated across three test files (wired-hooks-session.test.ts, wired-hooks-message.test.ts, and wired-hooks-gateway.test.ts) with identical implementations. Consider extracting this to a shared test utility file (e.g., src/test-utils/plugin-test-helpers.ts) to reduce duplication and make future maintenance easier.

Copilot uses AI. Check for mistakes.
Comment on lines 44 to +74
@@ -67,6 +70,8 @@ Docs: https://docs.openclaw.ai
- Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26.
- Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002.
- Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi.
- Agents: keep followup-runner session `totalTokens` aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.
- Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several CHANGELOG entries appear unrelated to the plugin hooks wiring described in the PR title:

These entries don't correspond to any code changes in the diffs. If this PR is intended only for wiring plugin hooks and fixing the ghost reminder bug (openclaw#13317), these unrelated entries should be removed. If this is a combined PR, the title and description should reflect all changes included.

Copilot uses AI. Check for mistakes.
void hookRunner
.runSessionEnd(
{
sessionId: previousSessionEntry.sessionId,
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The session_end hook is called with messageCount hardcoded to 0 when replacing an existing session. This means plugins won't receive accurate information about how many messages were in the ended session. Consider loading the actual message count from the session's message array or session file before calling the hook, or document this limitation if the message count is intentionally unavailable at this point in the session lifecycle.

Suggested change
sessionId: previousSessionEntry.sessionId,
sessionId: previousSessionEntry.sessionId,
// NOTE: The accurate message count for the ended session is not available
// at this point in the lifecycle, so we intentionally report 0 here.
// Plugins should treat this as "unknown" rather than a literal count.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants