Skip to content

perf(hooks): fire-and-forget telemetry hooks (zero blocking on every assistant turn)#573

Open
efenex wants to merge 4 commits into
rohitg00:mainfrom
efenex:perf/hooks-fire-and-forget
Open

perf(hooks): fire-and-forget telemetry hooks (zero blocking on every assistant turn)#573
efenex wants to merge 4 commits into
rohitg00:mainfrom
efenex:perf/hooks-fire-and-forget

Conversation

@efenex
Copy link
Copy Markdown
Contributor

@efenex efenex commented May 20, 2026

The bug

Plugin hooks block Claude Code's next-prompt boundary on `await fetch(/observe)` (and `/summarize`, `/session/end` etc.) for the full `AbortSignal.timeout` — up to 120 s per turn when the LLM provider is slow (serverless cold-start, novita, etc.). Observed concretely as `✻ Lollygagging… (running stop hooks… 1/2 · 3m 31s)` in the Claude Code status line on long sessions.

The subtle bug under the bug

Dropping the `await` alone is not enough. Node keeps the event loop alive waiting for any pending `fetch()` to settle, so the script still hangs until the AbortSignal fires. The patched-but-still-awaited version was clocked at exactly 120.10 s in direct testing.

The fix

Pair the un-awaited fetch with `setTimeout(() => process.exit(0), N).unref()`:

  • The unref'd timer doesn't keep the loop alive when fetch settles fast (local daemon dedup → ~50 ms).
  • It force-exits the process N ms after the request is dispatched if the response hangs.
  • N = 500 ms for single-fetch hooks, 1500 ms for session-end (4 sequential requests).
  • The daemon still receives and processes the request server-side — only the response is dropped.

Hooks fixed

Hook Before After
`stop` up to 120 s 0.10 s
`session-end` up to 90 s (4 sequential awaits) 0.09 s
`post-tool-use` up to 3 s (every tool call) 0.10 s
`post-tool-failure` up to 3 s 0.15 s
`prompt-submit` up to 3 s 0.15 s
`subagent-start` up to 0.8 s 0.15 s
`subagent-stop` up to 2 s 0.11 s
`task-completed` up to 2 s 0.10 s
`notification` up to 2 s 0.12 s

Hooks left untouched

`pre-tool-use`, `pre-compact`, `session-start` write context to stdout that Claude Code consumes — force-exit would truncate the inject. They stay synchronous.

Bundled bug fix from #561

The 3rd commit picks up Rex57's #561: Claude Code's PostToolUse payload uses `tool_response`, not `tool_output` — so `mem::compress` was silently failing XML validation on every real tool call. Folded in here (with Co-Authored attribution) because:

  1. It's the same file (`src/hooks/post-tool-use.ts` + `plugin/scripts/post-tool-use.mjs`).
  2. The fire-and-forget pattern is meaningless if the underlying data is wrong.
  3. Independent rediscovery — happy to drop the 3rd commit if you merge fix: read tool_response instead of tool_output in PostToolUse hook #561 first; the rebase will be trivial.

Related

Test plan

  • `npm test` passes (`npx vitest run` 1140/1140)
  • Each patched hook returns in 90-150 ms when invoked directly with synthetic stdin (down from 0.8-120 s)
  • Combined Stop boundary (this PR's stop hook + the user-defined hook stack) = 0.12 s
  • Real Claude Code session: no more `✻ Lollygagging` on Stop boundaries

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor

    • Converted many hook and script telemetry requests to non-blocking dispatch with a short forced exit to avoid blocking the process.
  • New Features

    • Added a project resolution helper that infers project name from env or repository/workdir.
  • Improvements

    • Tool response handling now extracts image/data with a legacy fallback.
  • Documentation

    • Updated hook guidance to distinguish context-injecting vs telemetry-only patterns.

Review Change Stack

efenex and others added 3 commits May 20, 2026 17:27
The Stop hook POSTs to /agentmemory/summarize on every assistant turn.
Originally awaited the response with a 120s AbortSignal timeout, which
gated Claude Code's next-prompt boundary on the daemon's full
LLM-driven summarize cycle (~minutes on long sessions / slow providers
like serverless OpenAI-compat endpoints). Worst case: every turn
blocked for 120s.

Dropping the `await` alone wasn't enough: Node keeps the event loop
alive waiting for any pending fetch() to settle, so the script still
hung until the AbortSignal fired. Empirically the patched-but-still-
awaited version was clocked at 120.10s in direct testing, and Claude
Code's status line showed "running stop hooks 1/2 · 3m 31s" mid-session.

Fix: pair the fire-and-forget fetch with `setTimeout(() => process.exit(0),
500).unref()`. The unref'd timer doesn't keep the loop alive when the
fetch happens to settle fast (local daemon dedup returns in <100ms),
but force-exits the process 500ms after the request was dispatched if
the response hangs. 500ms is more than enough to flush a 1KB POST to
the local daemon's socket buffer; the daemon still receives and
processes the summarize request server-side.

Empirical result: stop.mjs invocation time dropped from 120.10s to
0.10s. Both Stop hooks combined (this one + agentmemory-import-on-stop.sh)
now total 0.12s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply the same pattern from hooks(stop) c83611d to every other
telemetry hook that does an `await fetch(...)` without consuming the
response. The Stop fix alone wasn't enough — slow LLM providers (e.g.
serverless OpenAI-compat endpoints) caused the same hang on Stop's
sister hooks, observed concretely as 30-40s blocks at SessionEnd and
unbounded latency on PostToolUse / PromptSubmit / Notification.

