Bug
When closing Claude Code, users intermittently see:
SessionEnd hook [node "${CLAUDE_PLUGIN_ROOT}/scripts/session-end.mjs"] failed: Hook cancelled
Root cause
session-end.mjs fires fire-and-forget fetch() calls to the local daemon and then does:
setTimeout(() => process.exit(0), 1500).unref();
The intent (per AGENTS.md) is that the process force-exits after 1500ms so it does not block Claude Code's shutdown. But .unref() makes the timer passive — it does not prevent process exit, but it also does not force it. Node.js keeps the event loop alive as long as there are pending network requests, and the in-flight fetch() calls do exactly that.
Result: the process stays alive until the fetches complete or their AbortSignal timeouts fire:
/agentmemory/session/end: up to 30s
/agentmemory/crystals/auto (when CONSOLIDATION_ENABLED=true): up to 60s
/agentmemory/consolidate-pipeline (when CONSOLIDATION_ENABLED=true): up to 120s
Claude Code's hook timeout fires before the fetches settle and kills the process with Hook cancelled.
The bug is intermittent because when the daemon responds quickly the hook exits in time; when it is slow or busy the race is lost.
Fix
Remove .unref() from the setTimeout. An active (ref'd) timer keeps the event loop alive and guarantees the process exits after exactly 1500ms regardless of in-flight fetches. Since the fetches are fire-and-forget to a local daemon, exiting before they settle is the correct and intended behavior — matching what AGENTS.md documents.
- setTimeout(() => process.exit(0), 1500).unref();
+ setTimeout(() => process.exit(0), 1500);
Fix is in PR #1070.
Bug
When closing Claude Code, users intermittently see:
Root cause
session-end.mjsfires fire-and-forgetfetch()calls to the local daemon and then does:The intent (per
AGENTS.md) is that the process force-exits after 1500ms so it does not block Claude Code's shutdown. But.unref()makes the timer passive — it does not prevent process exit, but it also does not force it. Node.js keeps the event loop alive as long as there are pending network requests, and the in-flightfetch()calls do exactly that.Result: the process stays alive until the fetches complete or their
AbortSignaltimeouts fire:/agentmemory/session/end: up to 30s/agentmemory/crystals/auto(whenCONSOLIDATION_ENABLED=true): up to 60s/agentmemory/consolidate-pipeline(whenCONSOLIDATION_ENABLED=true): up to 120sClaude Code's hook timeout fires before the fetches settle and kills the process with
Hook cancelled.The bug is intermittent because when the daemon responds quickly the hook exits in time; when it is slow or busy the race is lost.
Fix
Remove
.unref()from thesetTimeout. An active (ref'd) timer keeps the event loop alive and guarantees the process exits after exactly 1500ms regardless of in-flight fetches. Since the fetches are fire-and-forget to a local daemon, exiting before they settle is the correct and intended behavior — matching whatAGENTS.mddocuments.Fix is in PR #1070.