From d5154b8eec848b45b4719d9f2ed3aaaaa2afe00a Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 21 Apr 2026 13:02:58 -0700 Subject: [PATCH] fix(codex): recover session lifecycle from hooks --- README.md | 2 + hooks/codex/handler.js | 457 +++++++++++++++++++++++++++------------ hooks/codex/handler.ts | 237 +++++++++++++++++--- hooks/codex/package.json | 2 +- 4 files changed, 526 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index 73cfc8f..8762c5c 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,8 @@ The `hooks/codex/` directory contains a TypeScript handler for Codex CLI telemet - prompt-submit hooks map user prompts into the next `run.start` - usage payloads emit both `run.end.payload.usage` and a `metric.snapshot` event +The Codex handler persists lightweight session state across hook subprocesses. If Codex only delivers later-stage hooks for a session, the handler can recover by emitting synthetic `session.start`/`run.start` events before the first `run.end` or usage snapshot. Full-fidelity lifecycle tracking still depends on configuring Codex session lifecycle hooks, not just `notify`. + Sample Codex hook configuration lives in [hooks/codex/hooks.json](/home/will/lab/agentmon/hooks/codex/hooks.json). On the local Codex CLI version we checked (`0.116.0`), `notify` is confirmed. Online reports suggest prompt-submit hooks may appear as `userpromptsubmit` or `userPromptSubmit`, so the sample config includes those aliases. The current Codex integration does not assume tool or subagent span hooks exist. If a newer Codex CLI exposes official tool/span hooks, they can be added separately without changing the run/session flow above. diff --git a/hooks/codex/handler.js b/hooks/codex/handler.js index 997b854..ca17c56 100755 --- a/hooks/codex/handler.js +++ b/hooks/codex/handler.js @@ -1,16 +1,13 @@ #!/usr/bin/env node + +// handler.ts +import { randomUUID as randomUUID2 } from "node:crypto"; +import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir, hostname } from "node:os"; +import { join } from "node:path"; + +// ../shared/lib.ts import { randomUUID } from "node:crypto"; -import { hostname } from "node:os"; -const INGEST_URL = process.env.AGENTMON_INGEST_URL || "http://localhost:8080"; -const FRAMEWORK = process.env.AGENTMON_FRAMEWORK || "codex"; -const HOST = process.env.AGENTMON_HOST || hostname(); -const BATCH_SIZE = 10; -const FLUSH_MS = 2e3; -const FETCH_TIMEOUT_MS = 500; -let buffer = []; -let flushTimer = null; -let isFlushing = false; -const activeRuns = /* @__PURE__ */ new Map(); function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -50,22 +47,209 @@ 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 || "codex"; +var HOST = process.env.AGENTMON_HOST || hostname(); +var { enqueue, flush } = createTransport(INGEST_URL); +var STATE_DIR = join(homedir(), ".agentmon-state", "codex"); +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 {}; + } +} +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 { + } +} +function getContext(input) { + return isRecord(input.context) ? input.context : {}; +} +function getSessionRecord(input) { + return isRecord(input.session) ? input.session : {}; +} +function getConversationRecord(input) { + return isRecord(input.conversation) ? input.conversation : {}; +} function getSessionKey(input) { + const context = getContext(input); + const session = getSessionRecord(input); + const conversation = getConversationRecord(input); return pickString( input.id, input.session, input.sessionId, input.session_id, + input.sessionID, input.threadId, input.thread_id, + input.threadID, input.chatId, input.chat_id, input.conversationId, - input.conversation_id + input.conversation_id, + context.id, + context.session, + context.sessionKey, + context.sessionId, + context.session_id, + context.threadId, + context.thread_id, + context.conversationId, + context.conversation_id, + session.id, + session.key, + session.sessionId, + session.session_id, + conversation.id, + conversation.sessionId, + conversation.session_id, + conversation.conversationId, + conversation.conversation_id ); } function getUsage(input) { - const usage = isRecord(input.usage) ? input.usage : isRecord(input.llm) ? input.llm : isRecord(input.tokens) ? input.tokens : isRecord(input.llm_usage) ? input.llm_usage : void 0; + const context = getContext(input); + const usage = isRecord(input.usage) ? input.usage : isRecord(input.llm) ? input.llm : isRecord(input.tokens) ? input.tokens : isRecord(input.llm_usage) ? input.llm_usage : isRecord(context.usage) ? context.usage : isRecord(context.llm) ? context.llm : isRecord(context.tokens) ? context.tokens : isRecord(context.llm_usage) ? context.llm_usage : void 0; if (!usage) return void 0; const result = {}; @@ -92,64 +276,70 @@ function getUsage(input) { return Object.keys(result).length > 0 ? result : void 0; } function getModel(input) { + const context = getContext(input); return pickString( input.model, isRecord(input.llm) ? input.llm.model : void 0, - isRecord(input.usage) ? input.usage.model : void 0 + isRecord(input.usage) ? input.usage.model : void 0, + context.model, + isRecord(context.llm) ? context.llm.model : void 0, + isRecord(context.usage) ? context.usage.model : void 0 ); } -function buildEnvelope(type, sessionKey, opts = {}) { - const correlation = {}; - if (sessionKey) { - correlation.session_id = sessionKey; +function getPrompt(input) { + const context = getContext(input); + return pickString( + input.prompt, + input.text, + input.message, + context.prompt, + context.text, + context.message + ); +} +function getDuration(input) { + const context = getContext(input); + return pickNumber( + input.duration_ms, + input.elapsed_ms, + input.duration, + context.duration_ms, + context.elapsed_ms, + context.duration + ); +} +function getNotificationType(input) { + const context = getContext(input); + return pickString( + input.type, + input.notification_type, + input.event, + input.event_type, + context.type, + context.notification_type, + context.event, + context.event_type + ); +} +function saveSessionState(sessionKey, state) { + if (!sessionKey) { + return; } - if (opts.runId) { - correlation.run_id = opts.runId; + saveState(sessionKey, state); +} +function ensureSessionStarted(sessionKey, input, state) { + if (!sessionKey || state.sessionStarted) { + return state; } - 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 - } + enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, { + attributes: { + synthetic: true, + recovered_from: "codex-hook" } - }; - 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(); - } + })); + state.sessionStarted = true; + saveSessionState(sessionKey, state); + return state; } function enqueueMetricSnapshot(sessionKey, runId, usage, input) { if (!usage) { @@ -160,33 +350,46 @@ function enqueueMetricSnapshot(sessionKey, runId, usage, input) { if (model) { metrics.model = model; } - enqueue(buildEnvelope("metric.snapshot", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "metric.snapshot", sessionKey, { runId, payload: { metrics } })); } -function startRun(sessionKey, input) { - const runId = randomUUID(); +function startRun(sessionKey, input, state, synthetic = false) { + ensureSessionStarted(sessionKey, input, state); + const runId = randomUUID2(); if (sessionKey) { - activeRuns.set(sessionKey, runId); + state.runId = runId; + saveSessionState(sessionKey, state); } - enqueue(buildEnvelope("run.start", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "run.start", sessionKey, { runId, attributes: { - trigger: pickString(input.trigger_type, input.trigger, input.event, input.event_type) + trigger: pickString(input.trigger_type, input.trigger, input.event, input.event_type), + ...synthetic && { synthetic: true } }, payload: { - prompt_preview: truncate(pickString(input.prompt, input.message, input.text), 200) + prompt_preview: truncate(getPrompt(input), 200) } })); return runId; } -function endRun(sessionKey, runId, input, duration) { +function ensureRunStarted(sessionKey, input, state) { + if (!sessionKey) { + return void 0; + } + if (state.runId) { + return state.runId; + } + return startRun(sessionKey, input, state, true); +} +function endRun(sessionKey, state, input, duration) { + const runId = state.runId; if (!runId) { return; } const usage = getUsage(input); - enqueue(buildEnvelope("run.end", sessionKey, { + enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, { runId, payload: { status: "success", @@ -196,96 +399,73 @@ function endRun(sessionKey, runId, input, duration) { } })); enqueueMetricSnapshot(sessionKey, runId, usage, input); -} -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(); - } - } - } + state.runId = void 0; + saveSessionState(sessionKey, state); } async function handleSessionStart(input) { - const sessionKey = getSessionKey(input) || randomUUID(); - enqueue(buildEnvelope("session.start", sessionKey)); - startRun(sessionKey, input); + const sessionKey = getSessionKey(input) || randomUUID2(); + const state = loadState(sessionKey); + if (!state.sessionStarted) { + enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey)); + state.sessionStarted = true; + } + startRun(sessionKey, input, state); await flush(); } async function handleSessionEnd(input) { const sessionKey = getSessionKey(input); - const runId = sessionKey ? activeRuns.get(sessionKey) : void 0; + const state = sessionKey ? loadState(sessionKey) : {}; const usage = getUsage(input); - const duration = pickNumber(input.duration_ms, input.elapsed_ms, input.duration); - endRun(sessionKey, runId, input, duration); - enqueue(buildEnvelope("session.end", sessionKey, { + const duration = getDuration(input); + ensureSessionStarted(sessionKey, input, state); + if (!state.runId && sessionKey) { + ensureRunStarted(sessionKey, input, state); + } + endRun(sessionKey, state, input, duration); + enqueue(buildEnvelope(FRAMEWORK, HOST, "session.end", sessionKey, { payload: { model: getModel(input), ...usage && { usage } } })); - enqueueMetricSnapshot(sessionKey, runId, usage, input); - activeRuns.delete(sessionKey || ""); + if (sessionKey) { + clearState(sessionKey); + } await flush(); } async function handlePromptSubmit(input) { const sessionKey = getSessionKey(input); - const runId = sessionKey ? activeRuns.get(sessionKey) : void 0; - const duration = pickNumber(input.elapsed_ms, input.duration_ms, input.duration); - const prompt = pickString(input.prompt, input.text, input.message); - if (runId && prompt) { - endRun(sessionKey, runId, input, duration); + const state = sessionKey ? loadState(sessionKey) : {}; + const duration = getDuration(input); + const prompt = getPrompt(input); + ensureSessionStarted(sessionKey, input, state); + if (state.runId && prompt) { + endRun(sessionKey, state, input, duration); } - startRun(sessionKey, input); + startRun(sessionKey, input, state, !state.sessionStarted); await flush(); } async function handleNotification(input) { const sessionKey = getSessionKey(input); - const notificationType = pickString(input.type, input.notification_type, input.event, input.event_type); + const state = sessionKey ? loadState(sessionKey) : {}; + const notificationType = getNotificationType(input); const usage = getUsage(input); - const duration = pickNumber(input.duration_ms, input.elapsed_ms); + const duration = getDuration(input); + ensureSessionStarted(sessionKey, input, state); if (notificationType === "agent-turn-complete" || notificationType === "Done" || notificationType === "turn.complete") { - const runId = sessionKey ? activeRuns.get(sessionKey) : void 0; - endRun(sessionKey, runId, input, duration); - if (pickString(input.prompt, input.message, input.text)) { - startRun(sessionKey, input); + if (!state.runId && sessionKey) { + ensureRunStarted(sessionKey, input, state); + } + endRun(sessionKey, state, input, duration); + if (getPrompt(input)) { + startRun(sessionKey, input, state); } } else if (usage) { - enqueueMetricSnapshot(sessionKey, sessionKey ? activeRuns.get(sessionKey) : void 0, usage, input); + enqueueMetricSnapshot(sessionKey, state.runId, usage, input); } await flush(); } -const handler = async () => { +var handler = async () => { const args = process.argv.slice(2); const hookType = args[0] || "unknown"; let input = {}; @@ -319,13 +499,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/codex/handler.ts b/hooks/codex/handler.ts index 8c07b41..158d2d8 100644 --- a/hooks/codex/handler.ts +++ b/hooks/codex/handler.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node import { randomUUID } from 'node:crypto'; -import { hostname } from 'node:os'; +import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { homedir, hostname } from 'node:os'; +import { join } from 'node:path'; import { type Dict, isRecord, @@ -18,28 +20,108 @@ const HOST = process.env.AGENTMON_HOST || hostname(); const { enqueue, flush } = createTransport(INGEST_URL); -const activeRuns = new Map(); +interface SessionState { + sessionStarted?: boolean; + runId?: string; +} + +const STATE_DIR = join(homedir(), '.agentmon-state', 'codex'); + +function ensureStateDir() { + try { + mkdirSync(STATE_DIR, { recursive: true }); + } catch { + // Ignore state directory creation failures so hooks stay best-effort. + } +} + +function loadState(sessionKey: string): SessionState { + try { + const raw = readFileSync(join(STATE_DIR, sessionKey + '.json'), 'utf8'); + return JSON.parse(raw) as SessionState; + } catch { + return {}; + } +} + +function saveState(sessionKey: string, state: SessionState) { + ensureStateDir(); + try { + writeFileSync(join(STATE_DIR, sessionKey + '.json'), JSON.stringify(state), 'utf8'); + } catch { + // Ignore state write failures so event emission can continue. + } +} + +function clearState(sessionKey: string) { + try { + unlinkSync(join(STATE_DIR, sessionKey + '.json')); + } catch { + // Ignore state cleanup failures. + } +} + +function getContext(input: Dict): Dict { + return isRecord(input.context) ? input.context : {}; +} + +function getSessionRecord(input: Dict): Dict { + return isRecord(input.session) ? input.session : {}; +} + +function getConversationRecord(input: Dict): Dict { + return isRecord(input.conversation) ? input.conversation : {}; +} function getSessionKey(input: Dict): string | undefined { + const context = getContext(input); + const session = getSessionRecord(input); + const conversation = getConversationRecord(input); + return pickString( input.id, input.session, input.sessionId, input.session_id, + input.sessionID, input.threadId, input.thread_id, + input.threadID, input.chatId, input.chat_id, input.conversationId, input.conversation_id, + context.id, + context.session, + context.sessionKey, + context.sessionId, + context.session_id, + context.threadId, + context.thread_id, + context.conversationId, + context.conversation_id, + session.id, + session.key, + session.sessionId, + session.session_id, + conversation.id, + conversation.sessionId, + conversation.session_id, + conversation.conversationId, + conversation.conversation_id, ); } function getUsage(input: Dict): Dict | undefined { + const context = getContext(input); const usage = isRecord(input.usage) ? input.usage : isRecord(input.llm) ? input.llm : isRecord(input.tokens) ? input.tokens : - isRecord(input.llm_usage) ? input.llm_usage : undefined; + isRecord(input.llm_usage) ? input.llm_usage : + isRecord(context.usage) ? context.usage : + isRecord(context.llm) ? context.llm : + isRecord(context.tokens) ? context.tokens : + isRecord(context.llm_usage) ? context.llm_usage : undefined; if (!usage) return undefined; const result: Dict = {}; @@ -58,13 +140,79 @@ function getUsage(input: Dict): Dict | undefined { } function getModel(input: Dict): string | undefined { + const context = getContext(input); return pickString( input.model, isRecord(input.llm) ? input.llm.model : undefined, isRecord(input.usage) ? input.usage.model : undefined, + context.model, + isRecord(context.llm) ? context.llm.model : undefined, + isRecord(context.usage) ? context.usage.model : undefined, ); } +function getPrompt(input: Dict): string | undefined { + const context = getContext(input); + return pickString( + input.prompt, + input.text, + input.message, + context.prompt, + context.text, + context.message, + ); +} + +function getDuration(input: Dict): number | undefined { + const context = getContext(input); + return pickNumber( + input.duration_ms, + input.elapsed_ms, + input.duration, + context.duration_ms, + context.elapsed_ms, + context.duration, + ); +} + +function getNotificationType(input: Dict): string | undefined { + const context = getContext(input); + return pickString( + input.type, + input.notification_type, + input.event, + input.event_type, + context.type, + context.notification_type, + context.event, + context.event_type, + ); +} + +function saveSessionState(sessionKey: string | undefined, state: SessionState) { + if (!sessionKey) { + return; + } + saveState(sessionKey, state); +} + +function ensureSessionStarted(sessionKey: string | undefined, input: Dict, state: SessionState): SessionState { + if (!sessionKey || state.sessionStarted) { + return state; + } + + enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.start', sessionKey, { + attributes: { + synthetic: true, + recovered_from: 'codex-hook', + }, + })); + + state.sessionStarted = true; + saveSessionState(sessionKey, state); + return state; +} + function enqueueMetricSnapshot(sessionKey: string | undefined, runId: string | undefined, usage: Dict | undefined, input: Dict) { if (!usage) { return; @@ -82,26 +230,41 @@ function enqueueMetricSnapshot(sessionKey: string | undefined, runId: string | u })); } -function startRun(sessionKey: string | undefined, input: Dict): string { +function startRun(sessionKey: string | undefined, input: Dict, state: SessionState, synthetic = false): string { + ensureSessionStarted(sessionKey, input, state); + const runId = randomUUID(); if (sessionKey) { - activeRuns.set(sessionKey, runId); + state.runId = runId; + saveSessionState(sessionKey, state); } enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.start', sessionKey, { runId, attributes: { trigger: pickString(input.trigger_type, input.trigger, input.event, input.event_type), + ...(synthetic && { synthetic: true }), }, payload: { - prompt_preview: truncate(pickString(input.prompt, input.message, input.text), 200), + prompt_preview: truncate(getPrompt(input), 200), }, })); return runId; } -function endRun(sessionKey: string | undefined, runId: string | undefined, input: Dict, duration?: number) { +function ensureRunStarted(sessionKey: string | undefined, input: Dict, state: SessionState): string | undefined { + if (!sessionKey) { + return undefined; + } + if (state.runId) { + return state.runId; + } + return startRun(sessionKey, input, state, true); +} + +function endRun(sessionKey: string | undefined, state: SessionState, input: Dict, duration?: number) { + const runId = state.runId; if (!runId) { return; } @@ -117,24 +280,35 @@ function endRun(sessionKey: string | undefined, runId: string | undefined, input }, })); enqueueMetricSnapshot(sessionKey, runId, usage, input); + + state.runId = undefined; + saveSessionState(sessionKey, state); } async function handleSessionStart(input: Dict) { const sessionKey = getSessionKey(input) || randomUUID(); + const state = loadState(sessionKey); - enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.start', sessionKey)); - startRun(sessionKey, input); + if (!state.sessionStarted) { + enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.start', sessionKey)); + state.sessionStarted = true; + } + startRun(sessionKey, input, state); await flush(); } async function handleSessionEnd(input: Dict) { const sessionKey = getSessionKey(input); - const runId = sessionKey ? activeRuns.get(sessionKey) : undefined; + const state = sessionKey ? loadState(sessionKey) : {}; const usage = getUsage(input); - const duration = pickNumber(input.duration_ms, input.elapsed_ms, input.duration); + const duration = getDuration(input); - endRun(sessionKey, runId, input, duration); + ensureSessionStarted(sessionKey, input, state); + if (!state.runId && sessionKey) { + ensureRunStarted(sessionKey, input, state); + } + endRun(sessionKey, state, input, duration); enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.end', sessionKey, { payload: { @@ -143,41 +317,48 @@ async function handleSessionEnd(input: Dict) { }, })); - enqueueMetricSnapshot(sessionKey, runId, usage, input); - - activeRuns.delete(sessionKey || ''); + if (sessionKey) { + clearState(sessionKey); + } await flush(); } async function handlePromptSubmit(input: Dict) { const sessionKey = getSessionKey(input); - const runId = sessionKey ? activeRuns.get(sessionKey) : undefined; - const duration = pickNumber(input.elapsed_ms, input.duration_ms, input.duration); - const prompt = pickString(input.prompt, input.text, input.message); + const state = sessionKey ? loadState(sessionKey) : {}; + const duration = getDuration(input); + const prompt = getPrompt(input); - if (runId && prompt) { - endRun(sessionKey, runId, input, duration); + ensureSessionStarted(sessionKey, input, state); + + if (state.runId && prompt) { + endRun(sessionKey, state, input, duration); } - startRun(sessionKey, input); + startRun(sessionKey, input, state, !state.sessionStarted); await flush(); } async function handleNotification(input: Dict) { const sessionKey = getSessionKey(input); - const notificationType = pickString(input.type, input.notification_type, input.event, input.event_type); + const state = sessionKey ? loadState(sessionKey) : {}; + const notificationType = getNotificationType(input); const usage = getUsage(input); - const duration = pickNumber(input.duration_ms, input.elapsed_ms); + const duration = getDuration(input); + + ensureSessionStarted(sessionKey, input, state); if (notificationType === 'agent-turn-complete' || notificationType === 'Done' || notificationType === 'turn.complete') { - const runId = sessionKey ? activeRuns.get(sessionKey) : undefined; - endRun(sessionKey, runId, input, duration); + if (!state.runId && sessionKey) { + ensureRunStarted(sessionKey, input, state); + } + endRun(sessionKey, state, input, duration); - if (pickString(input.prompt, input.message, input.text)) { - startRun(sessionKey, input); + if (getPrompt(input)) { + startRun(sessionKey, input, state); } } else if (usage) { - enqueueMetricSnapshot(sessionKey, sessionKey ? activeRuns.get(sessionKey) : undefined, usage, input); + enqueueMetricSnapshot(sessionKey, state.runId, usage, input); } await flush(); diff --git a/hooks/codex/package.json b/hooks/codex/package.json index fdad920..5d75c7f 100644 --- a/hooks/codex/package.json +++ b/hooks/codex/package.json @@ -8,7 +8,7 @@ "agentmon-codex-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": {