The bug: dropping `await` is not sufficient. Node keeps the event loop
alive waiting for any pending fetch() to settle. Without an explicit
exit, the script still blocks until the AbortSignal.timeout fires
(seconds to minutes depending on the per-hook cap).

The fix: pair the unawaited fetch with `setTimeout(() => process.exit(0),
N).unref()`. The unref'd timer doesn't keep the loop alive when fetch
settles fast, but force-exits the process N ms after the request was
dispatched if the response hangs. N is sized to flush the POST(s) to
the local daemon's socket buffer:

  - 500ms for single-fetch hooks: notification, post-tool-failure,
    post-tool-use, prompt-submit, subagent-start, subagent-stop,
    task-completed
  - 1500ms for session-end (4 sequential POSTs)

Empirically each patched hook now returns in 90-150ms regardless of
LLM-provider state (down from 0.8-30s+ per hook).

Hooks left untouched intentionally:
  - pre-tool-use, pre-compact, session-start — these write context to
    stdout for Claude Code to consume; force-exit would truncate
  - stop — already patched in c83611d

Also picks up the build outputs for the prior project-resolver
refactor (the .ts source was already committed but the rebuilt
plugin/scripts/*.mjs and the shared chunk plugin/scripts/_project-
DDQ-L_E2.mjs were not — they appear as bonus +/- in this diff since
the rebuild produced them alongside the hook-fix lines).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude Code's actual PostToolUse hook payload uses `tool_response`,
not `tool_output`. The hook was reading from the wrong field, so
`cleanOutput` was always `undefined`, the /observe POST reached the
server with no output value, and mem::compress silently failed XML
schema validation on every real tool call — observations got stored
with empty narratives, degrading memory_recall and starving the
consolidation pipeline.

Read `data.tool_response` with `data.tool_output` as a fallback so any
older integration still on the legacy field name keeps working.

Independently discovered and PR'd by Rex57 upstream as rohitg00#561 — applying
here so the hook fire-and-forget commit (3471847) plus this fix ship
together as one coherent post-tool-use change. If rohitg00#561 lands first,
this commit's diff for plugin/scripts/post-tool-use.mjs collapses to
the comment-line addition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 20, 2026

@efenex is attempting to deploy a commit to the rohitg00's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec67dcb5-8ace-4d81-ba16-94d1ece30a34

📥 Commits

Reviewing files that changed from the base of the PR and between 00b073a and fef2cc8.

📒 Files selected for processing (1)
  • AGENTS.md
✅ Files skipped from review due to trivial changes (1)
  • AGENTS.md

📝 Walkthrough

Walkthrough

This PR converts hook and plugin telemetry POSTs from awaited try/catch flows to non-blocking fire-and-forget fetch calls, adds short unref'd timers that force process exit, and introduces a project-name resolver utility.

Changes

Telemetry Fire-and-Forget Conversion

Layer / File(s) Summary
Project resolution utility
plugin/scripts/_project-DDQ-L_E2.mjs
Exports resolveProject() (alias t) that uses AGENTMEMORY_PROJECT_NAME or derives a basename via git rev-parse --show-toplevel, falling back to cwd basename.
Plugin script fire-and-forget telemetry dispatch
plugin/scripts/notification.mjs, plugin/scripts/post-tool-failure.mjs, plugin/scripts/post-tool-use.mjs, plugin/scripts/prompt-submit.mjs, plugin/scripts/session-end.mjs, plugin/scripts/stop.mjs, plugin/scripts/subagent-start.mjs, plugin/scripts/subagent-stop.mjs, plugin/scripts/task-completed.mjs
All plugin scripts changed from await fetch(...) with try/catch to fetch(...).catch(() => {}) and schedule a short unref'd setTimeout(...).unref() that calls process.exit(0); post-tool-use uses data.tool_response ?? data.tool_output; session-end parallelizes consolidation/bridge requests as best-effort.
TypeScript hooks fire-and-forget telemetry dispatch
src/hooks/notification.ts, src/hooks/post-tool-failure.ts, src/hooks/post-tool-use.ts, src/hooks/prompt-submit.ts, src/hooks/session-end.ts, src/hooks/stop.ts, src/hooks/subagent-start.ts, src/hooks/subagent-stop.ts, src/hooks/task-completed.ts
TypeScript hook implementations mirror plugin script changes: non-blocking fetch(...).catch(...), short unref'd process-exit timers, post-tool-use uses tool_response fallback, and session-end sends optional consolidation/bridge requests in parallel before exit.
Docs
AGENTS.md
Replaces prior guidance with two explicit patterns: context-injecting hooks (await + try/catch + timeout) vs telemetry-only hooks (fire-and-forget + unref'd exit timer).

Sequence Diagram(s)

sequenceDiagram
  participant Hook as Hook/Script
  participant Fetch as fetch()
  participant Observer as /agentmemory/observe
  participant Exit as setTimeout(process.exit)
  Hook->>Fetch: fetch(observer_request).catch(suppress)
  Fetch->>Observer: POST telemetry (fire-and-forget)
  Hook->>Exit: setTimeout(process.exit, ~500-1500ms).unref()
  Note over Exit: short unref'd timer forces process exit
  Observer-->>Fetch: response (ignored)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

A rabbit scurries through each hook and script,
Fire-and-forget—no more a blocking blip.
Timers unref, the process hops away,
Telemetry sent, then off to play. 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title precisely summarizes the main change: converting telemetry hooks from blocking to fire-and-forget behavior to eliminate latency on every assistant turn.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch perf/hooks-fire-and-forget

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (2)
src/hooks/post-tool-use.ts (1)

35-40: ⚡ Quick win

Trim this explanatory WHAT-comment and let naming/code carry it.

This block is very detailed and comment-heavy for behavior visible in code; please reduce it to a short rationale-only note (or remove it).

As per coding guidelines, "Avoid code comments explaining WHAT — use clear naming instead".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/post-tool-use.ts` around lines 35 - 40, The long explanatory block
above the tool output handling should be replaced with a short rationale-only
comment (or removed) that states the intent: preserve legacy fallback for
deprecated field names while preferring the current one; leave the actual
behavior in code (selecting tool_response with fallback to tool_output)
unchanged — update the comment near the logic that reads tool_response /
tool_output (and any mention of cleanOutput or mem::compress) to a one-line note
like "Prefer tool_response; keep fallback to deprecated tool_output for backward
compatibility."
src/hooks/stop.ts (1)

42-51: ⚡ Quick win

Please shorten/remove this WHAT-heavy inline narrative.

The behavior is clear from code; keep only a brief why-focused note if needed.

As per coding guidelines, "Avoid code comments explaining WHAT — use clear naming instead".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/stop.ts` around lines 42 - 51, The long WHAT-heavy comment above
the fire-and-forget logic in the Stop hook should be shortened: remove the
multi-paragraph narrative and replace it with a single brief why-focused note
that explains purpose (ensure the Stop hook doesn't hang waiting on a pending
fetch and we force exit shortly after the POST) and references the mechanism
used (the unref'd setTimeout paired with fetch() and AbortSignal.timeout).
Update the comment near the Stop hook / setTimeout(...).unref() so it is a
one-line rationale only, omitting the detailed step-by-step description.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugin/scripts/_project-DDQ-L_E2.mjs`:
- Line 8: The current assignment for dir uses cwd && cwd.trim() which throws if
cwd is not a string; change the guard to verify cwd is a string before calling
trim (e.g., use typeof cwd === "string" && cwd.trim()) so that non-string cwd
values fall back to process.cwd(); update the expression that sets dir (the
variable dir and the cwd check) to use this robust type-safe check.

In `@src/hooks/notification.ts`:
- Around line 36-54: Wrap the best-effort fetch call in a try/catch block while
preserving AbortSignal.timeout(2000) and the existing fire-and-forget behavior
(the fetch(...).catch(()=>{}) replacement), so that any synchronous or promise
throw is caught per hook contract; locate the top-level fetch invocation that
posts to `${REST_URL}/agentmemory/observe` (using authHeaders(),
AbortSignal.timeout, and JSON body with hookType "notification") and surround it
with try { await fetch(...); } catch (err) { /* swallow/log if needed */ } but
keep the non-blocking exit via setTimeout(() => process.exit(0), 500).unref().
Ensure you do not remove the AbortSignal.timeout call and that the catch handler
swallows errors to maintain best-effort semantics.

