feat(hooks): emit per-run token usage and duration on run.end

The stats layer reads usage/duration only from run.end, but neither
framework populated them, so tokens/cost/avg-duration were always 0.

- hermes: accumulate token usage across each run's api-result calls in
  session state and attach the summed usage plus a computed duration_ms
  (from a stored runStartedAt) onto run.end. metric.snapshot emission is
  unchanged, so there is no double counting.
- claude-code: store runStartedAt and use it as a duration_ms fallback at
  all run.end sites. Usage is unavailable from CC hook inputs.

Live verification: a real hermes run now reports duration_ms and
total_tokens on run.end; dashboard tokens_today/avg_duration_ms, both
previously 0, now populate. cost_today stays 0 (no provider emits cost
through the hooks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-06-23 11:16:23 -07:00
parent 5014d89258
commit 478c7529a7
4 changed files with 130 additions and 29 deletions
+5 -5
View File
@@ -335,7 +335,7 @@ async function handleSessionStart(input) {
}
const runId = randomUUID2();
activeRuns.set(sessionKey, runId);
saveState(sessionKey, { runId, spans: {} });
saveState(sessionKey, { runId, runStartedAt: Date.now(), spans: {} });
const contextWindow = getContextWindow(input);
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, {
attributes: {
@@ -362,7 +362,7 @@ async function handleSessionEnd(input) {
const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0;
const usage = getUsage(input);
const contextWindow = getContextWindow(input);
const duration = pickNumber(input.duration_ms, input.elapsed_ms);
const duration = pickNumber(input.duration_ms, input.elapsed_ms) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : void 0);
if (runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
runId,
@@ -398,7 +398,7 @@ async function handlePromptSubmit(input) {
runId,
payload: {
status: "success",
duration_ms: pickNumber(input.elapsed_ms, input.duration_ms)
duration_ms: pickNumber(input.elapsed_ms, input.duration_ms) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : void 0)
}
}));
}
@@ -414,7 +414,7 @@ async function handlePromptSubmit(input) {
const newRunId = randomUUID2();
if (sessionKey) {
activeRuns.set(sessionKey, newRunId);
saveState(sessionKey, { runId: newRunId, spans: {} });
saveState(sessionKey, { runId: newRunId, runStartedAt: Date.now(), spans: {} });
}
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.start", sessionKey, {
runId: newRunId,
@@ -613,10 +613,10 @@ async function handleNotification(input) {
const notificationType = pickString(input.notification_type, input.type);
const usage = getUsage(input);
const contextWindow = getContextWindow(input);
const duration = pickNumber(input.duration_ms, input.elapsed_ms);
if (notificationType === "Done" || notificationType === "success") {
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0;
const duration = pickNumber(input.duration_ms, input.elapsed_ms) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : void 0);
if (runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
runId,