#23019: fix(hooks): use globalThis singleton for internal hooks handlers Map
size: S
Cluster:
GlobalThis Integration Fixes
## Summary
- **Problem:** Workspace hooks registered for `message:received` (and other message events) silently never fire. The hook loader logs successful registration, but `triggerInternalHook` at dispatch time finds an empty handlers Map.
- **Why it matters:** The `message:received` internal hook bridge (backported in `f07bb8e8f`, refs #9387) is dead code — workspace hooks for message events are impossible to use. This blocks automation use cases like watchdog timestamps, message logging, and content filtering via the HOOK.md discovery system.
- **What changed:** The module-scoped `handlers` Map in `src/hooks/internal-hooks.ts` is now stored on `globalThis` so all bundler-split chunk duplicates share a single instance. Two tests added documenting the singleton contract.
- **What did NOT change (scope boundary):** No changes to hook loading, hook discovery, event dispatch, bundler config, or any other module. The fix is entirely within `internal-hooks.ts`.
## Change Type (select all)
- [x] Bug fix
## Scope (select all touched areas)
- [x] Gateway / orchestration
## Linked Issue/PR
- Closes #22790
- Closes #7067
- Related #8807
- Related #9387
## User-visible / Behavior Changes
Workspace hooks registered for `message:received` and `message:sent` events now fire as documented. No config or default changes.
## Security Impact (required)
- New permissions/capabilities? (`No`)
- Secrets/tokens handling changed? (`No`)
- New/changed network calls? (`No`)
- Command/tool execution surface changed? (`No`)
- Data access scope changed? (`No`)
## Repro + Verification
### Environment
- OS: Ubuntu 24.04.2 LTS (x64)
- Runtime/container: Node v25.6.1
- Model/provider: N/A (infrastructure bug)
- Integration/channel: Discord
- Relevant config: `hooks.internal.enabled: true`, workspace hook in `~/.openclaw/hooks/watchdog-timestamp/`
### Steps
1. Create a workspace hook with `metadata.openclaw.events: ["message:received"]` in HOOK.md
2. Implement handler.ts that writes a file on every `message:received` event
3. Enable the hook: `openclaw hooks enable watchdog-timestamp`
4. Restart gateway
5. Send a message through any channel (e.g., Discord)
6. Check if the handler executed (file should be written)
### Expected
Handler fires on every inbound message; file is written.
### Actual
Handler never fires. Hook loader logs `Registered hook: watchdog-timestamp -> message:received` but the handler is never called despite messages being processed.
## Evidence
### Root cause: bundler code-splitting duplicates module state
`tsdown` (Rolldown) code-splits `src/hooks/internal-hooks.ts` into multiple chunks. In OpenClaw 2026.2.19-2, three separate registry chunks exist:
```
registry-B-j4DRfe.js (21KB) — Map A
registry-BmY4gNy6.js (39KB) — Map B
registry-Bvgaapvc.js (39KB) — Map C
```
**Import chain (before fix):**
- Hook loader (`gateway-cli-*.js`): `import { registerInternalHook } from "./registry-B-j4DRfe.js"` → writes to Map A
- Discord dispatcher (`pi-embedded-*.js`): `import { triggerInternalHook } from "./registry-BmY4gNy6.js"` → reads from Map B
- Map B is empty. Hook never fires.
**Diagnostic patch output:**
```
[DIAG:pi-embedded-CHb5giY2.js] message:received bridge sessionKey="agent:main:discord:channel:1466192485440164011"
```
Confirms `triggerInternalHook` IS called with a valid sessionKey, but the Map it reads is empty.
**After fix:**
All chunk-duplicated copies reference `globalThis.__openclaw_internal_hooks__`. The first copy to execute creates the Map; all subsequent copies find and reuse it. Verified: 5 chunks contain the code, all emit `globalThis[GLOBAL_KEY] ?? (globalThis[GLOBAL_KEY] = new Map())`.
### Test results
- 2 new tests pass (singleton contract, register→trigger round-trip)
- 29/31 total pass (2 pre-existing failures in error-logging mocks, same on `main`)
- `pnpm check`: 0 warnings, 0 errors
- `pnpm build`: succeeds, no regressions
## Human Verification (required)
- **Verified scenarios:** Confirmed handlers Map is duplicated across chunks via `grep` on compiled output. Confirmed `triggerInternalHook` is called but finds empty Map via diagnostic `console.error` patch on 4 dispatch files (`pi-embedded-*.js`, `reply-*.js`, `subagent-registry-*.js`). Confirmed handler works standalone via Node dynamic import test. Confirmed globalThis pattern unifies Maps across chunks post-fix.
- **Edge cases checked:** `clearInternalHooks()` calls `handlers.clear()` (in-place), not `handlers = new Map()` — globalThis reference remains valid. Multiple gateway restarts tested.
- **What was NOT verified:** Cannot run full E2E in CI without a live Discord bot. The bundler duplication pattern may change with future tsdown/Rolldown versions (the globalThis approach is defensive regardless).
## Compatibility / Migration
- Backward compatible? (`Yes`)
- Config/env changes? (`No`)
- Migration needed? (`No`)
## Failure Recovery (if this breaks)
- Revert this single commit to restore the module-scoped Map
- `src/hooks/internal-hooks.ts` is the only changed source file
- Known bad symptom: if `globalThis.__openclaw_internal_hooks__` collides with another module (extremely unlikely given the namespaced key)
## Risks and Mitigations
- **Risk:** `globalThis` pollution with a well-namespaced key.
- **Mitigation:** Key is `__openclaw_internal_hooks__` — double-underscore prefixed, openclaw-namespaced, unlikely to collide. This is a standard pattern for cross-chunk singletons (used by React, Vue, etc.).
---
**AI-assisted:** Root cause analysis and fix implementation by AI agents (Claude Opus 4.6). Diagnostic methodology, evidence chain, and verification performed by the agents with human oversight. The submitter (the dandelion cult) understands the code and the fix.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
Fixed workspace hooks for `message:received` and `message:sent` events by storing the internal hooks handlers Map on `globalThis` to prevent bundler code-splitting from creating separate Map instances across chunks. The fix ensures `registerInternalHook` (hook loader) and `triggerInternalHook` (message dispatcher) share the same Map when they're split into different bundler chunks.
<h3>Confidence Score: 5/5</h3>
- This PR is safe to merge with no blocking issues
- The fix is minimal, well-tested, and directly addresses the root cause. The globalThis pattern is a standard approach for cross-chunk singletons used by major frameworks. The implementation correctly preserves the Map reference in `clearInternalHooks()` using `.clear()`. Two new tests document the singleton behavior. Only minor style suggestions for code readability.
- No files require special attention
<sub>Last reviewed commit: 2a2ec82</sub>
<!-- greptile_other_comments_section -->
<sub>(2/5) Greptile learns from your feedback when you react with thumbs up/down!</sub>
<!-- /greptile_comment -->
Most Similar PRs
#14746: fix(hooks): use globalThis for handler registry to survive bundler ...
by openperf · 2026-02-12
87.8%
#20541: fix(hooks): clear internal hooks before plugins register
by ramarnat · 2026-02-19
80.1%
#11817: fix(build): compile bundled hook handlers into dist
by AnonO6 · 2026-02-08
79.6%
#9603: fix: initialize global hook runner on plugin registry cache hit
by kevins88288 · 2026-02-05
79.3%
#19922: feat(hooks): add message:received and message:sent hook events
by NOVA-Openclaw · 2026-02-18
79.1%
#16915: fix: await compaction hooks with timeout to prevent cross-session d...
by maximalmargin · 2026-02-15
78.7%
#16618: feat: bridge message lifecycle hooks to workspace hook system
by DarlingtonDeveloper · 2026-02-14
78.4%
#17930: fix: evaluate tool_result_persist hooks lazily to avoid race condition
by TheArkifaneVashtorr · 2026-02-16
77.8%
#16960: perf: skip cache-busting for bundled hooks, use mtime for workspace...
by mudrii · 2026-02-15
77.6%
#13471: fix: security audit distinguishes internal hooks from external webh...
by jarvisz8 · 2026-02-10
77.4%