In `@src/hooks/post-tool-failure.ts`:
- Around line 36-60: Wrap the existing fire-and-forget fetch call in an
immediately-invoked async function that uses try/catch so the HTTP dispatch is
still non-blocking but any thrown errors are caught; inside the IIFE call
fetch(...) with the same options (including AbortSignal.timeout(3000)) and await
it, catch and ignore/log the error in the catch block, and remove the trailing
.catch(() => {}) on the original fetch call; keep the setTimeout(() =>
process.exit(0), 500).unref() exit behavior unchanged.

In `@src/hooks/post-tool-use.ts`:
- Around line 45-64: Wrap the best-effort fetch call in an explicit try/catch
block (instead of relying only on .catch()) so any synchronous or awaited errors
are handled per hooks guideline: call fetch(...) with the existing
AbortSignal.timeout(3000) and await it inside a try block, swallow/log errors in
the catch, and preserve the fire-and-forget behavior by not blocking termination
(keep the setTimeout(() => process.exit(0), 500).unref() afterwards). Update the
block that currently uses fetch(...)... .catch(() => {}) to use try { await
fetch(...) } catch (err) { /* handle/suppress error */ } while retaining the
same request payload and AbortSignal.timeout usage.

In `@src/hooks/prompt-submit.ts`:
- Around line 35-49: Wrap the fire-and-forget fetch dispatch in an
immediately-invoked async function and surround the awaited fetch call with
try/catch so errors are caught (preserve AbortSignal.timeout usage); locate the
current fetch invocation and replace the bare promise + .catch with an IIFE like
(async () => { try { await fetch(..., { signal: AbortSignal.timeout(3000) }) }
catch (err) { /* log or ignore */ } })(); keep the existing setTimeout(() =>
process.exit(0), 500).unref() behavior unchanged.

