Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions plugin/scripts/_project-DDQ-L_E2.mjs
Original file line number Diff line number Diff line change
@@ -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();
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.

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
37 changes: 18 additions & 19 deletions plugin/scripts/notification.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
37 changes: 18 additions & 19 deletions plugin/scripts/post-tool-failure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
41 changes: 20 additions & 21 deletions plugin/scripts/post-tool-use.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/"));
Expand Down
29 changes: 14 additions & 15 deletions plugin/scripts/prompt-submit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
57 changes: 25 additions & 32 deletions plugin/scripts/session-end.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
17 changes: 9 additions & 8 deletions plugin/scripts/stop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
1 change: 1 addition & 0 deletions plugin/scripts/subagent-start.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ async function main() {
}),
signal: AbortSignal.timeout(TIMEOUT_MS)
}).catch(() => {});
setTimeout(() => process.exit(0), 500).unref();
}
main();

Expand Down
37 changes: 18 additions & 19 deletions plugin/scripts/subagent-stop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading