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 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..4b4c78812 100755 --- a/plugin/scripts/post-tool-use.mjs +++ b/plugin/scripts/post-tool-use.mjs @@ -23,27 +23,26 @@ 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 {} + const { imageData, cleanOutput } = extractImageData(data.tool_response ?? data.tool_output); + 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/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/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..7492087c8 100644 --- a/src/hooks/post-tool-use.ts +++ b/src/hooks/post-tool-use.ts @@ -32,29 +32,36 @@ 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, + ); - 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/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 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();