In `@src/hooks/session-end.ts`:
- Around line 35-71: Wrap each best-effort fetch dispatch in a try/catch so
synchronous errors are caught and to follow the hook guideline: around the calls
that reference REST_URL and authHeaders() (the session/end,
agentmemory/crystals/auto, agentmemory/consolidate-pipeline, and
agentmemory/claude-bridge/sync fetches) enclose the fetch invocation in try {
void fetch(...).catch(()=>{}); } catch (err) { /* swallow */ } while keeping the
AbortSignal.timeout(...) signal and the final setTimeout(() => process.exit(0),
1500).unref(); call unchanged.

In `@src/hooks/stop.ts`:
- Around line 52-60: Wrap the best-effort fetch dispatch to
`${REST_URL}/agentmemory/summarize` in an explicit try/catch that awaits the
call so any thrown error from fetch or AbortSignal.timeout(120000) is caught and
ignored; keep using authHeaders() and JSON.stringify({ sessionId }) and preserve
the AbortSignal.timeout(...) option and the existing setTimeout(() =>
process.exit(0), 500).unref() timer behavior. Ensure the try block contains the
await fetch(...) invocation and the catch block is empty (or logs a non-fatal
message) so the hook remains standalone and does not propagate errors.

---

Nitpick comments:
In `@src/hooks/post-tool-use.ts`:
- Around line 35-40: The long explanatory block above the tool output handling
should be replaced with a short rationale-only comment (or removed) that states
the intent: preserve legacy fallback for deprecated field names while preferring
the current one; leave the actual behavior in code (selecting tool_response with
fallback to tool_output) unchanged — update the comment near the logic that
reads tool_response / tool_output (and any mention of cleanOutput or
mem::compress) to a one-line note like "Prefer tool_response; keep fallback to
deprecated tool_output for backward compatibility."

