From 9716a841c6c11f6b2ee509e65c70bcc11b8af099 Mon Sep 17 00:00:00 2001 From: Ruben de Smet Date: Wed, 20 May 2026 12:32:27 +0200 Subject: [PATCH 1/4] hooks(stop): force-exit so Stop hook never blocks Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- plugin/scripts/stop.mjs | 17 +++++++++-------- src/hooks/stop.ts | 27 ++++++++++++++++++--------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/plugin/scripts/stop.mjs b/plugin/scripts/stop.mjs index e0ffa3505..f6ec7ead6 100755 --- a/plugin/scripts/stop.mjs +++ b/plugin/scripts/stop.mjs @@ -23,14 +23,15 @@ async function main() { } if (isSdkChildContext(data)) return; const sessionId = data.session_id || "unknown"; - try { - await fetch(`${REST_URL}/agentmemory/summarize`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ sessionId }), - signal: AbortSignal.timeout(12e4) - }); - } catch {} + // Fire-and-forget; setTimeout below force-exits so Node doesn't + // keep the event loop alive waiting for the fetch. See src/hooks/stop.ts. + fetch(`${REST_URL}/agentmemory/summarize`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId }), + signal: AbortSignal.timeout(12e4) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); } main(); diff --git a/src/hooks/stop.ts b/src/hooks/stop.ts index 1f2f5b8a6..10131b175 100644 --- a/src/hooks/stop.ts +++ b/src/hooks/stop.ts @@ -39,16 +39,25 @@ async function main() { const sessionId = (data.session_id as string) || "unknown"; - try { - await fetch(`${REST_URL}/agentmemory/summarize`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ sessionId }), - signal: AbortSignal.timeout(120000), // Increased from 30s to 120s - }); - } catch { + // Fire-and-forget: don't block the Stop hook (and therefore Claude + // Code's next-prompt boundary) on the daemon's summarize work, which + // can run minutes per turn on long sessions / slow LLM providers. + // + // Subtlety: dropping the `await` is NOT enough. Node keeps the event + // loop alive waiting for any pending fetch() to settle, so without an + // explicit exit the script still hangs until the AbortSignal.timeout + // fires (~120s on slow providers). The unref'd setTimeout below + // forcibly exits 500ms after firing the request, which is plenty of + // time for the POST to flush to the local daemon's socket buffer. + 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(); } main(); \ No newline at end of file From 2fea70b17fd2ea98d5d8d4b687c9658856bc5249 Mon Sep 17 00:00:00 2001 From: Ruben de Smet Date: Wed, 20 May 2026 15:38:10 +0200 Subject: [PATCH 2/4] hooks: fire-and-forget + force-exit across all telemetry hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- plugin/scripts/_project-DDQ-L_E2.mjs | 26 +++++++++++ plugin/scripts/notification.mjs | 37 ++++++++-------- plugin/scripts/post-tool-failure.mjs | 37 ++++++++-------- plugin/scripts/post-tool-use.mjs | 39 ++++++++--------- plugin/scripts/prompt-submit.mjs | 29 ++++++------- plugin/scripts/session-end.mjs | 57 +++++++++++------------- plugin/scripts/subagent-start.mjs | 1 + plugin/scripts/subagent-stop.mjs | 37 ++++++++-------- plugin/scripts/task-completed.mjs | 41 +++++++++--------- src/hooks/notification.ts | 40 ++++++++--------- src/hooks/post-tool-failure.ts | 52 +++++++++++----------- src/hooks/post-tool-use.ts | 41 +++++++++--------- src/hooks/prompt-submit.ts | 32 +++++++------- src/hooks/session-end.ts | 65 +++++++++++++--------------- src/hooks/subagent-start.ts | 4 ++ src/hooks/subagent-stop.ts | 40 ++++++++--------- src/hooks/task-completed.ts | 48 ++++++++++---------- 17 files changed, 314 insertions(+), 312 deletions(-) create mode 100644 plugin/scripts/_project-DDQ-L_E2.mjs diff --git a/plugin/scripts/_project-DDQ-L_E2.mjs b/plugin/scripts/_project-DDQ-L_E2.mjs new file mode 100644 index 000000000..1ed59454c --- /dev/null +++ b/plugin/scripts/_project-DDQ-L_E2.mjs @@ -0,0 +1,26 @@ +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +//#region src/hooks/_project.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(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} + +//#endregion +export { resolveProject as t }; +//# sourceMappingURL=_project-DDQ-L_E2.mjs.map \ No newline at end of file diff --git a/plugin/scripts/notification.mjs b/plugin/scripts/notification.mjs index a318848d8..47b9d9587 100755 --- a/plugin/scripts/notification.mjs +++ b/plugin/scripts/notification.mjs @@ -24,25 +24,24 @@ async function main() { if (isSdkChildContext(data)) return; if (data.notification_type !== "permission_prompt") return; const sessionId = data.session_id || "unknown"; - try { - await 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: (/* @__PURE__ */ new Date()).toISOString(), - data: { - notification_type: data.notification_type, - title: data.title, - message: data.message - } - }), - signal: AbortSignal.timeout(2e3) - }); - } catch {} + 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: (/* @__PURE__ */ new Date()).toISOString(), + data: { + notification_type: data.notification_type, + title: data.title, + message: data.message + } + }), + signal: AbortSignal.timeout(2e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); } main(); diff --git a/plugin/scripts/post-tool-failure.mjs b/plugin/scripts/post-tool-failure.mjs index 3a593f3a6..51b9b8d9a 100755 --- a/plugin/scripts/post-tool-failure.mjs +++ b/plugin/scripts/post-tool-failure.mjs @@ -24,25 +24,24 @@ async function main() { if (isSdkChildContext(data)) return; if (data.is_interrupt) return; const sessionId = data.session_id || "unknown"; - try { - await 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: (/* @__PURE__ */ new Date()).toISOString(), - data: { - tool_name: data.tool_name, - tool_input: typeof data.tool_input === "string" ? data.tool_input.slice(0, 4e3) : JSON.stringify(data.tool_input ?? "").slice(0, 4e3), - error: typeof data.error === "string" ? data.error.slice(0, 4e3) : JSON.stringify(data.error ?? "").slice(0, 4e3) - } - }), - signal: AbortSignal.timeout(3e3) - }); - } catch {} + 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: (/* @__PURE__ */ new Date()).toISOString(), + data: { + tool_name: data.tool_name, + tool_input: typeof data.tool_input === "string" ? data.tool_input.slice(0, 4e3) : JSON.stringify(data.tool_input ?? "").slice(0, 4e3), + error: typeof data.error === "string" ? data.error.slice(0, 4e3) : JSON.stringify(data.error ?? "").slice(0, 4e3) + } + }), + signal: AbortSignal.timeout(3e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); } main(); diff --git a/plugin/scripts/post-tool-use.mjs b/plugin/scripts/post-tool-use.mjs index 5ebec6450..0305b5646 100755 --- a/plugin/scripts/post-tool-use.mjs +++ b/plugin/scripts/post-tool-use.mjs @@ -24,26 +24,25 @@ async function main() { if (isSdkChildContext(data)) return; const sessionId = data.session_id || "unknown"; const { imageData, cleanOutput } = extractImageData(data.tool_output); - try { - await 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: (/* @__PURE__ */ new Date()).toISOString(), - data: { - tool_name: data.tool_name, - tool_input: data.tool_input, - tool_output: truncate(cleanOutput, 8e3), - ...imageData ? { image_data: imageData } : {} - } - }), - signal: AbortSignal.timeout(3e3) - }); - } catch {} + 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: (/* @__PURE__ */ new Date()).toISOString(), + data: { + tool_name: data.tool_name, + tool_input: data.tool_input, + tool_output: truncate(cleanOutput, 8e3), + ...imageData ? { image_data: imageData } : {} + } + }), + signal: AbortSignal.timeout(3e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); } function isBase64Image(val) { return typeof val === "string" && (val.startsWith("data:image/") || val.startsWith("iVBORw0KGgo") || val.startsWith("/9j/")); diff --git a/plugin/scripts/prompt-submit.mjs b/plugin/scripts/prompt-submit.mjs index 18aa040a3..7ea5f92c6 100755 --- a/plugin/scripts/prompt-submit.mjs +++ b/plugin/scripts/prompt-submit.mjs @@ -23,21 +23,20 @@ async function main() { } if (isSdkChildContext(data)) return; const sessionId = data.session_id || "unknown"; - try { - await 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: (/* @__PURE__ */ new Date()).toISOString(), - data: { prompt: data.prompt } - }), - signal: AbortSignal.timeout(3e3) - }); - } catch {} + 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: (/* @__PURE__ */ new Date()).toISOString(), + data: { prompt: data.prompt } + }), + signal: AbortSignal.timeout(3e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); } main(); diff --git a/plugin/scripts/session-end.mjs b/plugin/scripts/session-end.mjs index 8e1de092e..45fd11af2 100755 --- a/plugin/scripts/session-end.mjs +++ b/plugin/scripts/session-end.mjs @@ -23,42 +23,35 @@ async function main() { } if (isSdkChildContext(data)) return; const sessionId = data.session_id || "unknown"; - try { - await fetch(`${REST_URL}/agentmemory/session/end`, { + fetch(`${REST_URL}/agentmemory/session/end`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId }), + signal: AbortSignal.timeout(3e4) + }).catch(() => {}); + if (process.env["CONSOLIDATION_ENABLED"] === "true") { + fetch(`${REST_URL}/agentmemory/crystals/auto`, { method: "POST", headers: authHeaders(), - body: JSON.stringify({ sessionId }), - signal: AbortSignal.timeout(3e4) - }); - } 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(6e4) - }); - } catch {} - try { - await fetch(`${REST_URL}/agentmemory/consolidate-pipeline`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ - tier: "all", - force: true - }), - signal: AbortSignal.timeout(12e4) - }); - } catch {} - } - if (process.env["CLAUDE_MEMORY_BRIDGE"] === "true") try { - await fetch(`${REST_URL}/agentmemory/claude-bridge/sync`, { + body: JSON.stringify({ olderThanDays: 0 }), + signal: AbortSignal.timeout(6e4) + }).catch(() => {}); + fetch(`${REST_URL}/agentmemory/consolidate-pipeline`, { method: "POST", headers: authHeaders(), - signal: AbortSignal.timeout(3e4) - }); - } catch {} + body: JSON.stringify({ + tier: "all", + force: true + }), + signal: AbortSignal.timeout(12e4) + }).catch(() => {}); + } + if (process.env["CLAUDE_MEMORY_BRIDGE"] === "true") fetch(`${REST_URL}/agentmemory/claude-bridge/sync`, { + method: "POST", + headers: authHeaders(), + signal: AbortSignal.timeout(3e4) + }).catch(() => {}); + setTimeout(() => process.exit(0), 1500).unref(); } main(); diff --git a/plugin/scripts/subagent-start.mjs b/plugin/scripts/subagent-start.mjs index db1434599..c0917da90 100755 --- a/plugin/scripts/subagent-start.mjs +++ b/plugin/scripts/subagent-start.mjs @@ -40,6 +40,7 @@ async function main() { }), signal: AbortSignal.timeout(TIMEOUT_MS) }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); } main(); diff --git a/plugin/scripts/subagent-stop.mjs b/plugin/scripts/subagent-stop.mjs index 7ec66a7d4..b459e863f 100755 --- a/plugin/scripts/subagent-stop.mjs +++ b/plugin/scripts/subagent-stop.mjs @@ -24,25 +24,24 @@ async function main() { if (isSdkChildContext(data)) return; const sessionId = data.session_id || "unknown"; const lastMsg = typeof data.last_assistant_message === "string" ? data.last_assistant_message.slice(0, 4e3) : ""; - try { - await fetch(`${REST_URL}/agentmemory/observe`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ - hookType: "subagent_stop", - sessionId, - project: data.cwd || process.cwd(), - cwd: data.cwd || process.cwd(), - timestamp: (/* @__PURE__ */ new Date()).toISOString(), - data: { - agent_id: data.agent_id, - agent_type: data.agent_type, - last_message: lastMsg - } - }), - signal: AbortSignal.timeout(2e3) - }); - } catch {} + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "subagent_stop", + sessionId, + project: data.cwd || process.cwd(), + cwd: data.cwd || process.cwd(), + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + agent_id: data.agent_id, + agent_type: data.agent_type, + last_message: lastMsg + } + }), + signal: AbortSignal.timeout(2e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); } main(); diff --git a/plugin/scripts/task-completed.mjs b/plugin/scripts/task-completed.mjs index e814f67c3..60d39eb76 100755 --- a/plugin/scripts/task-completed.mjs +++ b/plugin/scripts/task-completed.mjs @@ -23,27 +23,26 @@ async function main() { } if (isSdkChildContext(data)) return; const sessionId = data.session_id || "unknown"; - try { - await fetch(`${REST_URL}/agentmemory/observe`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ - hookType: "task_completed", - sessionId, - project: data.cwd || process.cwd(), - cwd: data.cwd || process.cwd(), - timestamp: (/* @__PURE__ */ new Date()).toISOString(), - data: { - task_id: data.task_id, - task_subject: data.task_subject, - task_description: typeof data.task_description === "string" ? data.task_description.slice(0, 2e3) : "", - teammate_name: data.teammate_name, - team_name: data.team_name - } - }), - signal: AbortSignal.timeout(2e3) - }); - } catch {} + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "task_completed", + sessionId, + project: data.cwd || process.cwd(), + cwd: data.cwd || process.cwd(), + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + task_id: data.task_id, + task_subject: data.task_subject, + task_description: typeof data.task_description === "string" ? data.task_description.slice(0, 2e3) : "", + teammate_name: data.teammate_name, + team_name: data.team_name + } + }), + signal: AbortSignal.timeout(2e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); } main(); diff --git a/src/hooks/notification.ts b/src/hooks/notification.ts index 6c4b7b81f..90662c888 100644 --- a/src/hooks/notification.ts +++ b/src/hooks/notification.ts @@ -33,27 +33,25 @@ async function main() { const sessionId = (data.session_id as string) || "unknown"; - try { - await 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 { - // fire and forget - } + // 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(); } main(); diff --git a/src/hooks/post-tool-failure.ts b/src/hooks/post-tool-failure.ts index 337aebdd1..8d43e99dd 100644 --- a/src/hooks/post-tool-failure.ts +++ b/src/hooks/post-tool-failure.ts @@ -33,33 +33,31 @@ async function main() { const sessionId = (data.session_id as string) || "unknown"; - try { - await 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 { - // fire and forget - } + // 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(); } main(); diff --git a/src/hooks/post-tool-use.ts b/src/hooks/post-tool-use.ts index 65afc8b1d..fe51f72d7 100644 --- a/src/hooks/post-tool-use.ts +++ b/src/hooks/post-tool-use.ts @@ -34,27 +34,26 @@ async function main() { const { imageData, cleanOutput } = extractImageData(data.tool_output); - try { - await 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 { - } + // 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(); } function isBase64Image(val: unknown): val is string { diff --git a/src/hooks/prompt-submit.ts b/src/hooks/prompt-submit.ts index 971b11be1..5c206e44c 100644 --- a/src/hooks/prompt-submit.ts +++ b/src/hooks/prompt-submit.ts @@ -32,23 +32,21 @@ async function main() { const sessionId = (data.session_id as string) || "unknown"; - try { - await 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 { - // fire and forget - } + // 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(); } main(); diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts index 31bef22e0..bbc59b9a7 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -32,48 +32,43 @@ async function main() { const sessionId = (data.session_id as string) || "unknown"; - try { - await fetch(`${REST_URL}/agentmemory/session/end`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ sessionId }), - signal: AbortSignal.timeout(30000), // Increased from 5s - }); - } catch { - // best-effort - } + // 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(); } main(); \ No newline at end of file diff --git a/src/hooks/subagent-start.ts b/src/hooks/subagent-start.ts index 3f730adb6..851eb60c1 100644 --- a/src/hooks/subagent-start.ts +++ b/src/hooks/subagent-start.ts @@ -56,6 +56,10 @@ async function main() { }), signal: AbortSignal.timeout(TIMEOUT_MS), }).catch(() => {}); + // Dropping `await` alone wasn't enough — Node keeps the event loop alive + // for the pending fetch. Force-exit so this hook never blocks Claude + // Code while the daemon (or its LLM provider) is slow. + setTimeout(() => process.exit(0), 500).unref(); } main(); diff --git a/src/hooks/subagent-stop.ts b/src/hooks/subagent-stop.ts index c555746e7..b91c26e35 100644 --- a/src/hooks/subagent-stop.ts +++ b/src/hooks/subagent-stop.ts @@ -36,27 +36,25 @@ async function main() { ? data.last_assistant_message.slice(0, 4000) : ""; - try { - await fetch(`${REST_URL}/agentmemory/observe`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ - hookType: "subagent_stop", - sessionId, - project: data.cwd || process.cwd(), - cwd: data.cwd || process.cwd(), - timestamp: new Date().toISOString(), - data: { - agent_id: data.agent_id, - agent_type: data.agent_type, - last_message: lastMsg, - }, - }), - signal: AbortSignal.timeout(2000), - }); - } catch { - // fire and forget - } + // 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: "subagent_stop", + sessionId, + project: data.cwd || process.cwd(), + cwd: data.cwd || process.cwd(), + timestamp: new Date().toISOString(), + data: { + agent_id: data.agent_id, + agent_type: data.agent_type, + last_message: lastMsg, + }, + }), + signal: AbortSignal.timeout(2000), + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); } main(); diff --git a/src/hooks/task-completed.ts b/src/hooks/task-completed.ts index 7f90f6e7f..277d4cfef 100644 --- a/src/hooks/task-completed.ts +++ b/src/hooks/task-completed.ts @@ -32,31 +32,29 @@ async function main() { const sessionId = (data.session_id as string) || "unknown"; - try { - await fetch(`${REST_URL}/agentmemory/observe`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ - hookType: "task_completed", - sessionId, - project: data.cwd || process.cwd(), - cwd: data.cwd || process.cwd(), - timestamp: new Date().toISOString(), - data: { - task_id: data.task_id, - task_subject: data.task_subject, - task_description: typeof data.task_description === "string" - ? data.task_description.slice(0, 2000) - : "", - teammate_name: data.teammate_name, - team_name: data.team_name, - }, - }), - signal: AbortSignal.timeout(2000), - }); - } catch { - // fire and forget - } + // 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: "task_completed", + sessionId, + project: data.cwd || process.cwd(), + cwd: data.cwd || process.cwd(), + timestamp: new Date().toISOString(), + data: { + task_id: data.task_id, + task_subject: data.task_subject, + task_description: typeof data.task_description === "string" + ? data.task_description.slice(0, 2000) + : "", + teammate_name: data.teammate_name, + team_name: data.team_name, + }, + }), + signal: AbortSignal.timeout(2000), + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); } main(); From 00b073ad6de2b47b80ed88e9984120bd1da76e26 Mon Sep 17 00:00:00 2001 From: Ruben de Smet Date: Wed, 20 May 2026 16:51:58 +0200 Subject: [PATCH 3/4] hooks(post-tool-use): read tool_response, fallback to tool_output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #561 — applying here so the hook fire-and-forget commit (3471847) plus this fix ship together as one coherent post-tool-use change. If #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) --- plugin/scripts/post-tool-use.mjs | 2 +- src/hooks/post-tool-use.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/plugin/scripts/post-tool-use.mjs b/plugin/scripts/post-tool-use.mjs index 0305b5646..4b4c78812 100755 --- a/plugin/scripts/post-tool-use.mjs +++ b/plugin/scripts/post-tool-use.mjs @@ -23,7 +23,7 @@ async function main() { } if (isSdkChildContext(data)) return; const sessionId = data.session_id || "unknown"; - const { imageData, cleanOutput } = extractImageData(data.tool_output); + const { imageData, cleanOutput } = extractImageData(data.tool_response ?? data.tool_output); fetch(`${REST_URL}/agentmemory/observe`, { method: "POST", headers: authHeaders(), diff --git a/src/hooks/post-tool-use.ts b/src/hooks/post-tool-use.ts index fe51f72d7..7492087c8 100644 --- a/src/hooks/post-tool-use.ts +++ b/src/hooks/post-tool-use.ts @@ -32,7 +32,15 @@ async function main() { const sessionId = (data.session_id as string) || "unknown"; - const { imageData, cleanOutput } = extractImageData(data.tool_output); + // Claude Code's actual PostToolUse payload uses `tool_response`, not + // `tool_output` — without this fallback, cleanOutput is always + // undefined and mem::compress silently fails XML validation on every + // real tool call (Rex57 caught this in #561). Keep `tool_output` as a + // legacy fallback so any older integration still using the deprecated + // field name continues to work. + const { imageData, cleanOutput } = extractImageData( + data.tool_response ?? data.tool_output, + ); // Fire-and-forget + force-exit; see src/hooks/stop.ts for rationale. fetch(`${REST_URL}/agentmemory/observe`, { From fef2cc83a1384499ead01b225fd5464f35293786 Mon Sep 17 00:00:00 2001 From: Ruben de Smet Date: Wed, 20 May 2026 18:12:54 +0200 Subject: [PATCH 4/4] docs(AGENTS.md): document the two hook script patterns (telemetry vs context) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit on #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) --- AGENTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ebcf35844..93183e474 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,10 @@ case "memory_your_tool": { ``` ### Hook Scripts -Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). They read JSON from stdin, make HTTP calls to the REST API, and exit. Always use `try/catch` with `AbortSignal.timeout()` for best-effort calls. +Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). They read JSON from stdin, make HTTP calls to the REST API, and exit. There are two patterns depending on whether Claude Code consumes the script's stdout: + +- **Context-injecting hooks** (`pre-tool-use`, `pre-compact`, `session-start`) write the recalled context to stdout for Claude Code to inject. These MUST use `try/catch` with `await fetch(..., { signal: AbortSignal.timeout(N) })` — the script has to wait for the response before exiting, and the timeout is the only bound on hang time. +- **Telemetry-only hooks** (`notification`, `post-tool-failure`, `post-tool-use`, `prompt-submit`, `stop`, `session-end`, `subagent-start`, `subagent-stop`, `task-completed`) write nothing to stdout. These MUST use fire-and-forget `fetch(..., { signal: AbortSignal.timeout(N) }).catch(() => {})` paired with `setTimeout(() => process.exit(0), N).unref()`. The unawaited fetch dispatches the request; the unref'd setTimeout force-exits the process after the request has been flushed to the local daemon's socket buffer (~500ms suffices). Without the setTimeout Node keeps the event loop alive waiting for any in-flight fetch to settle — which means the hook still blocks Claude Code's next-prompt boundary for up to the AbortSignal duration, exactly the bug fire-and-forget is meant to fix. ## Coding Standards