Compare commits
10 Commits
f8bec2d6d5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ebc944702f | |||
| 69eb87ebc9 | |||
| 478c7529a7 | |||
| 5014d89258 | |||
| c44e7fe72e | |||
| 8753c0c9d5 | |||
| 1b01f0b0cd | |||
| 27d40ce28f | |||
| 78376bdd83 | |||
| db73eca6fd |
@@ -5,5 +5,7 @@
|
||||
/web-ui
|
||||
|
||||
/swarm-monitor
|
||||
/event-processor
|
||||
/openclaw-monitor
|
||||
/hooks/*/node_modules/
|
||||
/build/
|
||||
|
||||
@@ -7,7 +7,7 @@ Telemetry and monitoring system for AI agent activity across [OpenClaw](https://
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ OpenClaw VMs │
|
||||
│ (zap, orb, sun) │
|
||||
│ (zap) │
|
||||
│ │
|
||||
│ hooks/agentmon/ │
|
||||
│ → handler.ts │
|
||||
@@ -187,7 +187,7 @@ The hook is deployed to each VM at `~/.openclaw/hooks/agentmon/`. Two environmen
|
||||
|
||||
```bash
|
||||
AGENTMON_INGEST_URL=http://192.168.122.1:8080
|
||||
AGENTMON_VM_NAME=zap # or orb, sun
|
||||
AGENTMON_VM_NAME=zap
|
||||
```
|
||||
|
||||
Deployment is automated via Ansible — see the [swarm ansible playbook](https://gitea-http.taildb3494.ts.net/will/swarm) `playbooks/customize.yml`.
|
||||
@@ -218,6 +218,20 @@ The `hooks/gemini/` directory contains a TypeScript handler for Gemini CLI telem
|
||||
|
||||
Sample Gemini hook configuration lives in [hooks/gemini/hooks.json](/home/will/lab/agentmon/hooks/gemini/hooks.json). Install the handler from that directory so the `agentmon-gemini-handler` binary is available, then point Gemini CLI at the sample hook config and set `AGENTMON_INGEST_URL` to your ingest gateway.
|
||||
|
||||
## Hermes Hook
|
||||
|
||||
The `hooks/hermes/` directory contains a TypeScript handler for Hermes Agent shell-hook telemetry. The current integration maps Hermes hook events into agentmon's session/run/span model:
|
||||
|
||||
- `on_session_start` maps to `session.start`
|
||||
- `pre_llm_call` maps to `run.start`
|
||||
- `post_llm_call` maps to `run.end`
|
||||
- `pre_tool_call` maps to `span.start`
|
||||
- `post_tool_call` maps to `span.end`
|
||||
- `post_api_request` maps usage payloads to `metric.snapshot`
|
||||
- `on_session_finalize` maps to `session.end`
|
||||
|
||||
Sample Hermes hook configuration lives in [hooks/hermes/hooks.yaml](/home/will/lab/agentmon/hooks/hermes/hooks.yaml). Install the handler from that directory so the `agentmon-hermes-handler` binary is available, then merge the sample `hooks:` block into `~/.hermes/config.yaml` and set `AGENTMON_INGEST_URL` to your ingest gateway.
|
||||
|
||||
## Go SDK
|
||||
|
||||
Emit events from Go applications:
|
||||
|
||||
@@ -207,6 +207,15 @@ func main() {
|
||||
if nextCursor != nil {
|
||||
resp["next_cursor"] = nextCursor.Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// Include total count on the first page (no cursor) so the UI can show "X of Y"
|
||||
if f.Cursor == nil {
|
||||
total, err := db.CountSessions(r.Context(), f)
|
||||
if err == nil {
|
||||
resp["total"] = total
|
||||
}
|
||||
}
|
||||
|
||||
httpx.WriteJSON(w, http.StatusOK, resp)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// ── components.js — shared UI primitives ─────────────────
|
||||
//
|
||||
// One-stop helpers for the repeating bar / pill / chart-header
|
||||
// patterns across pages. Renderers return HTML strings — callers
|
||||
// inject via innerHTML and wire events on the resulting DOM.
|
||||
|
||||
import { escapeHTML } from './utils.js';
|
||||
|
||||
// ── Bar primitives ───────────────────────────────────────
|
||||
|
||||
// barTrack — just the percentage bar (no label/count head).
|
||||
// value: numeric value to display
|
||||
// max: ceiling for percentage; 0/falsy means 0%
|
||||
// fwClass: optional framework slug (openclaw, claude-code, hermes, …)
|
||||
// — applied as fw-<slug> on the fill for color
|
||||
// size: 'xs' | 'sm' | 'md' | 'lg' (default 'md')
|
||||
// modifier: extra class on the fill (e.g. 'model', 'input', 'output')
|
||||
export function barTrack({ value = 0, max = 0, fwClass = '', size = 'md', modifier = '' } = {}) {
|
||||
const pct = max > 0 ? Math.min(100, (value / max) * 100).toFixed(1) : '0';
|
||||
const fillClasses = ['am-bar-fill'];
|
||||
if (fwClass) fillClasses.push('fw-' + fwClass);
|
||||
if (modifier) fillClasses.push(modifier);
|
||||
return `<div class="am-bar-track am-bar-track--${escapeHTML(size)}"><div class="${fillClasses.join(' ')}" style="width:${pct}%"></div></div>`;
|
||||
}
|
||||
|
||||
// barRow — head (name + count) plus track.
|
||||
// countDisplay overrides the rendered count text (e.g. "238" vs "238 events").
|
||||
export function barRow({ name = '', count = 0, countDisplay, max = 0, fwClass = '', size = 'sm', modifier = '' } = {}) {
|
||||
const display = countDisplay != null ? countDisplay : count;
|
||||
return `
|
||||
<div class="am-bar-row">
|
||||
<div class="am-bar-row-head">
|
||||
<span class="am-bar-row-name">${escapeHTML(String(name))}</span>
|
||||
<span class="am-bar-row-count">${escapeHTML(String(display))}</span>
|
||||
</div>
|
||||
${barTrack({ value: count, max, fwClass, size, modifier })}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// barRankList — ranked list of barRows wrapped in <ul class="am-bar-list">.
|
||||
// items: array of arbitrary objects
|
||||
// mapItem: fn(item) → { name, count, countDisplay?, fwClass?, modifier? }
|
||||
// maxOverride: ceiling override; default = max of items' counts
|
||||
// size: bar height (default 'xs' for ranked lists)
|
||||
// emptyText: shown when items is empty
|
||||
export function barRankList(items, { mapItem, maxOverride, size = 'xs', emptyText = 'No data' } = {}) {
|
||||
if (!items || items.length === 0) {
|
||||
return `<p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">${escapeHTML(emptyText)}</p>`;
|
||||
}
|
||||
const mapped = items.map(item => (mapItem ? mapItem(item) : { name: item.name, count: item.count }));
|
||||
const max = maxOverride != null ? maxOverride : Math.max(1, ...mapped.map(m => Number(m.count) || 0));
|
||||
const rows = mapped.map(m => `<li>${barRow({ ...m, max, size })}</li>`).join('');
|
||||
return `<ul class="am-bar-list">${rows}</ul>`;
|
||||
}
|
||||
|
||||
// ── Pill primitives ──────────────────────────────────────
|
||||
|
||||
// metricPill — small label + bold value, optional meta line.
|
||||
// value: pre-escaped HTML when valueHTML=true, else escaped as text
|
||||
// valueId: id attribute (for animateCounter targets)
|
||||
// variant: 'insight' | 'total' | 'range' | '' (default — standalone metric pill)
|
||||
// alert: adds .alert to the pill (turns value red)
|
||||
export function metricPill({ label, value = '-', valueId = '', valueHTML = false, meta = '', variant = '', alert = false } = {}) {
|
||||
const classes = ['am-pill'];
|
||||
if (variant) classes.push('am-pill--' + variant);
|
||||
if (alert) classes.push('alert');
|
||||
const idAttr = valueId ? ` id="${escapeHTML(valueId)}"` : '';
|
||||
const rendered = valueHTML ? value : escapeHTML(String(value));
|
||||
const metaLine = meta ? `<span class="am-pill-meta">${escapeHTML(String(meta))}</span>` : '';
|
||||
return `
|
||||
<div class="${classes.join(' ')}">
|
||||
<span class="am-pill-label">${escapeHTML(String(label))}</span>
|
||||
<strong class="am-pill-value"${idAttr}>${rendered}</strong>
|
||||
${metaLine}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// metricStrip — flex/grid container around a list of pill specs.
|
||||
// variant: '' | 'insights' (4-up grid) | 'totals' (tight inline pills)
|
||||
export function metricStrip(pills, { variant = '', className = '' } = {}) {
|
||||
const classes = ['am-pill-strip'];
|
||||
if (variant) classes.push('am-pill-strip--' + variant);
|
||||
if (className) classes.push(className);
|
||||
return `<div class="${classes.join(' ')}">${pills.map(p => metricPill(p)).join('')}</div>`;
|
||||
}
|
||||
|
||||
// ── Chart header ─────────────────────────────────────────
|
||||
|
||||
// chartHeader — title + optional subtitle on the left, raw controls HTML on the right.
|
||||
// controls is passed through unchanged (legend/buttons/tabs vary by chart).
|
||||
export function chartHeader({ title = '', subtitle = '', controls = '' } = {}) {
|
||||
const subtitleHTML = subtitle ? `<span class="am-chart-subtitle">${escapeHTML(subtitle)}</span>` : '';
|
||||
const controlsHTML = controls ? `<div class="am-chart-controls">${controls}</div>` : '';
|
||||
return `
|
||||
<div class="am-chart-header">
|
||||
<div class="am-chart-title-group">
|
||||
<span class="am-chart-title">${escapeHTML(title)}</span>
|
||||
${subtitleHTML}
|
||||
</div>
|
||||
${controlsHTML}
|
||||
</div>`;
|
||||
}
|
||||
@@ -46,6 +46,14 @@ let _agentsRenderTimer = null;
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────
|
||||
|
||||
function eventKindClass(eventType) {
|
||||
if (eventType === 'run.start' || eventType === 'run.end') return 'evt-run';
|
||||
if (eventType === 'span.start' || eventType === 'span.end') return 'evt-span';
|
||||
if (eventType === 'error') return 'evt-error';
|
||||
if (eventType === 'session.start' || eventType === 'session.end') return 'evt-session';
|
||||
return 'evt-other';
|
||||
}
|
||||
|
||||
function ensureAgentBucket(evt) {
|
||||
const identity = getAgentIdentity(evt);
|
||||
if (!identity.key) return null;
|
||||
@@ -258,7 +266,8 @@ function renderAgentLanes() {
|
||||
const opsHTML = ops.length > 0 ? `<div class="active-ops">${ops.map(op => {
|
||||
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
|
||||
const stale = elapsed > 300;
|
||||
const kindClass = op.kind === 'agent' || op.subType === 'subagent' ? ' subagent' : '';
|
||||
const kindClass = op.kind === 'agent' || op.subType === 'subagent' ? ' subagent'
|
||||
: op.kind === 'run' ? ' run' : '';
|
||||
return `
|
||||
<div class="active-op${stale ? ' stale' : ''}${kindClass}">
|
||||
<span class="active-op-dot"></span>
|
||||
@@ -276,7 +285,7 @@ function renderAgentLanes() {
|
||||
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
||||
|
||||
return `
|
||||
<div class="timeline-event">
|
||||
<div class="timeline-event ${eventKindClass(eventType)}">
|
||||
<div class="timeline-event-header">
|
||||
${getEventIcon(eventType)}
|
||||
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
||||
@@ -675,7 +684,7 @@ function renderAgentsLive() {
|
||||
</div>
|
||||
<div class="live-run-events">
|
||||
${group.events.map(evt => `
|
||||
<div class="timeline-event live-event">
|
||||
<div class="timeline-event live-event ${eventKindClass(getEnvelopeType(evt))}">
|
||||
<div class="timeline-event-header">
|
||||
${getEventIcon(getEnvelopeType(evt))}
|
||||
<span class="timeline-event-type">${escapeHTML(getEventLabel(getEnvelopeType(evt)))}</span>
|
||||
|
||||
@@ -39,6 +39,13 @@ import {
|
||||
import { clearErrorBadge } from '../palette.js';
|
||||
import { app, navigate, isRouteCurrent } from '../router.js';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
barRankList,
|
||||
barRow,
|
||||
metricPill,
|
||||
metricStrip,
|
||||
chartHeader,
|
||||
} from '../components.js';
|
||||
|
||||
// uPlot is loaded as a global IIFE; access via window.uPlot
|
||||
/* global uPlot */
|
||||
@@ -182,7 +189,13 @@ function renderSummaryCards() {
|
||||
const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0);
|
||||
const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 0;
|
||||
animateCounter('dash-error-rate', rate.toFixed(1) + '%');
|
||||
errorRateEl.classList.toggle('alert', rate > 5);
|
||||
const pill = errorRateEl.closest('.am-pill');
|
||||
if (pill) pill.classList.toggle('alert', rate > 5);
|
||||
}
|
||||
|
||||
if (document.getElementById('dash-cost-per-run')) {
|
||||
const avgCost = (s.runs_today || 0) > 0 ? (s.cost_today || 0) / s.runs_today : 0;
|
||||
animateCounter('dash-cost-per-run', avgCost ? formatCost(avgCost) : '$0.0000');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,12 +295,12 @@ function renderDashboardChartInsights() {
|
||||
}
|
||||
|
||||
const peakBucket = dashboardState.timeseries.series[stats.peakIndex];
|
||||
container.innerHTML = `
|
||||
<div class="chart-insight-pill"><span class="chart-insight-label">window total</span><strong>${escapeHTML(formatCount(stats.totalEvents))}</strong></div>
|
||||
<div class="chart-insight-pill"><span class="chart-insight-label">peak bucket</span><strong>${escapeHTML(formatCount(stats.peakTotal))}</strong><span class="chart-insight-meta">${escapeHTML(formatBucketLabel(peakBucket.ts))}</span></div>
|
||||
<div class="chart-insight-pill"><span class="chart-insight-label">mix</span><strong>${escapeHTML(formatCount(stats.totalRuns))}r / ${escapeHTML(formatCount(stats.totalTools))}t / ${escapeHTML(formatCount(stats.totalErrors))}e</strong></div>
|
||||
<div class="chart-insight-pill"><span class="chart-insight-label">bucket</span><strong>${escapeHTML(dashboardState.timeseries.bucket || '-')}</strong><span class="chart-insight-meta">${escapeHTML(String(stats.bucketCount))} points</span></div>
|
||||
`;
|
||||
container.innerHTML = metricStrip([
|
||||
{ label: 'window total', value: formatCount(stats.totalEvents), variant: 'insight' },
|
||||
{ label: 'peak bucket', value: formatCount(stats.peakTotal), meta: formatBucketLabel(peakBucket.ts), variant: 'insight' },
|
||||
{ label: 'mix', value: `${formatCount(stats.totalRuns)}r / ${formatCount(stats.totalTools)}t / ${formatCount(stats.totalErrors)}e`, variant: 'insight' },
|
||||
{ label: 'bucket', value: dashboardState.timeseries.bucket || '-', meta: stats.bucketCount + ' points', variant: 'insight' },
|
||||
], { variant: 'insights' });
|
||||
}
|
||||
|
||||
function renderDashboardChartHover(idx) {
|
||||
@@ -475,6 +488,10 @@ function tallyTool(evt) {
|
||||
if (attrs.span_kind === 'tool') {
|
||||
const name = attrs.name || 'unknown';
|
||||
dashboardState.toolCounts[name] = (dashboardState.toolCounts[name] || 0) + 1;
|
||||
const dur = Number(getEnvelopePayload(evt).duration_ms) || 0;
|
||||
if (dur > 0) {
|
||||
dashboardState.toolDurations[name] = (dashboardState.toolDurations[name] || 0) + dur;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -584,23 +601,11 @@ function renderTokenPanel() {
|
||||
<div class="token-stat-value">${escapeHTML(formatTokenCount(totalTokens))}</div>
|
||||
</div>
|
||||
<div class="token-io-bars">
|
||||
<div class="token-bar-row">
|
||||
<span class="token-bar-label">Input</span>
|
||||
<div class="token-bar-track">
|
||||
<div class="token-bar-fill input" style="width:${(inputTokens / maxIO * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
<span class="token-bar-count">${escapeHTML(formatTokenCount(inputTokens))}</span>
|
||||
</div>
|
||||
<div class="token-bar-row">
|
||||
<span class="token-bar-label">Output</span>
|
||||
<div class="token-bar-track">
|
||||
<div class="token-bar-fill output" style="width:${(outputTokens / maxIO * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
<span class="token-bar-count">${escapeHTML(formatTokenCount(outputTokens))}</span>
|
||||
</div>
|
||||
${barRow({ name: 'Input', count: inputTokens, countDisplay: formatTokenCount(inputTokens), max: maxIO, modifier: 'input', size: 'md' })}
|
||||
${barRow({ name: 'Output', count: outputTokens, countDisplay: formatTokenCount(outputTokens), max: maxIO, modifier: 'output', size: 'md' })}
|
||||
</div>
|
||||
<div class="token-cost-display">
|
||||
<span class="token-bar-label">Est. cost today</span>
|
||||
<span class="am-pill-label">Est. cost today</span>
|
||||
<strong>${escapeHTML(totalCost ? formatCost(totalCost) : '$0.0000')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -617,42 +622,34 @@ function renderLatencyPanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
const durSeries = ts.series.map(b => b.avg_duration_ms || 0).filter(v => v > 0);
|
||||
if (durSeries.length === 0) {
|
||||
container.innerHTML = '<p class="empty-state" style="padding:1rem">No run latency recorded yet</p>';
|
||||
const latencyBuckets = ts.series.filter(b => (b.tool_avg_ms || 0) > 0);
|
||||
if (latencyBuckets.length === 0) {
|
||||
container.innerHTML = '<p class="empty-state" style="padding:1rem">No tool latency recorded yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const durSeries = latencyBuckets.map(b => b.tool_avg_ms || 0);
|
||||
const avg = durSeries.reduce((a, b) => a + b, 0) / durSeries.length;
|
||||
const min = Math.min(...durSeries);
|
||||
const max = Math.max(...durSeries);
|
||||
const maxBar = max || 1;
|
||||
const p95 = Math.max(...latencyBuckets.map(b => b.tool_p95_ms || 0));
|
||||
const maxBar = Math.max(...durSeries) || 1;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="latency-panel">
|
||||
<div class="latency-range">
|
||||
<div class="latency-range-item">
|
||||
<span class="latency-range-label">Min</span>
|
||||
<span class="latency-range-val">${escapeHTML(formatDuration(min))}</span>
|
||||
</div>
|
||||
<div class="latency-range-item">
|
||||
<span class="latency-range-label">Avg</span>
|
||||
<span class="latency-range-val">${escapeHTML(formatDuration(avg))}</span>
|
||||
</div>
|
||||
<div class="latency-range-item">
|
||||
<span class="latency-range-label">Max</span>
|
||||
<span class="latency-range-val">${escapeHTML(formatDuration(max))}</span>
|
||||
</div>
|
||||
${metricPill({ label: 'Min', value: formatDuration(min), variant: 'range' })}
|
||||
${metricPill({ label: 'Avg', value: formatDuration(avg), variant: 'range' })}
|
||||
${metricPill({ label: 'P95', value: formatDuration(p95), variant: 'range' })}
|
||||
</div>
|
||||
<div class="latency-mini-bars">
|
||||
${durSeries.map((v, i) => {
|
||||
${latencyBuckets.map(b => {
|
||||
const v = b.tool_avg_ms || 0;
|
||||
const pct = (v / maxBar * 100).toFixed(1);
|
||||
const label = ts.series.filter(b => b.avg_duration_ms > 0)[i];
|
||||
const title = label ? formatBucketLabel(label.ts) + ': ' + formatDuration(v) : formatDuration(v);
|
||||
const title = formatBucketLabel(b.ts) + ': ' + formatDuration(v);
|
||||
return `<div class="latency-mini-bar" style="height:${pct}%" title="${escapeHTML(title)}"></div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div class="latency-range-label" style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-dim)">Avg run duration per bucket (${escapeHTML(ts.bucket || '-')})</div>
|
||||
<div class="am-pill-label" style="margin-top:0.5rem">Avg tool latency per bucket (${escapeHTML(ts.bucket || '-')})</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -675,21 +672,17 @@ function renderFrameworkBars() {
|
||||
|
||||
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors));
|
||||
|
||||
container.innerHTML = '<div class="fw-bars">' + entries.map(([name, stats]) => {
|
||||
container.innerHTML = '<div style="display:flex;flex-direction:column;gap:0.75rem;margin-top:0.25rem">' + entries.map(([name, stats]) => {
|
||||
const total = stats.runs + stats.tools + stats.errors;
|
||||
const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0;
|
||||
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
return `
|
||||
<div class="fw-bar-row">
|
||||
<div class="fw-bar-label">
|
||||
<span class="fw-bar-name">${escapeHTML(name)}</span>
|
||||
<span class="fw-bar-count">${total} events</span>
|
||||
</div>
|
||||
<div class="fw-bar-track">
|
||||
<div class="fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return barRow({
|
||||
name,
|
||||
count: total,
|
||||
countDisplay: total + ' events',
|
||||
max: maxTotal,
|
||||
fwClass: cssClass,
|
||||
size: 'lg',
|
||||
});
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
@@ -758,61 +751,28 @@ function renderDashFeed() {
|
||||
function renderDashTopTools() {
|
||||
const list = document.getElementById('dash-top-tools');
|
||||
if (!list) return;
|
||||
|
||||
const topTools = Object.entries(dashboardState.toolCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
|
||||
if (topTools.length === 0) {
|
||||
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No tool data yet</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
const maxCount = topTools[0]?.[1] || 1;
|
||||
list.innerHTML = topTools.map(([name, count]) => {
|
||||
const pct = (count / maxCount * 100).toFixed(1);
|
||||
return `
|
||||
<li>
|
||||
<div class="stat-list-header">
|
||||
<span class="stat-list-name">${escapeHTML(name)}</span>
|
||||
<span class="stat-list-count">${count}</span>
|
||||
</div>
|
||||
<div class="stat-list-bar-track">
|
||||
<div class="stat-list-bar-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
.slice(0, 10)
|
||||
.map(([name, count]) => {
|
||||
const durSum = dashboardState.toolDurations[name] || 0;
|
||||
const avg = count > 0 ? durSum / count : 0;
|
||||
const countDisplay = avg > 0
|
||||
? `${formatCount(count)} · ${formatDuration(avg)}`
|
||||
: formatCount(count);
|
||||
return { name, count, countDisplay };
|
||||
});
|
||||
list.innerHTML = barRankList(topTools, { emptyText: 'No tool data yet' });
|
||||
}
|
||||
|
||||
function renderDashTopModels() {
|
||||
const list = document.getElementById('dash-top-models');
|
||||
if (!list) return;
|
||||
|
||||
const topModels = Object.entries(dashboardState.modelCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
|
||||
if (topModels.length === 0) {
|
||||
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No model data yet</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
const maxCount = topModels[0]?.[1] || 1;
|
||||
list.innerHTML = topModels.map(([name, count]) => {
|
||||
const pct = (count / maxCount * 100).toFixed(1);
|
||||
return `
|
||||
<li>
|
||||
<div class="stat-list-header">
|
||||
<span class="stat-list-name">${escapeHTML(name)}</span>
|
||||
<span class="stat-list-count">${count}</span>
|
||||
</div>
|
||||
<div class="stat-list-bar-track">
|
||||
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
.slice(0, 10)
|
||||
.map(([name, count]) => ({ name, count, modifier: 'model' }));
|
||||
list.innerHTML = barRankList(topModels, { emptyText: 'No model data yet' });
|
||||
}
|
||||
|
||||
// ── Exports ──────────────────────────────────────────────
|
||||
@@ -828,6 +788,7 @@ export async function renderDashboard(routeToken) {
|
||||
recentEvents: [],
|
||||
recentEventIDs: new Set(),
|
||||
toolCounts: {},
|
||||
toolDurations: {},
|
||||
modelCounts: {},
|
||||
rightPanelMode: localStorage.getItem('agentmon:dash:right-panel') || 'framework',
|
||||
};
|
||||
@@ -858,34 +819,21 @@ export async function renderDashboard(routeToken) {
|
||||
<div class="summary-card-sub" id="dash-errors-sub"> </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics-strip">
|
||||
<div class="metric-pill">
|
||||
<span class="metric-pill-label">Tokens today</span>
|
||||
<span class="metric-pill-value" id="dash-tokens-today">-</span>
|
||||
</div>
|
||||
<div class="metric-pill">
|
||||
<span class="metric-pill-label">Cost today</span>
|
||||
<span class="metric-pill-value" id="dash-cost-today">-</span>
|
||||
</div>
|
||||
<div class="metric-pill">
|
||||
<span class="metric-pill-label">Avg run duration</span>
|
||||
<span class="metric-pill-value" id="dash-avg-duration">-</span>
|
||||
</div>
|
||||
<div class="metric-pill">
|
||||
<span class="metric-pill-label">Error rate</span>
|
||||
<span class="metric-pill-value metric-pill-alert" id="dash-error-rate">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:1.25rem">${metricStrip([
|
||||
{ label: 'Tokens today', valueId: 'dash-tokens-today' },
|
||||
{ label: 'Cost today', valueId: 'dash-cost-today' },
|
||||
{ label: 'Avg run duration', valueId: 'dash-avg-duration' },
|
||||
{ label: 'Error rate', valueId: 'dash-error-rate' },
|
||||
{ label: 'Cost / run', valueId: 'dash-cost-per-run' },
|
||||
])}</div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
|
||||
<div class="vm-strip" id="dash-vm-strip"></div>
|
||||
<div class="charts-row">
|
||||
<div class="chart-panel">
|
||||
<div class="chart-header">
|
||||
<div class="chart-title-group">
|
||||
<span class="chart-title">Event Rate</span>
|
||||
<span class="chart-subtitle">Runs, tool spans, and errors over time</span>
|
||||
</div>
|
||||
<div class="chart-header-controls">
|
||||
${chartHeader({
|
||||
title: 'Event Rate',
|
||||
subtitle: 'Runs, tool spans, and errors over time',
|
||||
controls: `
|
||||
<div class="chart-legend">
|
||||
<span class="chart-legend-item"><span class="chart-legend-dot total"></span>total</span>
|
||||
<span class="chart-legend-item"><span class="chart-legend-dot" style="background:#34d399"></span>runs</span>
|
||||
@@ -901,21 +849,21 @@ export async function renderDashboard(routeToken) {
|
||||
<button class="window-btn" data-w="6h">6h</button>
|
||||
<button class="window-btn" data-w="24h">24h</button>
|
||||
<button class="window-btn" data-w="7d">7d</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-insights" id="dash-chart-insights"></div>
|
||||
</div>`,
|
||||
})}
|
||||
<div id="dash-chart-insights" style="margin-bottom:0.9rem"></div>
|
||||
<div class="chart-container" id="dash-chart"></div>
|
||||
<div class="chart-hover-panel" id="dash-chart-hover"></div>
|
||||
</div>
|
||||
<div class="chart-panel right-panel">
|
||||
<div class="chart-header">
|
||||
<div class="right-panel-tabs" id="dash-right-tabs">
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button>
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button>
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button>
|
||||
</div>
|
||||
</div>
|
||||
${chartHeader({
|
||||
controls: `
|
||||
<div class="right-panel-tabs" id="dash-right-tabs">
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button>
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button>
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button>
|
||||
</div>`,
|
||||
})}
|
||||
<div class="right-panel-body" id="dash-right-panel">
|
||||
<p class="empty-state" style="padding:1rem">Loading...</p>
|
||||
</div>
|
||||
@@ -923,28 +871,20 @@ export async function renderDashboard(routeToken) {
|
||||
</div>
|
||||
<div class="bottom-panels">
|
||||
<div class="feed-panel">
|
||||
<div class="chart-header">
|
||||
<span class="chart-title">Recent Activity</span>
|
||||
</div>
|
||||
${chartHeader({ title: 'Recent Activity' })}
|
||||
<div class="timeline" id="dash-feed">
|
||||
<p class="empty-state" style="padding:1rem">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tools-panel">
|
||||
<div class="chart-header">
|
||||
<span class="chart-title">Top Usage</span>
|
||||
</div>
|
||||
${chartHeader({ title: 'Top Usage' })}
|
||||
<div class="usage-rank-group">
|
||||
<div class="usage-rank-header">Tools</div>
|
||||
<ul class="stat-list" id="dash-top-tools">
|
||||
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
|
||||
</ul>
|
||||
<div id="dash-top-tools"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
|
||||
</div>
|
||||
<div class="usage-rank-group">
|
||||
<div class="usage-rank-header">Models</div>
|
||||
<ul class="stat-list" id="dash-top-models">
|
||||
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
|
||||
</ul>
|
||||
<div id="dash-top-models"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1028,6 +968,7 @@ export async function renderDashboard(routeToken) {
|
||||
|
||||
for (const t of (topToolsData.tools || [])) {
|
||||
dashboardState.toolCounts[t.name] = t.count;
|
||||
dashboardState.toolDurations[t.name] = (t.avg_ms || 0) * (t.count || 0);
|
||||
}
|
||||
for (const m of (topModelsData.models || [])) {
|
||||
dashboardState.modelCounts[m.name] = m.count;
|
||||
|
||||
@@ -19,6 +19,15 @@ export function cleanup() {
|
||||
runLiveOps = {};
|
||||
}
|
||||
|
||||
function extractPromptPreview(spans) {
|
||||
for (const sp of spans) {
|
||||
const inner = (sp.payload || {}).payload || {};
|
||||
if (inner.prompt_preview) return inner.prompt_preview;
|
||||
if (inner.message_preview) return inner.message_preview;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderSpanPayload(sp) {
|
||||
const outer = sp.payload || {};
|
||||
const inner = outer.payload || {};
|
||||
@@ -361,6 +370,7 @@ export async function renderRun(runID, routeToken) {
|
||||
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||||
: 'ongoing';
|
||||
const runUsage = extractRunUsage(spans);
|
||||
const promptPreview = extractPromptPreview(spans);
|
||||
|
||||
app.innerHTML = `
|
||||
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">← Back to Session</a>
|
||||
@@ -382,6 +392,11 @@ export async function renderRun(runID, routeToken) {
|
||||
</div>
|
||||
</div>
|
||||
${!r.ended_at ? '<div class="run-live-ops" id="run-live-ops"></div>' : ''}
|
||||
${promptPreview ? `
|
||||
<div class="prompt-preview-section">
|
||||
<div class="prompt-preview-label">Prompt</div>
|
||||
<pre class="prompt-preview-text">${escapeHTML(promptPreview)}</pre>
|
||||
</div>` : ''}
|
||||
<div class="section-title">
|
||||
Spans <span class="count" id="run-detail-span-count">${spans.length}</span>
|
||||
${!r.ended_at ? '<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>Live</span>' : ''}
|
||||
|
||||
@@ -227,27 +227,6 @@ function refreshSessionsTable() {
|
||||
updatePaginationInfo();
|
||||
}
|
||||
|
||||
// Dead code: renderSessionRow is never called but preserved for fidelity
|
||||
function renderSessionRow(s) { // eslint-disable-line no-unused-vars
|
||||
const fw = s.framework || 'unknown';
|
||||
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
|
||||
const active = isSessionActive(s);
|
||||
const dotState = sessionDotState(s);
|
||||
const dotTitle = dotState === 'active'
|
||||
? 'Currently active session'
|
||||
: (active ? 'Open session' : 'Session ended');
|
||||
const errorCell = (s._errorCount || 0) > 0
|
||||
? `<span class="error-count-badge">${s._errorCount}</span>`
|
||||
: '<span style="color:var(--text-dim)">—</span>';
|
||||
return `
|
||||
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)}</td>
|
||||
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
|
||||
<td>${escapeHTML(s.host || '-')}</td>
|
||||
<td>${s.run_count}</td>
|
||||
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
|
||||
<td>${errorCell}</td>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateSessionTimers() {
|
||||
const tbody = document.getElementById('sessions-body');
|
||||
|
||||
@@ -1,6 +1,128 @@
|
||||
import { app, isRouteCurrent } from '../router.js';
|
||||
import { api } from '../api.js';
|
||||
import { escapeHTML, formatTokenCount, formatCost } from '../utils.js';
|
||||
import { barTrack, barRankList, metricStrip, chartHeader } from '../components.js';
|
||||
|
||||
/* global uPlot */
|
||||
|
||||
let usageChart = null;
|
||||
let usageChartMode = 'activity';
|
||||
let usageResizeObserver = null;
|
||||
let _usageSeries = [];
|
||||
|
||||
export function cleanup() {
|
||||
if (usageChart) { usageChart.destroy(); usageChart = null; }
|
||||
if (usageResizeObserver) { usageResizeObserver.disconnect(); usageResizeObserver = null; }
|
||||
_usageSeries = [];
|
||||
}
|
||||
|
||||
function buildChartData(series, mode) {
|
||||
if (!series || series.length === 0) return null;
|
||||
const ts = series.map(b => Math.floor(new Date(b.ts).getTime() / 1000));
|
||||
if (mode === 'tokens') {
|
||||
return [ts, series.map(b => b.input_tokens || 0), series.map(b => b.output_tokens || 0)];
|
||||
}
|
||||
if (mode === 'cost') {
|
||||
return [ts, series.map(b => b.cost || 0)];
|
||||
}
|
||||
return [ts, series.map(b => b.runs || 0), series.map(b => b.tools || 0), series.map(b => b.errors || 0)];
|
||||
}
|
||||
|
||||
function renderChart(series, mode) {
|
||||
const container = document.getElementById('usage-chart');
|
||||
if (!container) return;
|
||||
|
||||
if (usageChart) { usageChart.destroy(); usageChart = null; }
|
||||
container.innerHTML = '';
|
||||
|
||||
const data = buildChartData(series, mode);
|
||||
if (!data) {
|
||||
container.innerHTML = '<p class="empty-state" style="padding:1.5rem">No data for this period</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const width = container.clientWidth || 580;
|
||||
const height = 160;
|
||||
const axisStyle = {
|
||||
stroke: '#4e6070',
|
||||
grid: { stroke: 'rgba(28,38,55,0.6)', width: 1 },
|
||||
ticks: { stroke: 'rgba(28,38,55,0.6)', width: 1 },
|
||||
font: '11px Fira Code',
|
||||
};
|
||||
|
||||
let seriesDef;
|
||||
if (mode === 'tokens') {
|
||||
seriesDef = [
|
||||
{},
|
||||
{ label: 'Input', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
|
||||
{ label: 'Output', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
|
||||
];
|
||||
} else if (mode === 'cost') {
|
||||
seriesDef = [
|
||||
{},
|
||||
{ label: 'Cost', stroke: '#fbbf24', width: 1.75, fill: 'rgba(251,191,36,0.1)', points: { show: false } },
|
||||
];
|
||||
} else {
|
||||
seriesDef = [
|
||||
{},
|
||||
{ label: 'Runs', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
|
||||
{ label: 'Tools', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
|
||||
{ label: 'Errors', stroke: '#f87171', width: 1.5, fill: 'rgba(248,113,113,0.08)', points: { show: false } },
|
||||
];
|
||||
}
|
||||
|
||||
usageChart = new window.uPlot({
|
||||
width, height,
|
||||
cursor: { show: true },
|
||||
scales: { x: { time: true }, y: { auto: true, min: 0 } },
|
||||
axes: [{ ...axisStyle }, { ...axisStyle, size: 52 }],
|
||||
series: seriesDef,
|
||||
}, data, container);
|
||||
|
||||
if (usageResizeObserver) usageResizeObserver.disconnect();
|
||||
usageResizeObserver = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
if (usageChart) usageChart.setSize({ width: entry.contentRect.width, height });
|
||||
}
|
||||
});
|
||||
usageResizeObserver.observe(container);
|
||||
}
|
||||
|
||||
function renderFrameworkBreakdown(byFw) {
|
||||
const el = document.getElementById('usage-fw-breakdown');
|
||||
if (!el) return;
|
||||
|
||||
const entries = Object.entries(byFw || {}).sort((a, b) => {
|
||||
return (b[1].runs + b[1].tools + b[1].errors) - (a[1].runs + a[1].tools + a[1].errors);
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
el.innerHTML = '<p class="empty-state" style="padding:1rem">No framework data</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors), 1);
|
||||
|
||||
el.innerHTML = entries.map(([name, stats]) => {
|
||||
const total = stats.runs + stats.tools + stats.errors;
|
||||
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
const active = (stats.active_sessions || 0) > 0;
|
||||
return `
|
||||
<div class="usage-fw-row">
|
||||
<div class="usage-fw-name">
|
||||
<span class="fw-dot ${escapeHTML(cssClass)} ${active ? 'active' : 'ended'}"></span>
|
||||
<span>${escapeHTML(name)}</span>
|
||||
${active ? `<span class="usage-fw-active-badge">${stats.active_sessions} live</span>` : ''}
|
||||
</div>
|
||||
<div class="usage-fw-stats">
|
||||
<span class="usage-fw-stat"><span class="usage-fw-stat-label">runs</span>${stats.runs || 0}</span>
|
||||
<span class="usage-fw-stat"><span class="usage-fw-stat-label">tools</span>${stats.tools || 0}</span>
|
||||
${(stats.errors || 0) > 0 ? `<span class="usage-fw-stat error"><span class="usage-fw-stat-label">err</span>${stats.errors}</span>` : ''}
|
||||
</div>
|
||||
${barTrack({ value: total, max: maxTotal, fwClass: cssClass, size: 'sm' })}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export async function renderUsage(routeToken) {
|
||||
app.innerHTML = `
|
||||
@@ -18,90 +140,115 @@ export async function renderUsage(routeToken) {
|
||||
|
||||
const tools = toolsData.tools || [];
|
||||
const models = modelsData.models || [];
|
||||
const series = tsData.series || [];
|
||||
|
||||
// Aggregate 7d totals from timeseries
|
||||
const totals7d = series.reduce((acc, b) => {
|
||||
acc.runs += b.runs || 0;
|
||||
acc.tools += b.tools || 0;
|
||||
acc.errors += b.errors || 0;
|
||||
acc.tokens += b.tokens || 0;
|
||||
acc.cost += b.cost || 0;
|
||||
return acc;
|
||||
}, { runs: 0, tools: 0, errors: 0, tokens: 0, cost: 0 });
|
||||
|
||||
const s = summary || {};
|
||||
_usageSeries = tsData.series || [];
|
||||
|
||||
const t = _usageSeries.reduce((acc, b) => {
|
||||
acc.runs += b.runs || 0;
|
||||
acc.tools += b.tools || 0;
|
||||
acc.errors += b.errors || 0;
|
||||
acc.tokens += b.tokens || 0;
|
||||
acc.itok += b.input_tokens || 0;
|
||||
acc.otok += b.output_tokens || 0;
|
||||
acc.cost += b.cost || 0;
|
||||
return acc;
|
||||
}, { runs: 0, tools: 0, errors: 0, tokens: 0, itok: 0, otok: 0, cost: 0 });
|
||||
|
||||
const maxModel = models[0]?.count || 1;
|
||||
const maxTool = tools[0]?.count || 1;
|
||||
|
||||
const content = document.getElementById('usage-content');
|
||||
if (!content) return;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="usage-summary-tiles">
|
||||
<div class="meta-tile"><div class="meta-tile-label">Active Sessions</div><div class="meta-tile-value">${s.active_sessions || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Runs Today</div><div class="meta-tile-value">${s.runs_today || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Tool Calls Today</div><div class="meta-tile-value">${s.tool_calls_today || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Errors Today</div><div class="meta-tile-value">${s.errors_today || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Tokens Today</div><div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Cost Today</div><div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div></div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Active Sessions</div>
|
||||
<div class="meta-tile-value">${s.active_sessions || 0}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Runs Today</div>
|
||||
<div class="meta-tile-value">${s.runs_today || 0}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Tool Calls Today</div>
|
||||
<div class="meta-tile-value">${s.tool_calls_today || 0}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Errors Today</div>
|
||||
<div class="meta-tile-value${(s.errors_today || 0) > 0 ? ' has-errors' : ''}">${s.errors_today || 0}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Tokens Today</div>
|
||||
<div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div>
|
||||
${(s.tokens_today || 0) > 0 ? `<div class="meta-tile-sub">${formatTokenCount(s.tokens_today * 0.7 || 0)} in · ${formatTokenCount(s.tokens_today * 0.3 || 0)} out</div>` : ''}
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Cost Today</div>
|
||||
<div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div>
|
||||
${(s.runs_today || 0) > 0 ? `<div class="meta-tile-sub">${formatCost((s.cost_today || 0) / s.runs_today)}/run</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usage-section-row">
|
||||
<div class="usage-panel">
|
||||
<div class="section-title">7-Day Totals</div>
|
||||
<div class="usage-7d-tiles">
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Runs</span><strong>${totals7d.runs}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tool Calls</span><strong>${totals7d.tools}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Errors</span><strong>${totals7d.errors}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tokens</span><strong>${formatTokenCount(totals7d.tokens)}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Est. Cost</span><strong>${formatCost(totals7d.cost)}</strong></div>
|
||||
<div class="usage-panel usage-chart-panel">
|
||||
${chartHeader({
|
||||
title: '7-Day Trend',
|
||||
controls: `
|
||||
<div class="usage-chart-tabs" id="usage-chart-tabs">
|
||||
<button class="usage-chart-tab active" data-mode="activity">Activity</button>
|
||||
<button class="usage-chart-tab" data-mode="tokens">Tokens</button>
|
||||
<button class="usage-chart-tab" data-mode="cost">Cost</button>
|
||||
</div>`,
|
||||
})}
|
||||
${metricStrip([
|
||||
{ label: 'runs', value: String(t.runs), variant: 'total' },
|
||||
{ label: 'tools', value: String(t.tools), variant: 'total' },
|
||||
{ label: 'errors', value: String(t.errors), variant: 'total', alert: t.errors > 0 },
|
||||
{ label: 'tokens', value: formatTokenCount(t.tokens), variant: 'total' },
|
||||
{ label: 'cost', value: formatCost(t.cost), variant: 'total' },
|
||||
])}
|
||||
<div id="usage-chart"></div>
|
||||
</div>
|
||||
<div class="usage-panel usage-fw-panel">
|
||||
<div class="section-title" style="margin-bottom:0.75rem">
|
||||
Frameworks
|
||||
<span class="count">today</span>
|
||||
</div>
|
||||
<div id="usage-fw-breakdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usage-section-row">
|
||||
<div class="usage-panel">
|
||||
<div class="section-title">Top Models <span class="count">${models.length}</span></div>
|
||||
${models.length === 0 ? '<p class="empty-state">No model data yet</p>' : `
|
||||
<ul class="stat-list" id="usage-models-list">
|
||||
${(() => {
|
||||
const max = models[0]?.count || 1;
|
||||
return models.map(m => {
|
||||
const pct = (m.count / max * 100).toFixed(1);
|
||||
return `<li>
|
||||
<div class="stat-list-header">
|
||||
<span class="stat-list-name">${escapeHTML(m.name)}</span>
|
||||
<span class="stat-list-count">${m.count}</span>
|
||||
</div>
|
||||
<div class="stat-list-bar-track">
|
||||
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
})()}
|
||||
</ul>`}
|
||||
${barRankList(models, {
|
||||
mapItem: m => ({ name: m.name, count: m.count, modifier: 'model' }),
|
||||
maxOverride: maxModel,
|
||||
emptyText: 'No model data',
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="usage-panel">
|
||||
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div>
|
||||
${tools.length === 0 ? '<p class="empty-state">No tool data yet</p>' : `
|
||||
<ul class="stat-list" id="usage-tools-list">
|
||||
${(() => {
|
||||
const max = tools[0]?.count || 1;
|
||||
return tools.map(t => {
|
||||
const pct = (t.count / max * 100).toFixed(1);
|
||||
return `<li>
|
||||
<div class="stat-list-header">
|
||||
<span class="stat-list-name">${escapeHTML(t.name)}</span>
|
||||
<span class="stat-list-count">${t.count}</span>
|
||||
</div>
|
||||
<div class="stat-list-bar-track">
|
||||
<div class="stat-list-bar-fill tool" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
})()}
|
||||
</ul>`}
|
||||
${barRankList(tools, {
|
||||
mapItem: x => ({ name: x.name, count: x.count }),
|
||||
maxOverride: maxTool,
|
||||
emptyText: 'No tool data',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderChart(_usageSeries, usageChartMode);
|
||||
renderFrameworkBreakdown(s.by_framework);
|
||||
|
||||
document.querySelectorAll('.usage-chart-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (usageChartMode === btn.dataset.mode) return;
|
||||
usageChartMode = btn.dataset.mode;
|
||||
document.querySelectorAll('.usage-chart-tab').forEach(b => b.classList.toggle('active', b === btn));
|
||||
renderChart(_usageSeries, usageChartMode);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { renderRun, cleanup as cleanupRunDetail } from './pages/run-
|
||||
import { renderAgents, cleanup as cleanupAgents } from './pages/agents.js';
|
||||
import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js';
|
||||
import { renderSettings } from './pages/settings.js';
|
||||
import { renderUsage } from './pages/usage.js';
|
||||
import { renderUsage, cleanup as cleanupUsage } from './pages/usage.js';
|
||||
|
||||
// Exported so all page modules can write into it without querying the DOM each time
|
||||
export const app = document.getElementById('app');
|
||||
@@ -31,6 +31,7 @@ export function cleanupLiveViews() {
|
||||
cleanupSessionDetail();
|
||||
cleanupRunDetail();
|
||||
cleanupDashboard();
|
||||
cleanupUsage();
|
||||
}
|
||||
|
||||
export function route() {
|
||||
|
||||
@@ -204,7 +204,7 @@ export function getVMName(evt) {
|
||||
|
||||
export function getVMClassName(vmName) {
|
||||
const normalized = String(vmName || 'unknown').toLowerCase();
|
||||
return ['zap', 'orb', 'sun'].includes(normalized) ? normalized : 'unknown';
|
||||
return ['zap'].includes(normalized) ? normalized : 'unknown';
|
||||
}
|
||||
|
||||
export function getEventIcon(eventType) {
|
||||
|
||||
+351
-289
@@ -12,6 +12,7 @@
|
||||
|
||||
--text: #b8c5d4;
|
||||
--text-dim: #465a6e;
|
||||
--text-mute: #6b7f94;
|
||||
--text-bright: #e4edf5;
|
||||
|
||||
--accent: #22d3ee;
|
||||
@@ -1276,18 +1277,6 @@ tr.expandable:hover .expand-icon::before {
|
||||
border: 1px solid rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.timeline-vm-tag.orb {
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
color: var(--purple);
|
||||
border: 1px solid rgba(167, 139, 250, 0.2);
|
||||
}
|
||||
|
||||
.timeline-vm-tag.sun {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: var(--warning);
|
||||
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
|
||||
.timeline-vm-tag.unknown {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
@@ -1416,57 +1405,214 @@ tr.expandable:hover .expand-icon::before {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.stat-list {
|
||||
/* ============================================================
|
||||
Shared primitives (am-*): bars, pills, chart headers.
|
||||
See modules/components.js for the renderers.
|
||||
============================================================ */
|
||||
|
||||
/* ── Bars ─────────────────────────────────────────────── */
|
||||
.am-bar-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.am-bar-list li {
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stat-list li:last-child {
|
||||
border-bottom: none;
|
||||
.am-bar-list li:last-child { border-bottom: none; }
|
||||
|
||||
.am-bar-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.stat-list-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-list-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
background: var(--surface-2);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stat-list-header {
|
||||
.am-bar-row-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-list-bar-track {
|
||||
height: 3px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 2px;
|
||||
margin-top: 0.3rem;
|
||||
overflow: hidden;
|
||||
.am-bar-row-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-list-bar-fill {
|
||||
.am-bar-row-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.am-bar-track {
|
||||
background: var(--surface-2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.am-bar-track--xs { height: 3px; border-radius: 2px; }
|
||||
.am-bar-track--sm { height: 4px; border-radius: 2px; }
|
||||
.am-bar-track--md { height: 6px; border-radius: 3px; }
|
||||
.am-bar-track--lg { height: 8px; border-radius: 4px; }
|
||||
|
||||
.am-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* Framework / category color modifiers (applied to .am-bar-fill) */
|
||||
.am-bar-fill.fw-openclaw { background: var(--accent); }
|
||||
.am-bar-fill.fw-claude-code { background: var(--success); }
|
||||
.am-bar-fill.fw-opencode { background: var(--purple); }
|
||||
.am-bar-fill.fw-hermes { background: var(--warning); }
|
||||
.am-bar-fill.fw-codex { background: #60a5fa; }
|
||||
.am-bar-fill.fw-gemini { background: #f97316; }
|
||||
.am-bar-fill.fw-copilot { background: #2dd4bf; }
|
||||
.am-bar-fill.fw-unknown { background: var(--text-dim); }
|
||||
.am-bar-fill.model { background: var(--success); }
|
||||
.am-bar-fill.input { background: var(--accent); }
|
||||
.am-bar-fill.output { background: var(--purple); }
|
||||
|
||||
/* ── Pills ────────────────────────────────────────────── */
|
||||
.am-pill-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.am-pill-strip--insights {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.am-pill-strip--insights { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.am-pill-strip--insights { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.am-pill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.85rem;
|
||||
min-width: 130px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.am-pill-label {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.am-pill-value {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.am-pill-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.am-pill.alert .am-pill-value { color: var(--error); }
|
||||
|
||||
/* Variant: chart insight (taller, gradient background) */
|
||||
.am-pill--insight {
|
||||
min-height: 62px;
|
||||
border-color: var(--border-soft);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0));
|
||||
padding: 0.75rem 0.85rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.am-pill--insight .am-pill-value { font-size: 0.95rem; }
|
||||
|
||||
/* Variant: tight inline total (e.g. usage chart totals) */
|
||||
.am-pill--total {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.22rem 0.55rem;
|
||||
min-width: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.am-pill--total .am-pill-label { font-size: 0.6rem; }
|
||||
.am-pill--total .am-pill-value { font-size: 0.8rem; font-weight: normal; }
|
||||
|
||||
/* Variant: latency-range item (centered, borderless) */
|
||||
.am-pill--range {
|
||||
flex: 0 0 auto;
|
||||
min-width: auto;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.am-pill--range .am-pill-label { font-size: 0.68rem; }
|
||||
.am-pill--range .am-pill-value { font-size: 1rem; font-weight: 600; color: var(--text); }
|
||||
|
||||
/* ── Chart headers ────────────────────────────────────── */
|
||||
.am-chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.am-chart-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.am-chart-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.am-chart-subtitle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.am-chart-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
@@ -1593,7 +1739,7 @@ tr.expandable:hover .expand-icon::before {
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.metric-pill-value.bumped {
|
||||
.am-pill-value.bumped {
|
||||
animation: counterBump 400ms ease;
|
||||
}
|
||||
|
||||
@@ -1636,44 +1782,6 @@ tr.expandable:hover .expand-icon::before {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.chart-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.chart-subtitle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.chart-header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.window-selector {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
@@ -1747,50 +1855,6 @@ tr.expandable:hover .expand-icon::before {
|
||||
background: rgba(52, 211, 153, 0.12);
|
||||
}
|
||||
|
||||
.chart-insights {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chart-insights {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.chart-insights {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-insight-pill {
|
||||
min-height: 62px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0));
|
||||
padding: 0.75rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.chart-insight-label,
|
||||
.chart-insight-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chart-insight-pill strong {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
@@ -1870,55 +1934,6 @@ tr.expandable:hover .expand-icon::before {
|
||||
.chart-hover-metric.errors strong { color: #f87171; }
|
||||
.chart-hover-metric.delta strong { color: var(--accent); }
|
||||
|
||||
.fw-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.fw-bar-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.fw-bar-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fw-bar-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fw-bar-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.fw-bar-track {
|
||||
height: 8px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fw-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.fw-bar-fill.openclaw { background: var(--accent); }
|
||||
.fw-bar-fill.claude-code { background: var(--success); }
|
||||
.fw-bar-fill.opencode { background: var(--purple); }
|
||||
.fw-bar-fill.unknown { background: var(--text-dim); }
|
||||
|
||||
.bottom-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
@@ -2014,10 +2029,6 @@ tr.expandable:hover .expand-icon::before {
|
||||
box-shadow: 0 0 0 1px rgba(248, 250, 252, 0.2);
|
||||
}
|
||||
|
||||
.stat-list-bar-fill.model {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
/* ── Framework dots ───────────────────────────────────────── */
|
||||
.fw-dot {
|
||||
display: inline-block;
|
||||
@@ -2034,6 +2045,10 @@ tr.expandable:hover .expand-icon::before {
|
||||
.fw-dot.openclaw { background: var(--accent); --fw-glow: var(--accent); }
|
||||
.fw-dot.claude-code { background: var(--success); --fw-glow: var(--success); }
|
||||
.fw-dot.opencode { background: var(--purple); --fw-glow: var(--purple); }
|
||||
.fw-dot.hermes { background: var(--warning); --fw-glow: var(--warning); }
|
||||
.fw-dot.codex { background: #60a5fa; --fw-glow: #60a5fa; }
|
||||
.fw-dot.gemini { background: #f97316; --fw-glow: #f97316; }
|
||||
.fw-dot.copilot { background: #2dd4bf; --fw-glow: #2dd4bf; }
|
||||
.fw-dot.unknown { background: var(--text-dim); --fw-glow: var(--text-dim); }
|
||||
.fw-dot.ended { opacity: 0.3; }
|
||||
.fw-dot.active { box-shadow: 0 0 6px var(--fw-glow); animation: fwPulse 2s ease-in-out infinite; }
|
||||
@@ -2121,6 +2136,7 @@ tr.expandable:hover .expand-icon::before {
|
||||
|
||||
--text: #3d4a5c;
|
||||
--text-dim: #8b9ab0;
|
||||
--text-mute: #5c6b80;
|
||||
--text-bright: #1a2332;
|
||||
|
||||
--accent: #0891b2;
|
||||
@@ -2244,7 +2260,8 @@ tr.expandable:hover .expand-icon::before {
|
||||
padding: 0.6rem 1rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-mute);
|
||||
letter-spacing: 0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -2307,7 +2324,7 @@ tr.expandable:hover .expand-icon::before {
|
||||
margin-top: 0.2rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
/* ── Agent Lane Sparklines ────────────────────────────────── */
|
||||
@@ -2321,10 +2338,10 @@ tr.expandable:hover .expand-icon::before {
|
||||
|
||||
.agent-lane-sparkline-bar {
|
||||
flex: 1;
|
||||
background: var(--accent);
|
||||
background: linear-gradient(to top, var(--accent), var(--accent-glow));
|
||||
border-radius: 1px 1px 0 0;
|
||||
opacity: 0.5;
|
||||
min-height: 1px;
|
||||
opacity: 0.8;
|
||||
min-height: 2px;
|
||||
}
|
||||
|
||||
.agent-lane-dot {
|
||||
@@ -2348,7 +2365,7 @@ tr.expandable:hover .expand-icon::before {
|
||||
.agent-lane-status {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
@@ -2373,6 +2390,7 @@ tr.expandable:hover .expand-icon::before {
|
||||
padding: 0.35rem 0.625rem;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid rgba(34, 211, 238, 0.15);
|
||||
border-left: 2px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.75rem;
|
||||
animation: fadeUp 0.2s ease both;
|
||||
@@ -2380,11 +2398,17 @@ tr.expandable:hover .expand-icon::before {
|
||||
|
||||
[data-theme="light"] .active-op {
|
||||
border-color: rgba(8, 145, 178, 0.2);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.active-op.run {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.active-op.subagent {
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
border-color: rgba(167, 139, 250, 0.22);
|
||||
border-left-color: var(--purple);
|
||||
}
|
||||
|
||||
.active-op.stale {
|
||||
@@ -2456,6 +2480,7 @@ tr.expandable:hover .expand-icon::before {
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 0.25rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-left: 2px solid var(--border);
|
||||
background: transparent;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
@@ -2468,6 +2493,29 @@ tr.expandable:hover .expand-icon::before {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
/* ── Event-kind accents + hierarchy (scoped to Agents page) ── */
|
||||
#agents-content .timeline-event.evt-run { border-left-color: var(--accent); }
|
||||
#agents-content .timeline-event.evt-error { border-left-color: var(--error); }
|
||||
#agents-content .timeline-event.evt-session { border-left-color: var(--success); }
|
||||
#agents-content .timeline-event.evt-span { border-left-color: rgba(34, 211, 238, 0.4); }
|
||||
|
||||
/* Demote the generic type label to a quiet category tag; let the
|
||||
tool name / preview body carry the line. */
|
||||
#agents-content .timeline-event-type {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
#agents-content .timeline-event-body.tool-name,
|
||||
#agents-content .timeline-event-body.subagent-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.agent-lane-events .empty-state {
|
||||
padding: 2rem 1rem;
|
||||
font-size: 0.78rem;
|
||||
@@ -3115,44 +3163,6 @@ tr.clickable.active-session td:first-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Metrics strip ────────────────────────────────────────── */
|
||||
.metrics-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.metric-pill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.85rem;
|
||||
min-width: 130px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.metric-pill-label {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.metric-pill-value {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
font-family: 'Fira Code', monospace;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.metric-pill-alert.alert {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* ── Right panel tabs ─────────────────────────────────────── */
|
||||
.right-panel-tabs {
|
||||
display: flex;
|
||||
@@ -3179,7 +3189,7 @@ tr.clickable.active-session td:first-child {
|
||||
.right-panel-tab.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(var(--accent-rgb, 99, 102, 241), 0.08);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.right-panel-body {
|
||||
@@ -3211,7 +3221,7 @@ tr.clickable.active-session td:first-child {
|
||||
.token-stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
@@ -3222,47 +3232,6 @@ tr.clickable.active-session td:first-child {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.token-bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 3.5rem 1fr 3rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.token-bar-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.token-bar-track {
|
||||
height: 6px;
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.token-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.token-bar-fill.input {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.token-bar-fill.output {
|
||||
background: var(--purple, #a78bfa);
|
||||
}
|
||||
|
||||
.token-bar-count {
|
||||
font-size: 0.72rem;
|
||||
font-family: 'Fira Code', monospace;
|
||||
color: var(--text-muted, var(--text-dim));
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.token-cost-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -3291,27 +3260,6 @@ tr.clickable.active-session td:first-child {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.latency-range-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.latency-range-label {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.latency-range-val {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-family: 'Fira Code', monospace;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.latency-mini-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -3633,7 +3581,7 @@ tr.clickable.active-session td:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-header-controls {
|
||||
.am-chart-controls {
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -3932,3 +3880,117 @@ tr.clickable:focus-visible {
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ── Usage Page: Chart Panel ─────────────────────────────── */
|
||||
.usage-chart-panel { flex: 2 1 400px; }
|
||||
.usage-fw-panel { flex: 1 1 240px; }
|
||||
|
||||
.usage-chart-tabs {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usage-chart-tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.usage-chart-tab:last-child { border-right: none; }
|
||||
.usage-chart-tab:hover { color: var(--text-bright); background: var(--surface-2); }
|
||||
.usage-chart-tab.active { color: var(--accent); background: var(--accent-dim); }
|
||||
|
||||
/* ── Usage Page: Framework Breakdown ─────────────────────── */
|
||||
.usage-fw-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
padding: 0.65rem 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.usage-fw-row:last-child { border-bottom: none; }
|
||||
|
||||
.usage-fw-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.usage-fw-active-badge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--success);
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
|
||||
.usage-fw-stats {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.usage-fw-stat {
|
||||
display: flex;
|
||||
gap: 0.28rem;
|
||||
align-items: baseline;
|
||||
color: var(--text);
|
||||
}
|
||||
.usage-fw-stat.error { color: var(--error); }
|
||||
|
||||
.usage-fw-stat-label {
|
||||
font-size: 0.58rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ── Run Detail: Prompt Preview ──────────────────────────── */
|
||||
.prompt-preview-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--purple);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.875rem 1.125rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.prompt-preview-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.prompt-preview-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--code-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── meta-tile: sub-line ─────────────────────────────────── */
|
||||
.meta-tile-value.has-errors { color: var(--error); }
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
image: postgres:16.13
|
||||
container_name: agentmon-db
|
||||
environment:
|
||||
POSTGRES_PASSWORD: pass
|
||||
@@ -22,7 +22,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
nats:
|
||||
image: nats:latest
|
||||
image: nats@sha256:7971c76fcd4057c090faf5bc7673199ffe0ae586704518e9a469f156155b4e47
|
||||
container_name: agentmon-nats
|
||||
ports:
|
||||
- "4222:4222"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -172,6 +172,7 @@ async function readStdin() {
|
||||
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 ALLOW_STARTUP_SESSIONS = process.env.AGENTMON_CLAUDE_ALLOW_STARTUP === "1";
|
||||
var { enqueue, flush } = createTransport(INGEST_URL);
|
||||
var STATE_DIR = join(homedir(), ".agentmon-state");
|
||||
function ensureStateDir() {
|
||||
@@ -231,6 +232,15 @@ function isNonPersistentClaudeLaunch() {
|
||||
({ cmd }) => cmd.includes("/claude") && cmd.includes("--no-session-persistence")
|
||||
);
|
||||
}
|
||||
function hasClaudeProcessAncestor() {
|
||||
return getProcessTree().some(({ cmd }) => {
|
||||
if (!cmd)
|
||||
return false;
|
||||
if (cmd.includes("agentmon-handler") || cmd.includes("/hooks/claude-code/handler"))
|
||||
return false;
|
||||
return /(^|[/\s])claude(\s|$)/.test(cmd) || cmd.includes("@anthropic-ai/claude-code");
|
||||
});
|
||||
}
|
||||
var activeRuns = /* @__PURE__ */ new Map();
|
||||
var activeSpans = /* @__PURE__ */ new Map();
|
||||
var activeSubagents = /* @__PURE__ */ new Map();
|
||||
@@ -319,9 +329,13 @@ async function handleSessionStart(input) {
|
||||
console.error("[agentmon] ignoring claude-code startup from --no-session-persistence launch");
|
||||
return;
|
||||
}
|
||||
if (pickString(input.source) === "startup" && (!ALLOW_STARTUP_SESSIONS || !hasClaudeProcessAncestor())) {
|
||||
console.error("[agentmon] ignoring claude-code startup session");
|
||||
return;
|
||||
}
|
||||
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: {
|
||||
@@ -348,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,
|
||||
@@ -384,14 +398,23 @@ 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)
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (!runId && sessionKey) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, {
|
||||
attributes: {
|
||||
cwd: pickString(input.cwd),
|
||||
transcript_path: pickString(input.transcript_path),
|
||||
source: pickString(input.source)
|
||||
}
|
||||
}));
|
||||
}
|
||||
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,
|
||||
@@ -590,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,
|
||||
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
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 ALLOW_STARTUP_SESSIONS = process.env.AGENTMON_CLAUDE_ALLOW_STARTUP === '1';
|
||||
|
||||
const { enqueue, flush } = createTransport(INGEST_URL);
|
||||
|
||||
// ── Persisted state (survives between hook subprocess invocations) ──────────
|
||||
interface SessionState {
|
||||
runId?: string;
|
||||
runStartedAt?: number; // epoch ms when the current run began
|
||||
spans: { [key: string]: string }; // key = sessionKey:toolName, value = spanId
|
||||
spanStartTimes?: { [spanId: string]: number }; // spanId -> epoch ms
|
||||
subagent?: { name: string; spanId: string };
|
||||
@@ -89,6 +91,14 @@ function isNonPersistentClaudeLaunch() {
|
||||
cmd.includes('/claude') && cmd.includes('--no-session-persistence')
|
||||
);
|
||||
}
|
||||
|
||||
function hasClaudeProcessAncestor() {
|
||||
return getProcessTree().some(({ cmd }) => {
|
||||
if (!cmd) return false;
|
||||
if (cmd.includes('agentmon-handler') || cmd.includes('/hooks/claude-code/handler')) return false;
|
||||
return /(^|[/\s])claude(\s|$)/.test(cmd) || cmd.includes('@anthropic-ai/claude-code');
|
||||
});
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const activeRuns = new Map<string, string>();
|
||||
@@ -182,10 +192,14 @@ async function handleSessionStart(input: Dict) {
|
||||
console.error('[agentmon] ignoring claude-code startup from --no-session-persistence launch');
|
||||
return;
|
||||
}
|
||||
if (pickString(input.source) === 'startup' && (!ALLOW_STARTUP_SESSIONS || !hasClaudeProcessAncestor())) {
|
||||
console.error('[agentmon] ignoring claude-code startup session');
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = randomUUID();
|
||||
activeRuns.set(sessionKey, runId);
|
||||
saveState(sessionKey, { runId, spans: {} });
|
||||
saveState(sessionKey, { runId, runStartedAt: Date.now(), spans: {} });
|
||||
|
||||
const contextWindow = getContextWindow(input);
|
||||
|
||||
@@ -217,7 +231,7 @@ async function handleSessionEnd(input: Dict) {
|
||||
const runId = sessionKey ? (activeRuns.get(sessionKey) || state.runId) : undefined;
|
||||
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 : undefined);
|
||||
|
||||
if (runId) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
|
||||
@@ -257,7 +271,17 @@ async function handlePromptSubmit(input: Dict) {
|
||||
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 : undefined),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (!runId && sessionKey) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.start', sessionKey, {
|
||||
attributes: {
|
||||
cwd: pickString(input.cwd),
|
||||
transcript_path: pickString(input.transcript_path),
|
||||
source: pickString(input.source),
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -265,7 +289,7 @@ async function handlePromptSubmit(input: Dict) {
|
||||
const newRunId = randomUUID();
|
||||
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, {
|
||||
@@ -485,11 +509,11 @@ async function handleNotification(input: Dict) {
|
||||
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) : undefined;
|
||||
const duration = pickNumber(input.duration_ms, input.elapsed_ms) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : undefined);
|
||||
|
||||
if (runId) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
|
||||
|
||||
Executable
+541
@@ -0,0 +1,541 @@
|
||||
#!/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";
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function pickString(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return void 0;
|
||||
}
|
||||
function pickNumber(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return void 0;
|
||||
}
|
||||
function truncate(value, limit) {
|
||||
if (value === void 0 || value === null) {
|
||||
return void 0;
|
||||
}
|
||||
const text = typeof value === "string" ? value : safeJSONStringify(value);
|
||||
if (!text) {
|
||||
return void 0;
|
||||
}
|
||||
if (text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(0, limit) + "...";
|
||||
}
|
||||
function safeJSONStringify(value) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
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 || "hermes";
|
||||
var HOST = process.env.AGENTMON_HOST || hostname();
|
||||
var { enqueue, flush } = createTransport(INGEST_URL);
|
||||
var USAGE_TOKEN_FIELDS = [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"reasoning_tokens",
|
||||
"total_cost"
|
||||
];
|
||||
function accumulateUsage(into, usage) {
|
||||
if (!usage) {
|
||||
return into;
|
||||
}
|
||||
for (const key of USAGE_TOKEN_FIELDS) {
|
||||
const v = pickNumber(usage[key]);
|
||||
if (v !== void 0) {
|
||||
into[key] = (pickNumber(into[key]) ?? 0) + v;
|
||||
}
|
||||
}
|
||||
return into;
|
||||
}
|
||||
function runUsagePayload(state) {
|
||||
return state.runUsage && Object.keys(state.runUsage).length > 0 ? state.runUsage : void 0;
|
||||
}
|
||||
var STATE_DIR = join(homedir(), ".agentmon-state", "hermes");
|
||||
function ensureStateDir() {
|
||||
try {
|
||||
mkdirSync(STATE_DIR, { recursive: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
function loadState(sessionKey) {
|
||||
try {
|
||||
const raw = readFileSync(join(STATE_DIR, sessionKey + ".json"), "utf8");
|
||||
const state = JSON.parse(raw);
|
||||
return { spans: {}, ...state };
|
||||
} catch {
|
||||
return { spans: {} };
|
||||
}
|
||||
}
|
||||
function saveState(sessionKey, state) {
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
ensureStateDir();
|
||||
try {
|
||||
writeFileSync(join(STATE_DIR, sessionKey + ".json"), JSON.stringify(state), "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
function clearState(sessionKey) {
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
unlinkSync(join(STATE_DIR, sessionKey + ".json"));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
function getExtra(input) {
|
||||
return isRecord(input.extra) ? input.extra : {};
|
||||
}
|
||||
function getSessionKey(input) {
|
||||
const extra = getExtra(input);
|
||||
return pickString(
|
||||
input.session_id,
|
||||
input.sessionId,
|
||||
input.sessionID,
|
||||
input.session,
|
||||
extra.session_id,
|
||||
extra.sessionId,
|
||||
extra.sessionID,
|
||||
extra.parent_session_id,
|
||||
extra.task_id
|
||||
);
|
||||
}
|
||||
function getToolCallId(input) {
|
||||
const extra = getExtra(input);
|
||||
return pickString(input.tool_call_id, extra.tool_call_id, extra.call_id, extra.id);
|
||||
}
|
||||
function getToolName(input) {
|
||||
const extra = getExtra(input);
|
||||
return pickString(input.tool_name, input.tool, input.name, extra.tool_name, extra.tool, extra.name) || "unknown";
|
||||
}
|
||||
function getUsage(input) {
|
||||
const extra = getExtra(input);
|
||||
const usage = isRecord(input.usage) ? input.usage : isRecord(extra.usage) ? extra.usage : void 0;
|
||||
if (!usage)
|
||||
return void 0;
|
||||
const result = {};
|
||||
if (usage.input_tokens !== void 0)
|
||||
result.input_tokens = usage.input_tokens;
|
||||
if (usage.prompt_tokens !== void 0)
|
||||
result.input_tokens = usage.prompt_tokens;
|
||||
if (usage.output_tokens !== void 0)
|
||||
result.output_tokens = usage.output_tokens;
|
||||
if (usage.completion_tokens !== void 0)
|
||||
result.output_tokens = usage.completion_tokens;
|
||||
if (usage.cache_read_tokens !== void 0)
|
||||
result.cache_read_tokens = usage.cache_read_tokens;
|
||||
if (usage.cache_write_tokens !== void 0)
|
||||
result.cache_write_tokens = usage.cache_write_tokens;
|
||||
if (usage.cache_creation_tokens !== void 0)
|
||||
result.cache_write_tokens = usage.cache_creation_tokens;
|
||||
if (usage.reasoning_tokens !== void 0)
|
||||
result.reasoning_tokens = usage.reasoning_tokens;
|
||||
if (usage.total_tokens !== void 0)
|
||||
result.total_tokens = usage.total_tokens;
|
||||
if (usage.total_cost !== void 0)
|
||||
result.total_cost = usage.total_cost;
|
||||
if (usage.cost !== void 0)
|
||||
result.total_cost = usage.cost;
|
||||
return Object.keys(result).length > 0 ? result : void 0;
|
||||
}
|
||||
function getModel(input) {
|
||||
const extra = getExtra(input);
|
||||
return pickString(input.model, extra.model, extra.response_model);
|
||||
}
|
||||
function getPlatform(input) {
|
||||
const extra = getExtra(input);
|
||||
return pickString(input.platform, extra.platform);
|
||||
}
|
||||
function getDuration(input) {
|
||||
const extra = getExtra(input);
|
||||
return pickNumber(input.duration_ms, input.elapsed_ms, extra.duration_ms, extra.api_duration, extra.elapsed_ms);
|
||||
}
|
||||
function ensureSessionStarted(sessionKey, input, state) {
|
||||
if (!sessionKey || state.sessionStarted) {
|
||||
return;
|
||||
}
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, {
|
||||
attributes: {
|
||||
synthetic: true,
|
||||
recovered_from: "hermes-hook",
|
||||
cwd: pickString(input.cwd),
|
||||
platform: getPlatform(input),
|
||||
model: getModel(input)
|
||||
}
|
||||
}));
|
||||
state.sessionStarted = true;
|
||||
saveState(sessionKey, state);
|
||||
}
|
||||
async function handleSessionStart(input) {
|
||||
const sessionKey = getSessionKey(input) || randomUUID2();
|
||||
const state = loadState(sessionKey);
|
||||
if (!state.sessionStarted) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, {
|
||||
attributes: {
|
||||
cwd: pickString(input.cwd),
|
||||
platform: getPlatform(input),
|
||||
model: getModel(input)
|
||||
}
|
||||
}));
|
||||
state.sessionStarted = true;
|
||||
saveState(sessionKey, state);
|
||||
}
|
||||
await flush();
|
||||
}
|
||||
async function handleRunStart(input) {
|
||||
const sessionKey = getSessionKey(input) || randomUUID2();
|
||||
const state = loadState(sessionKey);
|
||||
ensureSessionStarted(sessionKey, input, state);
|
||||
if (state.runId) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
|
||||
runId: state.runId,
|
||||
payload: {
|
||||
status: "success",
|
||||
duration_ms: state.runStartedAt ? Date.now() - state.runStartedAt : void 0,
|
||||
...runUsagePayload(state) && { usage: runUsagePayload(state) }
|
||||
}
|
||||
}));
|
||||
}
|
||||
const runId = randomUUID2();
|
||||
state.runId = runId;
|
||||
state.runStartedAt = Date.now();
|
||||
state.runUsage = {};
|
||||
saveState(sessionKey, state);
|
||||
const extra = getExtra(input);
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.start", sessionKey, {
|
||||
runId,
|
||||
attributes: {
|
||||
is_first_turn: extra.is_first_turn,
|
||||
platform: getPlatform(input),
|
||||
model: getModel(input)
|
||||
},
|
||||
payload: {
|
||||
prompt_preview: truncate(extra.user_message ?? input.user_message, 200)
|
||||
}
|
||||
}));
|
||||
await flush();
|
||||
}
|
||||
async function handleRunEnd(input) {
|
||||
const sessionKey = getSessionKey(input);
|
||||
if (!sessionKey) {
|
||||
await flush();
|
||||
return;
|
||||
}
|
||||
const state = loadState(sessionKey);
|
||||
ensureSessionStarted(sessionKey, input, state);
|
||||
if (state.runId) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
|
||||
runId: state.runId,
|
||||
payload: {
|
||||
status: "success",
|
||||
duration_ms: getDuration(input) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : void 0),
|
||||
model: getModel(input),
|
||||
response_preview: truncate(getExtra(input).assistant_response, 500),
|
||||
...runUsagePayload(state) && { usage: runUsagePayload(state) }
|
||||
}
|
||||
}));
|
||||
state.runId = void 0;
|
||||
state.runStartedAt = void 0;
|
||||
state.runUsage = {};
|
||||
saveState(sessionKey, state);
|
||||
}
|
||||
await flush();
|
||||
}
|
||||
async function handleToolStart(input) {
|
||||
const sessionKey = getSessionKey(input);
|
||||
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
||||
ensureSessionStarted(sessionKey, input, state);
|
||||
const toolName = getToolName(input);
|
||||
const toolCallId = getToolCallId(input);
|
||||
const spanKey = toolCallId || toolName;
|
||||
const spanId = randomUUID2();
|
||||
state.spans[spanKey] = spanId;
|
||||
saveState(sessionKey, state);
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "span.start", sessionKey, {
|
||||
runId: state.runId,
|
||||
spanId,
|
||||
attributes: {
|
||||
span_kind: "tool",
|
||||
name: toolName,
|
||||
tool_call_id: toolCallId
|
||||
},
|
||||
payload: {
|
||||
input: truncate(input.tool_input ?? getExtra(input).args, 500)
|
||||
}
|
||||
}));
|
||||
await flush();
|
||||
}
|
||||
async function handleToolEnd(input) {
|
||||
const sessionKey = getSessionKey(input);
|
||||
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
||||
const toolName = getToolName(input);
|
||||
const toolCallId = getToolCallId(input);
|
||||
const spanKey = toolCallId || toolName;
|
||||
const spanId = state.spans[spanKey];
|
||||
const extra = getExtra(input);
|
||||
const result = extra.result ?? input.result;
|
||||
const resultRecord = isRecord(result) ? result : {};
|
||||
const success = extra.error === void 0 && resultRecord.error === void 0;
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "span.end", sessionKey, {
|
||||
runId: state.runId,
|
||||
spanId,
|
||||
attributes: {
|
||||
span_kind: "tool",
|
||||
name: toolName,
|
||||
tool_call_id: toolCallId
|
||||
},
|
||||
payload: {
|
||||
status: success ? "success" : "error",
|
||||
result_preview: truncate(resultRecord.output ?? resultRecord.error ?? extra.error ?? result, 500),
|
||||
duration_ms: getDuration(input)
|
||||
}
|
||||
}));
|
||||
delete state.spans[spanKey];
|
||||
saveState(sessionKey, state);
|
||||
await flush();
|
||||
}
|
||||
async function handleAPIResult(input) {
|
||||
const sessionKey = getSessionKey(input);
|
||||
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
||||
const usage = getUsage(input);
|
||||
if (!usage) {
|
||||
await flush();
|
||||
return;
|
||||
}
|
||||
if (sessionKey) {
|
||||
state.runUsage = accumulateUsage(state.runUsage || {}, usage);
|
||||
saveState(sessionKey, state);
|
||||
}
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "metric.snapshot", sessionKey, {
|
||||
runId: state.runId,
|
||||
payload: {
|
||||
metrics: {
|
||||
usage,
|
||||
model: getModel(input),
|
||||
provider: pickString(getExtra(input).provider),
|
||||
duration_ms: getDuration(input)
|
||||
}
|
||||
}
|
||||
}));
|
||||
await flush();
|
||||
}
|
||||
async function handleSessionEnd(input) {
|
||||
const sessionKey = getSessionKey(input);
|
||||
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
||||
if (state.runId) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
|
||||
runId: state.runId,
|
||||
payload: {
|
||||
status: input.interrupted || getExtra(input).interrupted ? "interrupted" : "success",
|
||||
duration_ms: state.runStartedAt ? Date.now() - state.runStartedAt : void 0,
|
||||
model: getModel(input),
|
||||
...runUsagePayload(state) && { usage: runUsagePayload(state) }
|
||||
}
|
||||
}));
|
||||
}
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.end", sessionKey, {
|
||||
payload: {
|
||||
model: getModel(input),
|
||||
completed: getExtra(input).completed,
|
||||
interrupted: getExtra(input).interrupted
|
||||
}
|
||||
}));
|
||||
clearState(sessionKey);
|
||||
await flush();
|
||||
}
|
||||
var handler = async () => {
|
||||
const hookType = process.argv[2] || "unknown";
|
||||
let input = {};
|
||||
try {
|
||||
const stdin = await readStdin();
|
||||
if (stdin) {
|
||||
input = JSON.parse(stdin);
|
||||
}
|
||||
} catch {
|
||||
input = {};
|
||||
}
|
||||
try {
|
||||
switch (hookType) {
|
||||
case "session-start":
|
||||
case "start":
|
||||
await handleSessionStart(input);
|
||||
break;
|
||||
case "run-start":
|
||||
case "pre-llm":
|
||||
await handleRunStart(input);
|
||||
break;
|
||||
case "run-end":
|
||||
case "post-llm":
|
||||
await handleRunEnd(input);
|
||||
break;
|
||||
case "tool-start":
|
||||
await handleToolStart(input);
|
||||
break;
|
||||
case "tool-end":
|
||||
await handleToolEnd(input);
|
||||
break;
|
||||
case "api-result":
|
||||
case "post-api":
|
||||
await handleAPIResult(input);
|
||||
break;
|
||||
case "session-end":
|
||||
case "stop":
|
||||
await handleSessionEnd(input);
|
||||
break;
|
||||
default:
|
||||
console.debug(`[agentmon] unknown hermes hook type: ${hookType}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug("[agentmon] hermes handler error:", err);
|
||||
}
|
||||
};
|
||||
handler();
|
||||
@@ -0,0 +1,434 @@
|
||||
#!/usr/bin/env node
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
type Dict,
|
||||
isRecord,
|
||||
pickString,
|
||||
pickNumber,
|
||||
truncate,
|
||||
buildEnvelope,
|
||||
createTransport,
|
||||
readStdin,
|
||||
} from '../shared/lib';
|
||||
|
||||
const INGEST_URL = process.env.AGENTMON_INGEST_URL || 'http://localhost:8080';
|
||||
const FRAMEWORK = process.env.AGENTMON_FRAMEWORK || 'hermes';
|
||||
const HOST = process.env.AGENTMON_HOST || hostname();
|
||||
|
||||
const { enqueue, flush } = createTransport(INGEST_URL);
|
||||
|
||||
interface SessionState {
|
||||
sessionStarted?: boolean;
|
||||
runId?: string;
|
||||
runStartedAt?: number;
|
||||
runUsage?: Dict;
|
||||
spans: { [key: string]: string };
|
||||
}
|
||||
|
||||
// Token fields are reported per LLM call (api-result). A single run (user turn)
|
||||
// can span several calls, so we sum them into a per-run total that rides along
|
||||
// on run.end — the location the stats layer reads usage from.
|
||||
const USAGE_TOKEN_FIELDS = [
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'total_tokens',
|
||||
'cache_read_tokens',
|
||||
'cache_write_tokens',
|
||||
'reasoning_tokens',
|
||||
'total_cost',
|
||||
];
|
||||
|
||||
function accumulateUsage(into: Dict, usage: Dict | undefined): Dict {
|
||||
if (!usage) {
|
||||
return into;
|
||||
}
|
||||
for (const key of USAGE_TOKEN_FIELDS) {
|
||||
const v = pickNumber(usage[key]);
|
||||
if (v !== undefined) {
|
||||
into[key] = (pickNumber(into[key]) ?? 0) + v;
|
||||
}
|
||||
}
|
||||
return into;
|
||||
}
|
||||
|
||||
function runUsagePayload(state: SessionState): Dict | undefined {
|
||||
return state.runUsage && Object.keys(state.runUsage).length > 0 ? state.runUsage : undefined;
|
||||
}
|
||||
|
||||
const STATE_DIR = join(homedir(), '.agentmon-state', 'hermes');
|
||||
|
||||
function ensureStateDir() {
|
||||
try {
|
||||
mkdirSync(STATE_DIR, { recursive: true });
|
||||
} catch {
|
||||
// Hooks should never fail the agent because telemetry state is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function loadState(sessionKey: string): SessionState {
|
||||
try {
|
||||
const raw = readFileSync(join(STATE_DIR, sessionKey + '.json'), 'utf8');
|
||||
const state = JSON.parse(raw) as SessionState;
|
||||
return { spans: {}, ...state };
|
||||
} catch {
|
||||
return { spans: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(sessionKey: string | undefined, state: SessionState) {
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
ensureStateDir();
|
||||
try {
|
||||
writeFileSync(join(STATE_DIR, sessionKey + '.json'), JSON.stringify(state), 'utf8');
|
||||
} catch {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
|
||||
function clearState(sessionKey: string | undefined) {
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
unlinkSync(join(STATE_DIR, sessionKey + '.json'));
|
||||
} catch {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
|
||||
function getExtra(input: Dict): Dict {
|
||||
return isRecord(input.extra) ? input.extra : {};
|
||||
}
|
||||
|
||||
function getSessionKey(input: Dict): string | undefined {
|
||||
const extra = getExtra(input);
|
||||
return pickString(
|
||||
input.session_id,
|
||||
input.sessionId,
|
||||
input.sessionID,
|
||||
input.session,
|
||||
extra.session_id,
|
||||
extra.sessionId,
|
||||
extra.sessionID,
|
||||
extra.parent_session_id,
|
||||
extra.task_id,
|
||||
);
|
||||
}
|
||||
|
||||
function getToolCallId(input: Dict): string | undefined {
|
||||
const extra = getExtra(input);
|
||||
return pickString(input.tool_call_id, extra.tool_call_id, extra.call_id, extra.id);
|
||||
}
|
||||
|
||||
function getToolName(input: Dict): string {
|
||||
const extra = getExtra(input);
|
||||
return pickString(input.tool_name, input.tool, input.name, extra.tool_name, extra.tool, extra.name) || 'unknown';
|
||||
}
|
||||
|
||||
function getUsage(input: Dict): Dict | undefined {
|
||||
const extra = getExtra(input);
|
||||
const usage = isRecord(input.usage) ? input.usage :
|
||||
isRecord(extra.usage) ? extra.usage :
|
||||
undefined;
|
||||
if (!usage) return undefined;
|
||||
|
||||
const result: Dict = {};
|
||||
if (usage.input_tokens !== undefined) result.input_tokens = usage.input_tokens;
|
||||
if (usage.prompt_tokens !== undefined) result.input_tokens = usage.prompt_tokens;
|
||||
if (usage.output_tokens !== undefined) result.output_tokens = usage.output_tokens;
|
||||
if (usage.completion_tokens !== undefined) result.output_tokens = usage.completion_tokens;
|
||||
if (usage.cache_read_tokens !== undefined) result.cache_read_tokens = usage.cache_read_tokens;
|
||||
if (usage.cache_write_tokens !== undefined) result.cache_write_tokens = usage.cache_write_tokens;
|
||||
if (usage.cache_creation_tokens !== undefined) result.cache_write_tokens = usage.cache_creation_tokens;
|
||||
if (usage.reasoning_tokens !== undefined) result.reasoning_tokens = usage.reasoning_tokens;
|
||||
if (usage.total_tokens !== undefined) result.total_tokens = usage.total_tokens;
|
||||
if (usage.total_cost !== undefined) result.total_cost = usage.total_cost;
|
||||
if (usage.cost !== undefined) result.total_cost = usage.cost;
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function getModel(input: Dict): string | undefined {
|
||||
const extra = getExtra(input);
|
||||
return pickString(input.model, extra.model, extra.response_model);
|
||||
}
|
||||
|
||||
function getPlatform(input: Dict): string | undefined {
|
||||
const extra = getExtra(input);
|
||||
return pickString(input.platform, extra.platform);
|
||||
}
|
||||
|
||||
function getDuration(input: Dict): number | undefined {
|
||||
const extra = getExtra(input);
|
||||
return pickNumber(input.duration_ms, input.elapsed_ms, extra.duration_ms, extra.api_duration, extra.elapsed_ms);
|
||||
}
|
||||
|
||||
function ensureSessionStarted(sessionKey: string | undefined, input: Dict, state: SessionState) {
|
||||
if (!sessionKey || state.sessionStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.start', sessionKey, {
|
||||
attributes: {
|
||||
synthetic: true,
|
||||
recovered_from: 'hermes-hook',
|
||||
cwd: pickString(input.cwd),
|
||||
platform: getPlatform(input),
|
||||
model: getModel(input),
|
||||
},
|
||||
}));
|
||||
state.sessionStarted = true;
|
||||
saveState(sessionKey, state);
|
||||
}
|
||||
|
||||
async function handleSessionStart(input: Dict) {
|
||||
const sessionKey = getSessionKey(input) || randomUUID();
|
||||
const state = loadState(sessionKey);
|
||||
if (!state.sessionStarted) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.start', sessionKey, {
|
||||
attributes: {
|
||||
cwd: pickString(input.cwd),
|
||||
platform: getPlatform(input),
|
||||
model: getModel(input),
|
||||
},
|
||||
}));
|
||||
state.sessionStarted = true;
|
||||
saveState(sessionKey, state);
|
||||
}
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function handleRunStart(input: Dict) {
|
||||
const sessionKey = getSessionKey(input) || randomUUID();
|
||||
const state = loadState(sessionKey);
|
||||
ensureSessionStarted(sessionKey, input, state);
|
||||
|
||||
if (state.runId) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
|
||||
runId: state.runId,
|
||||
payload: {
|
||||
status: 'success',
|
||||
duration_ms: state.runStartedAt ? Date.now() - state.runStartedAt : undefined,
|
||||
...(runUsagePayload(state) && { usage: runUsagePayload(state) }),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const runId = randomUUID();
|
||||
state.runId = runId;
|
||||
state.runStartedAt = Date.now();
|
||||
state.runUsage = {};
|
||||
saveState(sessionKey, state);
|
||||
|
||||
const extra = getExtra(input);
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.start', sessionKey, {
|
||||
runId,
|
||||
attributes: {
|
||||
is_first_turn: extra.is_first_turn,
|
||||
platform: getPlatform(input),
|
||||
model: getModel(input),
|
||||
},
|
||||
payload: {
|
||||
prompt_preview: truncate(extra.user_message ?? input.user_message, 200),
|
||||
},
|
||||
}));
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function handleRunEnd(input: Dict) {
|
||||
const sessionKey = getSessionKey(input);
|
||||
if (!sessionKey) {
|
||||
await flush();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = loadState(sessionKey);
|
||||
ensureSessionStarted(sessionKey, input, state);
|
||||
|
||||
if (state.runId) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
|
||||
runId: state.runId,
|
||||
payload: {
|
||||
status: 'success',
|
||||
duration_ms: getDuration(input) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : undefined),
|
||||
model: getModel(input),
|
||||
response_preview: truncate(getExtra(input).assistant_response, 500),
|
||||
...(runUsagePayload(state) && { usage: runUsagePayload(state) }),
|
||||
},
|
||||
}));
|
||||
state.runId = undefined;
|
||||
state.runStartedAt = undefined;
|
||||
state.runUsage = {};
|
||||
saveState(sessionKey, state);
|
||||
}
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function handleToolStart(input: Dict) {
|
||||
const sessionKey = getSessionKey(input);
|
||||
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
||||
ensureSessionStarted(sessionKey, input, state);
|
||||
|
||||
const toolName = getToolName(input);
|
||||
const toolCallId = getToolCallId(input);
|
||||
const spanKey = toolCallId || toolName;
|
||||
const spanId = randomUUID();
|
||||
state.spans[spanKey] = spanId;
|
||||
saveState(sessionKey, state);
|
||||
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'span.start', sessionKey, {
|
||||
runId: state.runId,
|
||||
spanId,
|
||||
attributes: {
|
||||
span_kind: 'tool',
|
||||
name: toolName,
|
||||
tool_call_id: toolCallId,
|
||||
},
|
||||
payload: {
|
||||
input: truncate(input.tool_input ?? getExtra(input).args, 500),
|
||||
},
|
||||
}));
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function handleToolEnd(input: Dict) {
|
||||
const sessionKey = getSessionKey(input);
|
||||
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
||||
const toolName = getToolName(input);
|
||||
const toolCallId = getToolCallId(input);
|
||||
const spanKey = toolCallId || toolName;
|
||||
const spanId = state.spans[spanKey];
|
||||
const extra = getExtra(input);
|
||||
const result = extra.result ?? input.result;
|
||||
const resultRecord = isRecord(result) ? result : {};
|
||||
const success = extra.error === undefined && resultRecord.error === undefined;
|
||||
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'span.end', sessionKey, {
|
||||
runId: state.runId,
|
||||
spanId,
|
||||
attributes: {
|
||||
span_kind: 'tool',
|
||||
name: toolName,
|
||||
tool_call_id: toolCallId,
|
||||
},
|
||||
payload: {
|
||||
status: success ? 'success' : 'error',
|
||||
result_preview: truncate(resultRecord.output ?? resultRecord.error ?? extra.error ?? result, 500),
|
||||
duration_ms: getDuration(input),
|
||||
},
|
||||
}));
|
||||
|
||||
delete state.spans[spanKey];
|
||||
saveState(sessionKey, state);
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function handleAPIResult(input: Dict) {
|
||||
const sessionKey = getSessionKey(input);
|
||||
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
||||
const usage = getUsage(input);
|
||||
if (!usage) {
|
||||
await flush();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionKey) {
|
||||
state.runUsage = accumulateUsage(state.runUsage || {}, usage);
|
||||
saveState(sessionKey, state);
|
||||
}
|
||||
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'metric.snapshot', sessionKey, {
|
||||
runId: state.runId,
|
||||
payload: {
|
||||
metrics: {
|
||||
usage,
|
||||
model: getModel(input),
|
||||
provider: pickString(getExtra(input).provider),
|
||||
duration_ms: getDuration(input),
|
||||
},
|
||||
},
|
||||
}));
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function handleSessionEnd(input: Dict) {
|
||||
const sessionKey = getSessionKey(input);
|
||||
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
||||
if (state.runId) {
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
|
||||
runId: state.runId,
|
||||
payload: {
|
||||
status: input.interrupted || getExtra(input).interrupted ? 'interrupted' : 'success',
|
||||
duration_ms: state.runStartedAt ? Date.now() - state.runStartedAt : undefined,
|
||||
model: getModel(input),
|
||||
...(runUsagePayload(state) && { usage: runUsagePayload(state) }),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.end', sessionKey, {
|
||||
payload: {
|
||||
model: getModel(input),
|
||||
completed: getExtra(input).completed,
|
||||
interrupted: getExtra(input).interrupted,
|
||||
},
|
||||
}));
|
||||
clearState(sessionKey);
|
||||
await flush();
|
||||
}
|
||||
|
||||
const handler = async () => {
|
||||
const hookType = process.argv[2] || 'unknown';
|
||||
|
||||
let input: Dict = {};
|
||||
try {
|
||||
const stdin = await readStdin();
|
||||
if (stdin) {
|
||||
input = JSON.parse(stdin);
|
||||
}
|
||||
} catch {
|
||||
input = {};
|
||||
}
|
||||
|
||||
try {
|
||||
switch (hookType) {
|
||||
case 'session-start':
|
||||
case 'start':
|
||||
await handleSessionStart(input);
|
||||
break;
|
||||
case 'run-start':
|
||||
case 'pre-llm':
|
||||
await handleRunStart(input);
|
||||
break;
|
||||
case 'run-end':
|
||||
case 'post-llm':
|
||||
await handleRunEnd(input);
|
||||
break;
|
||||
case 'tool-start':
|
||||
await handleToolStart(input);
|
||||
break;
|
||||
case 'tool-end':
|
||||
await handleToolEnd(input);
|
||||
break;
|
||||
case 'api-result':
|
||||
case 'post-api':
|
||||
await handleAPIResult(input);
|
||||
break;
|
||||
case 'session-end':
|
||||
case 'stop':
|
||||
await handleSessionEnd(input);
|
||||
break;
|
||||
default:
|
||||
console.debug(`[agentmon] unknown hermes hook type: ${hookType}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('[agentmon] hermes handler error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
handler();
|
||||
@@ -0,0 +1,21 @@
|
||||
hooks:
|
||||
on_session_start:
|
||||
- command: "~/.local/bin/agentmon-hermes-handler session-start"
|
||||
pre_llm_call:
|
||||
- command: "~/.local/bin/agentmon-hermes-handler run-start"
|
||||
post_llm_call:
|
||||
- command: "~/.local/bin/agentmon-hermes-handler run-end"
|
||||
pre_tool_call:
|
||||
- matcher: ".*"
|
||||
command: "~/.local/bin/agentmon-hermes-handler tool-start"
|
||||
post_tool_call:
|
||||
- matcher: ".*"
|
||||
command: "~/.local/bin/agentmon-hermes-handler tool-end"
|
||||
post_api_request:
|
||||
- command: "~/.local/bin/agentmon-hermes-handler api-result"
|
||||
on_session_finalize:
|
||||
- command: "~/.local/bin/agentmon-hermes-handler session-end"
|
||||
on_session_reset:
|
||||
- command: "~/.local/bin/agentmon-hermes-handler session-start"
|
||||
|
||||
hooks_auto_accept: true
|
||||
Generated
+463
@@ -0,0 +1,463 @@
|
||||
{
|
||||
"name": "@anthropic-ai/agentmon-hermes",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@anthropic-ai/agentmon-hermes",
|
||||
"version": "1.0.0",
|
||||
"bin": {
|
||||
"agentmon-hermes-handler": "handler.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
|
||||
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
|
||||
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
|
||||
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
|
||||
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
|
||||
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
|
||||
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
|
||||
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
|
||||
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
|
||||
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
|
||||
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
|
||||
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.20.2",
|
||||
"@esbuild/android-arm": "0.20.2",
|
||||
"@esbuild/android-arm64": "0.20.2",
|
||||
"@esbuild/android-x64": "0.20.2",
|
||||
"@esbuild/darwin-arm64": "0.20.2",
|
||||
"@esbuild/darwin-x64": "0.20.2",
|
||||
"@esbuild/freebsd-arm64": "0.20.2",
|
||||
"@esbuild/freebsd-x64": "0.20.2",
|
||||
"@esbuild/linux-arm": "0.20.2",
|
||||
"@esbuild/linux-arm64": "0.20.2",
|
||||
"@esbuild/linux-ia32": "0.20.2",
|
||||
"@esbuild/linux-loong64": "0.20.2",
|
||||
"@esbuild/linux-mips64el": "0.20.2",
|
||||
"@esbuild/linux-ppc64": "0.20.2",
|
||||
"@esbuild/linux-riscv64": "0.20.2",
|
||||
"@esbuild/linux-s390x": "0.20.2",
|
||||
"@esbuild/linux-x64": "0.20.2",
|
||||
"@esbuild/netbsd-x64": "0.20.2",
|
||||
"@esbuild/openbsd-x64": "0.20.2",
|
||||
"@esbuild/sunos-x64": "0.20.2",
|
||||
"@esbuild/win32-arm64": "0.20.2",
|
||||
"@esbuild/win32-ia32": "0.20.2",
|
||||
"@esbuild/win32-x64": "0.20.2"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@anthropic-ai/agentmon-hermes",
|
||||
"version": "1.0.0",
|
||||
"description": "agentmon hook handler for Hermes Agent",
|
||||
"main": "handler.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"agentmon-hermes-handler": "./handler.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npx esbuild handler.ts --bundle --platform=node --format=esm --outfile=handler.js"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func (d *DB) GetSessionWithRuns(ctx context.Context, sessionID string) (*Session
|
||||
session_id,
|
||||
MIN(ts) as started_at,
|
||||
MAX(CASE WHEN type = 'session.end' THEN ts END) as ended_at,
|
||||
MAX(source_framework) as framework,
|
||||
COALESCE((ARRAY_AGG(source_framework ORDER BY CASE WHEN type = 'session.start' THEN 0 ELSE 1 END, ts) FILTER (WHERE source_framework IS NOT NULL))[1], 'unknown') as framework,
|
||||
MAX(payload->'event'->'source'->>'host') as host
|
||||
FROM events
|
||||
WHERE session_id = $1
|
||||
|
||||
@@ -89,7 +89,7 @@ func (d *DB) ListSessions(ctx context.Context, f SessionsFilter) ([]SessionRow,
|
||||
session_id,
|
||||
MIN(ts) as started_at,
|
||||
MAX(CASE WHEN type = 'session.end' THEN ts END) as ended_at,
|
||||
MAX(source_framework) as framework,
|
||||
COALESCE((ARRAY_AGG(source_framework ORDER BY CASE WHEN type = 'session.start' THEN 0 ELSE 1 END, ts) FILTER (WHERE source_framework IS NOT NULL))[1], 'unknown') as framework,
|
||||
MAX(client_id) as client_id,
|
||||
MAX(payload->'event'->'source'->>'host') as host,
|
||||
COUNT(DISTINCT run_id) as run_count
|
||||
@@ -136,3 +136,59 @@ func (d *DB) ListSessions(ctx context.Context, f SessionsFilter) ([]SessionRow,
|
||||
|
||||
return out, nextCursor, rows.Err()
|
||||
}
|
||||
|
||||
// CountSessions returns the total number of sessions matching the filter (without limit or cursor).
|
||||
func (d *DB) CountSessions(ctx context.Context, f SessionsFilter) (int, error) {
|
||||
var innerConditions []string
|
||||
var outerConditions []string
|
||||
var args []any
|
||||
argN := 1
|
||||
|
||||
if f.From == nil {
|
||||
t := time.Now().Add(-24 * time.Hour)
|
||||
f.From = &t
|
||||
}
|
||||
innerConditions = append(innerConditions, fmt.Sprintf("ts >= $%d", argN))
|
||||
args = append(args, *f.From)
|
||||
argN++
|
||||
|
||||
if f.To != nil {
|
||||
innerConditions = append(innerConditions, fmt.Sprintf("ts <= $%d", argN))
|
||||
args = append(args, *f.To)
|
||||
argN++
|
||||
}
|
||||
|
||||
if f.Framework != "" {
|
||||
innerConditions = append(innerConditions, fmt.Sprintf("source_framework = $%d", argN))
|
||||
args = append(args, f.Framework)
|
||||
argN++
|
||||
}
|
||||
|
||||
if f.Host != "" {
|
||||
outerConditions = append(outerConditions, fmt.Sprintf("host = $%d", argN))
|
||||
args = append(args, f.Host)
|
||||
argN++
|
||||
}
|
||||
|
||||
innerWhere := strings.Join(innerConditions, " AND ")
|
||||
outerWhere := ""
|
||||
if len(outerConditions) > 0 {
|
||||
outerWhere = "WHERE " + strings.Join(outerConditions, " AND ")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH session_groups AS (
|
||||
SELECT
|
||||
session_id,
|
||||
MAX(payload->'event'->'source'->>'host') as host
|
||||
FROM events
|
||||
WHERE session_id IS NOT NULL AND %s
|
||||
GROUP BY session_id
|
||||
)
|
||||
SELECT COUNT(*) FROM session_groups %s
|
||||
`, innerWhere, outerWhere)
|
||||
|
||||
var count int
|
||||
err := d.sql.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ type TimeseriesBucket struct {
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
AvgDurationMS float64 `json:"avg_duration_ms"`
|
||||
ToolAvgMS float64 `json:"tool_avg_ms"`
|
||||
ToolP95MS float64 `json:"tool_p95_ms"`
|
||||
}
|
||||
|
||||
type TimeseriesResult struct {
|
||||
@@ -51,7 +53,7 @@ func (d *DB) GetSummary(ctx context.Context) (*Summary, error) {
|
||||
WITH session_groups AS (
|
||||
SELECT
|
||||
session_id,
|
||||
COALESCE(MAX(source_framework), 'unknown') AS framework,
|
||||
COALESCE((ARRAY_AGG(source_framework ORDER BY CASE WHEN type = 'session.start' THEN 0 ELSE 1 END, ts) FILTER (WHERE source_framework IS NOT NULL))[1], 'unknown') AS framework,
|
||||
MAX(ts) AS last_event_at,
|
||||
BOOL_OR(type = 'session.start') AS has_start,
|
||||
BOOL_OR(type = 'session.end') AS has_end
|
||||
@@ -157,8 +159,11 @@ func (d *DB) GetSummary(ctx context.Context) (*Summary, error) {
|
||||
}
|
||||
|
||||
type TopTool struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
AvgMS float64 `json:"avg_ms"`
|
||||
P95MS float64 `json:"p95_ms"`
|
||||
Errors int `json:"errors"`
|
||||
}
|
||||
|
||||
type TopModel struct {
|
||||
@@ -176,7 +181,11 @@ func (d *DB) GetTopTools(ctx context.Context, limit int) ([]TopTool, error) {
|
||||
q := `
|
||||
SELECT
|
||||
payload->'attributes'->>'name' AS tool_name,
|
||||
COUNT(*) AS cnt
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(AVG((payload->'payload'->>'duration_ms')::float8), 0) AS avg_ms,
|
||||
COALESCE(percentile_cont(0.95) WITHIN GROUP (
|
||||
ORDER BY (payload->'payload'->>'duration_ms')::float8), 0) AS p95_ms,
|
||||
COUNT(*) FILTER (WHERE payload->'payload'->>'status' = 'error') AS errors
|
||||
FROM events
|
||||
WHERE type = 'span.end'
|
||||
AND payload->'attributes'->>'span_kind' = 'tool'
|
||||
@@ -195,7 +204,7 @@ func (d *DB) GetTopTools(ctx context.Context, limit int) ([]TopTool, error) {
|
||||
var out []TopTool
|
||||
for rows.Next() {
|
||||
var t TopTool
|
||||
if err := rows.Scan(&t.Name, &t.Count); err != nil {
|
||||
if err := rows.Scan(&t.Name, &t.Count, &t.AvgMS, &t.P95MS, &t.Errors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
@@ -300,7 +309,14 @@ func (d *DB) GetTimeseries(ctx context.Context, window string) (*TimeseriesResul
|
||||
COALESCE(SUM((payload->'payload'->'usage'->>'total_cost')::float8)
|
||||
FILTER (WHERE type = 'run.end'), 0) AS cost,
|
||||
COALESCE(AVG((payload->'payload'->>'duration_ms')::float8)
|
||||
FILTER (WHERE type = 'run.end'), 0) AS avg_duration_ms
|
||||
FILTER (WHERE type = 'run.end'), 0) AS avg_duration_ms,
|
||||
COALESCE(AVG((payload->'payload'->>'duration_ms')::float8)
|
||||
FILTER (WHERE type = 'span.end'
|
||||
AND payload->'attributes'->>'span_kind' = 'tool'), 0) AS tool_avg_ms,
|
||||
COALESCE(percentile_cont(0.95) WITHIN GROUP (
|
||||
ORDER BY (payload->'payload'->>'duration_ms')::float8)
|
||||
FILTER (WHERE type = 'span.end'
|
||||
AND payload->'attributes'->>'span_kind' = 'tool'), 0) AS tool_p95_ms
|
||||
FROM events
|
||||
WHERE ts >= $2
|
||||
AND type IN ('run.start', 'run.end', 'span.end', 'error')
|
||||
@@ -318,7 +334,8 @@ func (d *DB) GetTimeseries(ctx context.Context, window string) (*TimeseriesResul
|
||||
for rows.Next() {
|
||||
var b TimeseriesBucket
|
||||
if err := rows.Scan(&b.TS, &b.Runs, &b.Tools, &b.Errors,
|
||||
&b.Tokens, &b.InputTokens, &b.OutputTokens, &b.Cost, &b.AvgDurationMS); err != nil {
|
||||
&b.Tokens, &b.InputTokens, &b.OutputTokens, &b.Cost, &b.AvgDurationMS,
|
||||
&b.ToolAvgMS, &b.ToolP95MS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
series = append(series, b)
|
||||
|
||||
Reference in New Issue
Block a user