In `@src/hooks/stop.ts`:
- Around line 42-51: The long WHAT-heavy comment above the fire-and-forget logic
in the Stop hook should be shortened: remove the multi-paragraph narrative and
replace it with a single brief why-focused note that explains purpose (ensure
the Stop hook doesn't hang waiting on a pending fetch and we force exit shortly
after the POST) and references the mechanism used (the unref'd setTimeout paired
with fetch() and AbortSignal.timeout). Update the comment near the Stop hook /
setTimeout(...).unref() so it is a one-line rationale only, omitting the
detailed step-by-step description.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2c4af9d3-3248-4311-91c2-20d2c221f1b2

📥 Commits

Reviewing files that changed from the base of the PR and between 93d1bdd and 00b073a.

📒 Files selected for processing (19)
  • plugin/scripts/_project-DDQ-L_E2.mjs
  • plugin/scripts/notification.mjs
  • plugin/scripts/post-tool-failure.mjs
  • plugin/scripts/post-tool-use.mjs
  • plugin/scripts/prompt-submit.mjs
  • plugin/scripts/session-end.mjs
  • plugin/scripts/stop.mjs
  • plugin/scripts/subagent-start.mjs
  • plugin/scripts/subagent-stop.mjs
  • plugin/scripts/task-completed.mjs
  • src/hooks/notification.ts
  • src/hooks/post-tool-failure.ts
  • src/hooks/post-tool-use.ts
  • src/hooks/prompt-submit.ts
  • src/hooks/session-end.ts
  • src/hooks/stop.ts
  • src/hooks/subagent-start.ts
  • src/hooks/subagent-stop.ts
  • src/hooks/task-completed.ts

