diff --git a/hooks/claude-code/handler.js b/hooks/claude-code/handler.js index 10ce6ee..413ef13 100755 --- a/hooks/claude-code/handler.js +++ b/hooks/claude-code/handler.js @@ -1,48 +1,13 @@ #!/usr/bin/env node -import { randomUUID } from "node:crypto"; + +// handler.ts +import { randomUUID as randomUUID2 } from "node:crypto"; import { hostname, homedir } from "node:os"; import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -const INGEST_URL = process.env.AGENTMON_INGEST_URL || "http://localhost:8080"; -const FRAMEWORK = process.env.AGENTMON_FRAMEWORK || "claude-code"; -const HOST = process.env.AGENTMON_HOST || hostname(); -const BATCH_SIZE = 10; -const FLUSH_MS = 2e3; -const FETCH_TIMEOUT_MS = 500; -const STATE_DIR = join(homedir(), ".agentmon-state"); -function ensureStateDir() { - try { - mkdirSync(STATE_DIR, { recursive: true }); - } catch { - } -} -function loadState(sessionKey) { - try { - const raw = readFileSync(join(STATE_DIR, sessionKey + ".json"), "utf8"); - return JSON.parse(raw); - } catch { - return { spans: {} }; - } -} -function saveState(sessionKey, state) { - ensureStateDir(); - try { - writeFileSync(join(STATE_DIR, sessionKey + ".json"), JSON.stringify(state), "utf8"); - } catch { - } -} -function clearState(sessionKey) { - try { - unlinkSync(join(STATE_DIR, sessionKey + ".json")); - } catch { - } -} -let buffer = []; -let flushTimer = null; -let isFlushing = false; -const activeRuns = /* @__PURE__ */ new Map(); -const activeSpans = /* @__PURE__ */ new Map(); -const activeSubagents = /* @__PURE__ */ new Map(); + +// ../shared/lib.ts +import { randomUUID } from "node:crypto"; function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -82,6 +47,163 @@ function safeJSONStringify(value) { return String(value); } } +function buildEnvelope(framework, host, type, sessionKey, opts = {}) { + const correlation = {}; + if (sessionKey) { + correlation.session_id = sessionKey; + } + if (opts.runId) { + correlation.run_id = opts.runId; + } + if (opts.spanId) { + correlation.span_id = opts.spanId; + } + if (opts.parentSpanId) { + correlation.parent_span_id = opts.parentSpanId; + } + const envelope = { + schema: { name: "agentmon.event", version: 1 }, + event: { + id: randomUUID(), + type, + ts: (/* @__PURE__ */ new Date()).toISOString(), + source: { + framework, + client_id: host, + host + } + } + }; + if (Object.keys(correlation).length > 0) { + envelope.correlation = correlation; + } + if (opts.attributes && Object.keys(opts.attributes).length > 0) { + envelope.attributes = opts.attributes; + } + if (opts.payload && Object.keys(opts.payload).length > 0) { + envelope.payload = opts.payload; + } + return envelope; +} +function createTransport(ingestUrl, opts) { + const batchSize = opts?.batchSize ?? 10; + const flushMs = opts?.flushMs ?? 2e3; + const fetchTimeoutMs = opts?.fetchTimeoutMs ?? 500; + let buffer = []; + let flushTimer = null; + let isFlushing = false; + async function postBatch(batch) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); + try { + await fetch(`${ingestUrl}/v1/events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(batch), + signal: controller.signal + }); + } finally { + clearTimeout(timeout); + } + } + function scheduleFlush() { + if (!flushTimer) { + flushTimer = setTimeout(() => { + void flush2(); + }, flushMs); + } + } + async function flush2() { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + if (isFlushing || buffer.length === 0) { + return; + } + isFlushing = true; + const batch = buffer.splice(0, batchSize); + try { + await postBatch(batch); + } catch { + console.debug(`[agentmon] failed to flush ${batch.length} events`); + } finally { + isFlushing = false; + if (buffer.length > 0) { + if (buffer.length >= batchSize) { + void flush2(); + } else { + scheduleFlush(); + } + } + } + } + function enqueue2(event) { + buffer.push(event); + if (buffer.length >= batchSize) { + void flush2(); + } else { + scheduleFlush(); + } + } + return { enqueue: enqueue2, flush: flush2 }; +} +async function readStdin() { + return new Promise((resolve) => { + let data = ""; + let done = false; + const timer = setTimeout(() => finish(data), 100); + const finish = (value) => { + if (done) + return; + done = true; + clearTimeout(timer); + resolve(value); + }; + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => finish(data)); + process.stdin.on("error", () => finish("")); + }); +} + +// handler.ts +var INGEST_URL = process.env.AGENTMON_INGEST_URL || "http://localhost:8080"; +var FRAMEWORK = process.env.AGENTMON_FRAMEWORK || "claude-code"; +var HOST = process.env.AGENTMON_HOST || hostname(); +var { enqueue, flush } = createTransport(INGEST_URL); +var STATE_DIR = join(homedir(), ".agentmon-state"); +function ensureStateDir() { + try { + mkdirSync(STATE_DIR, { recursive: true }); + } catch { + } +} +function loadState(sessionKey) { + try { + const raw = readFileSync(join(STATE_DIR, sessionKey + ".json"), "utf8"); + return JSON.parse(raw); + } catch { + return { spans: {} }; + } +} +function saveState(sessionKey, state) { + ensureStateDir(); + try { + writeFileSync(join(STATE_DIR, sessionKey + ".json"), JSON.stringify(state), "utf8"); + } catch { + } +} +function clearState(sessionKey) { + try { + unlinkSync(join(STATE_DIR, sessionKey + ".json")); + } catch { + } +} +var activeRuns = /* @__PURE__ */ new Map(); +var activeSpans = /* @__PURE__ */ new Map(); +var activeSubagents = /* @__PURE__ */ new Map(); function getSessionKey(input) { return pickString( input.sessionId, @@ -128,98 +250,6 @@ function getContextWindow(input) { result.tokens_remaining = stats.tokens_remaining; return Object.keys(result).length > 0 ? result : void 0; } -function buildEnvelope(type, sessionKey, opts = {}) { - const correlation = {}; - if (sessionKey) { - correlation.session_id = sessionKey; - } - if (opts.runId) { - correlation.run_id = opts.runId; - } - if (opts.spanId) { - correlation.span_id = opts.spanId; - } - if (opts.parentSpanId) { - correlation.parent_span_id = opts.parentSpanId; - } - const envelope = { - schema: { name: "agentmon.event", version: 1 }, - event: { - id: randomUUID(), - type, - ts: (/* @__PURE__ */ new Date()).toISOString(), - source: { - framework: FRAMEWORK, - client_id: HOST, - host: HOST - } - } - }; - if (Object.keys(correlation).length > 0) { - envelope.correlation = correlation; - } - if (opts.attributes && Object.keys(opts.attributes).length > 0) { - envelope.attributes = opts.attributes; - } - if (opts.payload && Object.keys(opts.payload).length > 0) { - envelope.payload = opts.payload; - } - return envelope; -} -function scheduleFlush() { - if (!flushTimer) { - flushTimer = setTimeout(() => { - void flush(); - }, FLUSH_MS); - } -} -function enqueue(event) { - buffer.push(event); - if (buffer.length >= BATCH_SIZE) { - void flush(); - } else { - scheduleFlush(); - } -} -async function postBatch(batch) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - try { - await fetch(`${INGEST_URL}/v1/events`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(batch), - signal: controller.signal - }); - } finally { - clearTimeout(timeout); - } -} -async function flush() { - if (flushTimer) { - clearTimeout(flushTimer); - flushTimer = null; - } - if (isFlushing || buffer.length === 0) { - return; - } - isFlushing = true; - const batch = buffer.splice(0, BATCH_SIZE); - try { - await postBatch(batch); - } catch { - console.debug(`[agentmon] failed to flush ${batch.length} events`); - } finally { - isFlushing = false; - if (buffer.length > 0) { - if (buffer.length >= BATCH_SIZE) { - void flush(); - } else { - scheduleFlush(); - } - } - } -} function emitError(sessionKey, runId, spanId, errorValue) { if (errorValue === void 0 || errorValue === null || errorValue === false) { return; @@ -227,7 +257,7 @@ function emitError(sessionKey, runId, spanId, errorValue) { const errorRecord = isRecord(errorValue) ? errorValue : {}; const message = pickString(errorRecord.message, errorRecord.error, errorValue) || "unknown"; const errType = pickString(errorRecord.type, errorRecord.code) || FRAMEWORK; - enqueue(buildEnvelope("error", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "error", sessionKey, { runId, spanId, payload: { @@ -239,15 +269,24 @@ function emitError(sessionKey, runId, spanId, errorValue) { })); } async function handleSessionStart(input) { - const sessionKey = getSessionKey(input) || randomUUID(); - const runId = randomUUID(); + const sessionKey = getSessionKey(input); + if (!sessionKey) { + console.error("[agentmon] ignoring claude-code session.start without session_id"); + return; + } + const hookEventName = pickString(input.hook_event_name); + if (hookEventName && hookEventName !== "SessionStart") { + console.error(`[agentmon] ignoring claude-code session.start with hook_event_name=${hookEventName}`); + return; + } + const runId = randomUUID2(); activeRuns.set(sessionKey, runId); saveState(sessionKey, { runId, spans: {} }); const contextWindow = getContextWindow(input); - enqueue(buildEnvelope("session.start", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, { attributes: contextWindow ? { context_window: contextWindow } : void 0 })); - enqueue(buildEnvelope("run.start", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "run.start", sessionKey, { runId, attributes: { trigger: pickString(input.trigger_type, input.trigger) @@ -266,7 +305,7 @@ async function handleSessionEnd(input) { const contextWindow = getContextWindow(input); const duration = pickNumber(input.duration_ms, input.elapsed_ms); if (runId) { - enqueue(buildEnvelope("run.end", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, { runId, payload: { status: "success", @@ -277,7 +316,7 @@ async function handleSessionEnd(input) { } })); } - enqueue(buildEnvelope("session.end", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "session.end", sessionKey, { payload: { ...usage && { usage }, ...contextWindow && { context_window: contextWindow } @@ -296,7 +335,7 @@ async function handlePromptSubmit(input) { const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0; const prompt = pickString(input.prompt, input.text, input.message); if (runId && prompt) { - enqueue(buildEnvelope("run.end", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, { runId, payload: { status: "success", @@ -304,12 +343,12 @@ async function handlePromptSubmit(input) { } })); } - const newRunId = randomUUID(); + const newRunId = randomUUID2(); if (sessionKey) { activeRuns.set(sessionKey, newRunId); saveState(sessionKey, { runId: newRunId, spans: {} }); } - enqueue(buildEnvelope("run.start", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "run.start", sessionKey, { runId: newRunId, attributes: { type: "user_prompt" @@ -326,7 +365,7 @@ async function handleToolStart(input) { const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0; const toolName = pickString(input.tool, input.tool_name, input.name) || "unknown"; const toolInput = isRecord(input.input) ? input.input : {}; - const spanId = randomUUID(); + const spanId = randomUUID2(); const spanKey = sessionKey ? sessionKey + ":" + toolName : void 0; if (spanKey) { activeSpans.set(spanKey, spanId); @@ -338,7 +377,7 @@ async function handleToolStart(input) { if (sessionKey) saveState(sessionKey, state); } - enqueue(buildEnvelope("span.start", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "span.start", sessionKey, { runId, spanId, attributes: { @@ -367,7 +406,7 @@ async function handleToolEnd(input) { if (sessionKey) saveState(sessionKey, state); } - enqueue(buildEnvelope("span.end", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "span.end", sessionKey, { runId, spanId, attributes: { @@ -391,7 +430,7 @@ async function handleSubagentStart(input) { const state = sessionKey ? loadState(sessionKey) : { spans: {} }; const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0; const agentName = pickString(input.agent, input.agent_name, input.name) || "unknown"; - const spanId = randomUUID(); + const spanId = randomUUID2(); if (sessionKey) { activeSubagents.set(sessionKey, { name: agentName, spanId }); state.subagent = { name: agentName, spanId }; @@ -401,7 +440,7 @@ async function handleSubagentStart(input) { state.runId = runId; saveState(sessionKey, state); } - enqueue(buildEnvelope("span.start", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "span.start", sessionKey, { runId, spanId, attributes: { @@ -430,7 +469,7 @@ async function handleSubagentStop(input) { if (sessionKey) saveState(sessionKey, state); } - enqueue(buildEnvelope("span.end", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "span.end", sessionKey, { runId, spanId, attributes: { @@ -451,7 +490,7 @@ async function handleCompactStart(input) { const sessionKey = getSessionKey(input); const state = sessionKey ? loadState(sessionKey) : { spans: {} }; const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0; - const spanId = randomUUID(); + const spanId = randomUUID2(); if (sessionKey) { activeSpans.set(sessionKey + ":compact", spanId); state.compactSpanId = spanId; @@ -461,7 +500,7 @@ async function handleCompactStart(input) { state.runId = runId; saveState(sessionKey, state); } - enqueue(buildEnvelope("span.start", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "span.start", sessionKey, { runId, spanId, attributes: { @@ -485,7 +524,7 @@ async function handleCompactEnd(input) { if (sessionKey) saveState(sessionKey, state); } - enqueue(buildEnvelope("span.end", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "span.end", sessionKey, { runId, spanId, attributes: { @@ -511,7 +550,7 @@ async function handleNotification(input) { const state = sessionKey ? loadState(sessionKey) : { spans: {} }; const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0; if (runId) { - enqueue(buildEnvelope("run.end", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, { runId, payload: { status: "success", @@ -525,7 +564,7 @@ async function handleNotification(input) { } await flush(); } -const handler = async () => { +var handler = async () => { const args = process.argv.slice(2); const hookType = args[0] || "unknown"; let input = {}; @@ -576,13 +615,4 @@ const handler = async () => { console.debug("[agentmon] handler error:", err); } }; -async function readStdin() { - return new Promise((resolve) => { - let data = ""; - process.stdin.on("data", (chunk) => data += chunk); - process.stdin.on("end", () => resolve(data)); - process.stdin.on("error", () => resolve("")); - setTimeout(() => resolve(data), 100); - }); -} handler(); diff --git a/hooks/claude-code/handler.ts b/hooks/claude-code/handler.ts index 4a4af0b..e0aa25e 100644 --- a/hooks/claude-code/handler.ts +++ b/hooks/claude-code/handler.ts @@ -124,7 +124,18 @@ function emitError(sessionKey: string | undefined, runId: string | undefined, sp } async function handleSessionStart(input: Dict) { - const sessionKey = getSessionKey(input) || randomUUID(); + const sessionKey = getSessionKey(input); + if (!sessionKey) { + console.error('[agentmon] ignoring claude-code session.start without session_id'); + return; + } + + const hookEventName = pickString(input.hook_event_name); + if (hookEventName && hookEventName !== 'SessionStart') { + console.error(`[agentmon] ignoring claude-code session.start with hook_event_name=${hookEventName}`); + return; + } + const runId = randomUUID(); activeRuns.set(sessionKey, runId); saveState(sessionKey, { runId, spans: {} }); diff --git a/hooks/claude-code/package.json b/hooks/claude-code/package.json index 9cda803..d989d83 100644 --- a/hooks/claude-code/package.json +++ b/hooks/claude-code/package.json @@ -8,7 +8,7 @@ "agentmon-handler": "./handler.js" }, "scripts": { - "build": "npx esbuild handler.ts --platform=node --format=esm --outfile=handler.js" + "build": "npx esbuild handler.ts --bundle --platform=node --format=esm --outfile=handler.js" }, "dependencies": {}, "devDependencies": {