function resolveProject(cwd) {
const explicit = process.env["AGENTMEMORY_PROJECT_NAME"];
if (explicit && explicit.trim()) return explicit.trim();
const dir = cwd && cwd.trim() ? cwd : process.cwd();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard cwd.trim() with a string check.

cwd && cwd.trim() throws when cwd is non-string (e.g., object/number), which bypasses your fallback path. Please harden this branch.

Suggested patch
-	const dir = cwd && cwd.trim() ? cwd : process.cwd();
+	const dir = typeof cwd === "string" && cwd.trim() ? cwd : process.cwd();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const dir = cwd && cwd.trim() ? cwd : process.cwd();
const dir = typeof cwd === "string" && cwd.trim() ? cwd : process.cwd();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugin/scripts/_project-DDQ-L_E2.mjs` at line 8, The current assignment for
dir uses cwd && cwd.trim() which throws if cwd is not a string; change the guard
to verify cwd is a string before calling trim (e.g., use typeof cwd === "string"
&& cwd.trim()) so that non-string cwd values fall back to process.cwd(); update
the expression that sets dir (the variable dir and the cwd check) to use this
robust type-safe check.

Comment thread src/hooks/notification.ts
Comment on lines +36 to +54
// Fire-and-forget + force-exit; see src/hooks/stop.ts for rationale.
fetch(`${REST_URL}/agentmemory/observe`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
hookType: "notification",
sessionId,
project: data.cwd || process.cwd(),
cwd: data.cwd || process.cwd(),
timestamp: new Date().toISOString(),
data: {
notification_type: data.notification_type,
title: data.title,
message: data.message,
},
}),
signal: AbortSignal.timeout(2000),
}).catch(() => {});
setTimeout(() => process.exit(0), 500).unref();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Wrap the best-effort fetch dispatch in try/catch to match hook contract.

This segment uses only .catch() on the Promise; the hook guideline requires try/catch around best-effort HTTP calls (while keeping AbortSignal.timeout()).

Suggested patch
-  fetch(`${REST_URL}/agentmemory/observe`, {
-    method: "POST",
-    headers: authHeaders(),
-    body: JSON.stringify({
-      hookType: "notification",
-      sessionId,
-      project: data.cwd || process.cwd(),
-      cwd: data.cwd || process.cwd(),
-      timestamp: new Date().toISOString(),
-      data: {
-        notification_type: data.notification_type,
-        title: data.title,
-        message: data.message,
-      },
-    }),
-    signal: AbortSignal.timeout(2000),
-  }).catch(() => {});
+  try {
+    fetch(`${REST_URL}/agentmemory/observe`, {
+      method: "POST",
+      headers: authHeaders(),
+      body: JSON.stringify({
+        hookType: "notification",
+        sessionId,
+        project: data.cwd || process.cwd(),
+        cwd: data.cwd || process.cwd(),
+        timestamp: new Date().toISOString(),
+        data: {
+          notification_type: data.notification_type,
+          title: data.title,
+          message: data.message,
+        },
+      }),
+      signal: AbortSignal.timeout(2000),
+    }).catch(() => {});
+  } catch {}

As per coding guidelines, "Hook scripts in src/hooks/ are standalone Node.js scripts without iii-sdk imports that read JSON from stdin, make HTTP calls to REST APIs, and always use try/catch with AbortSignal.timeout() for best-effort calls".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Fire-and-forget + force-exit; see src/hooks/stop.ts for rationale.
fetch(`${REST_URL}/agentmemory/observe`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
hookType: "notification",
sessionId,
project: data.cwd || process.cwd(),
cwd: data.cwd || process.cwd(),
timestamp: new Date().toISOString(),
data: {
notification_type: data.notification_type,
title: data.title,
message: data.message,
},
}),
signal: AbortSignal.timeout(2000),
}).catch(() => {});
setTimeout(() => process.exit(0), 500).unref();
// Fire-and-forget + force-exit; see src/hooks/stop.ts for rationale.
try {
fetch(`${REST_URL}/agentmemory/observe`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
hookType: "notification",
sessionId,
project: data.cwd || process.cwd(),
cwd: data.cwd || process.cwd(),
timestamp: new Date().toISOString(),
data: {
notification_type: data.notification_type,
title: data.title,
message: data.message,
},
}),
signal: AbortSignal.timeout(2000),
}).catch(() => {});
} catch {}
setTimeout(() => process.exit(0), 500).unref();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/notification.ts` around lines 36 - 54, Wrap the best-effort fetch
call in a try/catch block while preserving AbortSignal.timeout(2000) and the
existing fire-and-forget behavior (the fetch(...).catch(()=>{}) replacement), so
that any synchronous or promise throw is caught per hook contract; locate the
top-level fetch invocation that posts to `${REST_URL}/agentmemory/observe`
(using authHeaders(), AbortSignal.timeout, and JSON body with hookType
"notification") and surround it with try { await fetch(...); } catch (err) { /*
swallow/log if needed */ } but keep the non-blocking exit via setTimeout(() =>
process.exit(0), 500).unref(). Ensure you do not remove the AbortSignal.timeout
call and that the catch handler swallows errors to maintain best-effort
semantics.

Comment on lines +36 to +60
// Fire-and-forget + force-exit; see src/hooks/stop.ts for rationale.
fetch(`${REST_URL}/agentmemory/observe`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
hookType: "post_tool_failure",
sessionId,
project: data.cwd || process.cwd(),
cwd: data.cwd || process.cwd(),
timestamp: new Date().toISOString(),
data: {
tool_name: data.tool_name,
tool_input:
typeof data.tool_input === "string"
? data.tool_input.slice(0, 4000)
: JSON.stringify(data.tool_input ?? "").slice(0, 4000),
error:
typeof data.error === "string"
? data.error.slice(0, 4000)
: JSON.stringify(data.error ?? "").slice(0, 4000),
},
}),
signal: AbortSignal.timeout(3000),
}).catch(() => {});
setTimeout(() => process.exit(0), 500).unref();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Best-effort telemetry call should still be guarded with try/catch.

Please keep the fire-and-forget behavior, but wrap this HTTP dispatch in try/catch to satisfy the hook-script contract.

As per coding guidelines, "Hook scripts in src/hooks/ are standalone Node.js scripts without iii-sdk imports that read JSON from stdin, make HTTP calls to REST APIs, and always use try/catch with AbortSignal.timeout() for best-effort calls".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/post-tool-failure.ts` around lines 36 - 60, Wrap the existing
fire-and-forget fetch call in an immediately-invoked async function that uses
try/catch so the HTTP dispatch is still non-blocking but any thrown errors are
caught; inside the IIFE call fetch(...) with the same options (including
AbortSignal.timeout(3000)) and await it, catch and ignore/log the error in the
catch block, and remove the trailing .catch(() => {}) on the original fetch
call; keep the setTimeout(() => process.exit(0), 500).unref() exit behavior
unchanged.

Comment on lines +45 to +64
// Fire-and-forget + force-exit; see src/hooks/stop.ts for rationale.
fetch(`${REST_URL}/agentmemory/observe`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
hookType: "post_tool_use",
sessionId,
project: data.cwd || process.cwd(),
cwd: data.cwd || process.cwd(),
timestamp: new Date().toISOString(),
data: {
tool_name: data.tool_name,
tool_input: data.tool_input,
tool_output: truncate(cleanOutput, 8000),
...(imageData ? { image_data: imageData } : {}),
},
}),
signal: AbortSignal.timeout(3000),
}).catch(() => {});
setTimeout(() => process.exit(0), 500).unref();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Reintroduce try/catch around this best-effort hook request.

Current pattern uses Promise .catch() only; hook scripts here are required to use try/catch with AbortSignal.timeout() for REST calls.

As per coding guidelines, "Hook scripts in src/hooks/ are standalone Node.js scripts without iii-sdk imports that read JSON from stdin, make HTTP calls to REST APIs, and always use try/catch with AbortSignal.timeout() for best-effort calls".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/post-tool-use.ts` around lines 45 - 64, Wrap the best-effort fetch
call in an explicit try/catch block (instead of relying only on .catch()) so any
synchronous or awaited errors are handled per hooks guideline: call fetch(...)
with the existing AbortSignal.timeout(3000) and await it inside a try block,
swallow/log errors in the catch, and preserve the fire-and-forget behavior by
not blocking termination (keep the setTimeout(() => process.exit(0),
500).unref() afterwards). Update the block that currently uses fetch(...)...
.catch(() => {}) to use try { await fetch(...) } catch (err) { /*
handle/suppress error */ } while retaining the same request payload and
AbortSignal.timeout usage.

Comment on lines +35 to +49
// Fire-and-forget + force-exit; see src/hooks/stop.ts for rationale.
fetch(`${REST_URL}/agentmemory/observe`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
hookType: "prompt_submit",
sessionId,
project: data.cwd || process.cwd(),
cwd: data.cwd || process.cwd(),
timestamp: new Date().toISOString(),
data: { prompt: data.prompt },
}),
signal: AbortSignal.timeout(3000),
}).catch(() => {});
setTimeout(() => process.exit(0), 500).unref();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Keep fire-and-forget, but wrap the dispatch in try/catch.

For this hook path, best-effort REST calls need try/catch + timeout; .catch() alone doesn’t satisfy the project rule.

As per coding guidelines, "Hook scripts in src/hooks/ are standalone Node.js scripts without iii-sdk imports that read JSON from stdin, make HTTP calls to REST APIs, and always use try/catch with AbortSignal.timeout() for best-effort calls".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/prompt-submit.ts` around lines 35 - 49, Wrap the fire-and-forget
fetch dispatch in an immediately-invoked async function and surround the awaited
fetch call with try/catch so errors are caught (preserve AbortSignal.timeout
usage); locate the current fetch invocation and replace the bare promise +
.catch with an IIFE like (async () => { try { await fetch(..., { signal:
AbortSignal.timeout(3000) }) } catch (err) { /* log or ignore */ } })(); keep
the existing setTimeout(() => process.exit(0), 500).unref() behavior unchanged.

Comment thread src/hooks/session-end.ts
Comment on lines +35 to +71
// Fire-and-forget + force-exit. SessionEnd was the slowest hook by a
// wide margin — up to ~90s total when CONSOLIDATION_ENABLED+CLAUDE_
// MEMORY_BRIDGE were on, since 4 sequential awaits stacked. Now all
// four requests dispatch in parallel and the script exits 1500ms later
// regardless. The daemon still processes everything server-side.
fetch(`${REST_URL}/agentmemory/session/end`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ sessionId }),
signal: AbortSignal.timeout(30000),
}).catch(() => {});

if (process.env["CONSOLIDATION_ENABLED"] === "true") {
try {
await fetch(`${REST_URL}/agentmemory/crystals/auto`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ olderThanDays: 0 }),
signal: AbortSignal.timeout(60000), // Increased from 15s
});
} catch {}
fetch(`${REST_URL}/agentmemory/crystals/auto`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ olderThanDays: 0 }),
signal: AbortSignal.timeout(60000),
}).catch(() => {});

try {
await fetch(`${REST_URL}/agentmemory/consolidate-pipeline`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ tier: "all", force: true }),
signal: AbortSignal.timeout(120000), // Increased from 30s
});
} catch {}
fetch(`${REST_URL}/agentmemory/consolidate-pipeline`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ tier: "all", force: true }),
signal: AbortSignal.timeout(120000),
}).catch(() => {});
}

if (process.env["CLAUDE_MEMORY_BRIDGE"] === "true") {
try {
await fetch(`${REST_URL}/agentmemory/claude-bridge/sync`, {
method: "POST",
headers: authHeaders(),
signal: AbortSignal.timeout(30000), // Increased from 5s
});
} catch {
// best-effort
}
fetch(`${REST_URL}/agentmemory/claude-bridge/sync`, {
method: "POST",
headers: authHeaders(),
signal: AbortSignal.timeout(30000),
}).catch(() => {});
}

setTimeout(() => process.exit(0), 1500).unref();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Wrap this session-end dispatch block in try/catch guards.

Good move parallelizing requests, but this still needs explicit try/catch around the best-effort REST dispatches in this hook.

As per coding guidelines, "Hook scripts in src/hooks/ are standalone Node.js scripts without iii-sdk imports that read JSON from stdin, make HTTP calls to REST APIs, and always use try/catch with AbortSignal.timeout() for best-effort calls".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/session-end.ts` around lines 35 - 71, Wrap each best-effort fetch
dispatch in a try/catch so synchronous errors are caught and to follow the hook
guideline: around the calls that reference REST_URL and authHeaders() (the
session/end, agentmemory/crystals/auto, agentmemory/consolidate-pipeline, and
agentmemory/claude-bridge/sync fetches) enclose the fetch invocation in try {
void fetch(...).catch(()=>{}); } catch (err) { /* swallow */ } while keeping the
AbortSignal.timeout(...) signal and the final setTimeout(() => process.exit(0),
1500).unref(); call unchanged.

Comment thread src/hooks/stop.ts
Comment on lines +52 to +60
fetch(`${REST_URL}/agentmemory/summarize`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ sessionId }),
signal: AbortSignal.timeout(120000),
}).catch(() => {
// summarize is best-effort
}
});
setTimeout(() => process.exit(0), 500).unref();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add try/catch around this best-effort summarize call.

This hook keeps timeout (good), but still needs explicit try/catch around the REST dispatch per project hook conventions.

As per coding guidelines, "Hook scripts in src/hooks/ are standalone Node.js scripts without iii-sdk imports that read JSON from stdin, make HTTP calls to REST APIs, and always use try/catch with AbortSignal.timeout() for best-effort calls".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/stop.ts` around lines 52 - 60, Wrap the best-effort fetch dispatch
to `${REST_URL}/agentmemory/summarize` in an explicit try/catch that awaits the
call so any thrown error from fetch or AbortSignal.timeout(120000) is caught and
ignored; keep using authHeaders() and JSON.stringify({ sessionId }) and preserve
the AbortSignal.timeout(...) option and the existing setTimeout(() =>
process.exit(0), 500).unref() timer behavior. Ensure the try block contains the
await fetch(...) invocation and the catch block is empty (or logs a non-fatal
message) so the hook remains standalone and does not propagate errors.

…context)

CodeRabbit on rohitg00#573 flagged that the previous "always use try/catch with
await" contract is violated by this PR's fire-and-forget pattern. The
contract was the bug — try/catch+await blocks Claude Code's next-prompt
boundary on every assistant turn, by up to the AbortSignal.timeout
duration (120 s per Stop on slow LLM providers).

Update AGENTS.md to reflect the split:

- Context-injecting hooks (pre-tool-use, pre-compact, session-start):
  keep try/catch + await. Claude Code consumes their stdout, so the
  script MUST wait for the response before exiting.
- Telemetry-only hooks (notification, post-tool-failure, post-tool-use,
  prompt-submit, stop, session-end, subagent-start, subagent-stop,
  task-completed): fire-and-forget + setTimeout(...).unref() force-exit.
  The unawaited fetch dispatches the request; the unref'd timer
  force-exits the process after the request flushes to the local
  daemon's socket buffer (~500 ms). Without the timer, Node keeps the
  event loop alive waiting for the fetch — defeating the purpose.

The split matches the existing categorization in this PR's commits and
mirrors the per-hook decision documented in AGENTS.md's PR description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant