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
|
/web-ui
|
||||||
|
|
||||||
/swarm-monitor
|
/swarm-monitor
|
||||||
|
/event-processor
|
||||||
|
/openclaw-monitor
|
||||||
/hooks/*/node_modules/
|
/hooks/*/node_modules/
|
||||||
/build/
|
/build/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Telemetry and monitoring system for AI agent activity across [OpenClaw](https://
|
|||||||
```
|
```
|
||||||
┌──────────────────────────┐
|
┌──────────────────────────┐
|
||||||
│ OpenClaw VMs │
|
│ OpenClaw VMs │
|
||||||
│ (zap, orb, sun) │
|
│ (zap) │
|
||||||
│ │
|
│ │
|
||||||
│ hooks/agentmon/ │
|
│ hooks/agentmon/ │
|
||||||
│ → handler.ts │
|
│ → handler.ts │
|
||||||
@@ -187,7 +187,7 @@ The hook is deployed to each VM at `~/.openclaw/hooks/agentmon/`. Two environmen
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
AGENTMON_INGEST_URL=http://192.168.122.1:8080
|
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`.
|
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.
|
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
|
## Go SDK
|
||||||
|
|
||||||
Emit events from Go applications:
|
Emit events from Go applications:
|
||||||
|
|||||||
@@ -207,6 +207,15 @@ func main() {
|
|||||||
if nextCursor != nil {
|
if nextCursor != nil {
|
||||||
resp["next_cursor"] = nextCursor.Format(time.RFC3339Nano)
|
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)
|
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 ──────────────────────────────────────
|
// ── 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) {
|
function ensureAgentBucket(evt) {
|
||||||
const identity = getAgentIdentity(evt);
|
const identity = getAgentIdentity(evt);
|
||||||
if (!identity.key) return null;
|
if (!identity.key) return null;
|
||||||
@@ -258,7 +266,8 @@ function renderAgentLanes() {
|
|||||||
const opsHTML = ops.length > 0 ? `<div class="active-ops">${ops.map(op => {
|
const opsHTML = ops.length > 0 ? `<div class="active-ops">${ops.map(op => {
|
||||||
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
|
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
|
||||||
const stale = elapsed > 300;
|
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 `
|
return `
|
||||||
<div class="active-op${stale ? ' stale' : ''}${kindClass}">
|
<div class="active-op${stale ? ' stale' : ''}${kindClass}">
|
||||||
<span class="active-op-dot"></span>
|
<span class="active-op-dot"></span>
|
||||||
@@ -276,7 +285,7 @@ function renderAgentLanes() {
|
|||||||
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="timeline-event">
|
<div class="timeline-event ${eventKindClass(eventType)}">
|
||||||
<div class="timeline-event-header">
|
<div class="timeline-event-header">
|
||||||
${getEventIcon(eventType)}
|
${getEventIcon(eventType)}
|
||||||
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
||||||
@@ -675,7 +684,7 @@ function renderAgentsLive() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="live-run-events">
|
<div class="live-run-events">
|
||||||
${group.events.map(evt => `
|
${group.events.map(evt => `
|
||||||
<div class="timeline-event live-event">
|
<div class="timeline-event live-event ${eventKindClass(getEnvelopeType(evt))}">
|
||||||
<div class="timeline-event-header">
|
<div class="timeline-event-header">
|
||||||
${getEventIcon(getEnvelopeType(evt))}
|
${getEventIcon(getEnvelopeType(evt))}
|
||||||
<span class="timeline-event-type">${escapeHTML(getEventLabel(getEnvelopeType(evt)))}</span>
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(getEnvelopeType(evt)))}</span>
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ import {
|
|||||||
import { clearErrorBadge } from '../palette.js';
|
import { clearErrorBadge } from '../palette.js';
|
||||||
import { app, navigate, isRouteCurrent } from '../router.js';
|
import { app, navigate, isRouteCurrent } from '../router.js';
|
||||||
import { api } from '../api.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
|
// uPlot is loaded as a global IIFE; access via window.uPlot
|
||||||
/* global uPlot */
|
/* global uPlot */
|
||||||
@@ -182,7 +189,13 @@ function renderSummaryCards() {
|
|||||||
const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0);
|
const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0);
|
||||||
const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 0;
|
const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 0;
|
||||||
animateCounter('dash-error-rate', rate.toFixed(1) + '%');
|
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];
|
const peakBucket = dashboardState.timeseries.series[stats.peakIndex];
|
||||||
container.innerHTML = `
|
container.innerHTML = metricStrip([
|
||||||
<div class="chart-insight-pill"><span class="chart-insight-label">window total</span><strong>${escapeHTML(formatCount(stats.totalEvents))}</strong></div>
|
{ label: 'window total', value: formatCount(stats.totalEvents), variant: 'insight' },
|
||||||
<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>
|
{ label: 'peak bucket', value: formatCount(stats.peakTotal), meta: formatBucketLabel(peakBucket.ts), variant: 'insight' },
|
||||||
<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>
|
{ label: 'mix', value: `${formatCount(stats.totalRuns)}r / ${formatCount(stats.totalTools)}t / ${formatCount(stats.totalErrors)}e`, variant: 'insight' },
|
||||||
<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>
|
{ label: 'bucket', value: dashboardState.timeseries.bucket || '-', meta: stats.bucketCount + ' points', variant: 'insight' },
|
||||||
`;
|
], { variant: 'insights' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDashboardChartHover(idx) {
|
function renderDashboardChartHover(idx) {
|
||||||
@@ -475,6 +488,10 @@ function tallyTool(evt) {
|
|||||||
if (attrs.span_kind === 'tool') {
|
if (attrs.span_kind === 'tool') {
|
||||||
const name = attrs.name || 'unknown';
|
const name = attrs.name || 'unknown';
|
||||||
dashboardState.toolCounts[name] = (dashboardState.toolCounts[name] || 0) + 1;
|
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 class="token-stat-value">${escapeHTML(formatTokenCount(totalTokens))}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="token-io-bars">
|
<div class="token-io-bars">
|
||||||
<div class="token-bar-row">
|
${barRow({ name: 'Input', count: inputTokens, countDisplay: formatTokenCount(inputTokens), max: maxIO, modifier: 'input', size: 'md' })}
|
||||||
<span class="token-bar-label">Input</span>
|
${barRow({ name: 'Output', count: outputTokens, countDisplay: formatTokenCount(outputTokens), max: maxIO, modifier: 'output', size: 'md' })}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="token-cost-display">
|
<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>
|
<strong>${escapeHTML(totalCost ? formatCost(totalCost) : '$0.0000')}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -617,42 +622,34 @@ function renderLatencyPanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const durSeries = ts.series.map(b => b.avg_duration_ms || 0).filter(v => v > 0);
|
const latencyBuckets = ts.series.filter(b => (b.tool_avg_ms || 0) > 0);
|
||||||
if (durSeries.length === 0) {
|
if (latencyBuckets.length === 0) {
|
||||||
container.innerHTML = '<p class="empty-state" style="padding:1rem">No run latency recorded yet</p>';
|
container.innerHTML = '<p class="empty-state" style="padding:1rem">No tool latency recorded yet</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const durSeries = latencyBuckets.map(b => b.tool_avg_ms || 0);
|
||||||
const avg = durSeries.reduce((a, b) => a + b, 0) / durSeries.length;
|
const avg = durSeries.reduce((a, b) => a + b, 0) / durSeries.length;
|
||||||
const min = Math.min(...durSeries);
|
const min = Math.min(...durSeries);
|
||||||
const max = Math.max(...durSeries);
|
const p95 = Math.max(...latencyBuckets.map(b => b.tool_p95_ms || 0));
|
||||||
const maxBar = max || 1;
|
const maxBar = Math.max(...durSeries) || 1;
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="latency-panel">
|
<div class="latency-panel">
|
||||||
<div class="latency-range">
|
<div class="latency-range">
|
||||||
<div class="latency-range-item">
|
${metricPill({ label: 'Min', value: formatDuration(min), variant: 'range' })}
|
||||||
<span class="latency-range-label">Min</span>
|
${metricPill({ label: 'Avg', value: formatDuration(avg), variant: 'range' })}
|
||||||
<span class="latency-range-val">${escapeHTML(formatDuration(min))}</span>
|
${metricPill({ label: 'P95', value: formatDuration(p95), variant: 'range' })}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="latency-mini-bars">
|
<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 pct = (v / maxBar * 100).toFixed(1);
|
||||||
const label = ts.series.filter(b => b.avg_duration_ms > 0)[i];
|
const title = formatBucketLabel(b.ts) + ': ' + formatDuration(v);
|
||||||
const title = label ? formatBucketLabel(label.ts) + ': ' + formatDuration(v) : formatDuration(v);
|
|
||||||
return `<div class="latency-mini-bar" style="height:${pct}%" title="${escapeHTML(title)}"></div>`;
|
return `<div class="latency-mini-bar" style="height:${pct}%" title="${escapeHTML(title)}"></div>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -675,21 +672,17 @@ function renderFrameworkBars() {
|
|||||||
|
|
||||||
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors));
|
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 total = stats.runs + stats.tools + stats.errors;
|
||||||
const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0;
|
|
||||||
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
return `
|
return barRow({
|
||||||
<div class="fw-bar-row">
|
name,
|
||||||
<div class="fw-bar-label">
|
count: total,
|
||||||
<span class="fw-bar-name">${escapeHTML(name)}</span>
|
countDisplay: total + ' events',
|
||||||
<span class="fw-bar-count">${total} events</span>
|
max: maxTotal,
|
||||||
</div>
|
fwClass: cssClass,
|
||||||
<div class="fw-bar-track">
|
size: 'lg',
|
||||||
<div class="fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div>
|
});
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('') + '</div>';
|
}).join('') + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,61 +751,28 @@ function renderDashFeed() {
|
|||||||
function renderDashTopTools() {
|
function renderDashTopTools() {
|
||||||
const list = document.getElementById('dash-top-tools');
|
const list = document.getElementById('dash-top-tools');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
const topTools = Object.entries(dashboardState.toolCounts)
|
const topTools = Object.entries(dashboardState.toolCounts)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.slice(0, 10);
|
.slice(0, 10)
|
||||||
|
.map(([name, count]) => {
|
||||||
if (topTools.length === 0) {
|
const durSum = dashboardState.toolDurations[name] || 0;
|
||||||
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No tool data yet</li>';
|
const avg = count > 0 ? durSum / count : 0;
|
||||||
return;
|
const countDisplay = avg > 0
|
||||||
}
|
? `${formatCount(count)} · ${formatDuration(avg)}`
|
||||||
|
: formatCount(count);
|
||||||
const maxCount = topTools[0]?.[1] || 1;
|
return { name, count, countDisplay };
|
||||||
list.innerHTML = topTools.map(([name, count]) => {
|
});
|
||||||
const pct = (count / maxCount * 100).toFixed(1);
|
list.innerHTML = barRankList(topTools, { emptyText: 'No tool data yet' });
|
||||||
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('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDashTopModels() {
|
function renderDashTopModels() {
|
||||||
const list = document.getElementById('dash-top-models');
|
const list = document.getElementById('dash-top-models');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
const topModels = Object.entries(dashboardState.modelCounts)
|
const topModels = Object.entries(dashboardState.modelCounts)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.slice(0, 10);
|
.slice(0, 10)
|
||||||
|
.map(([name, count]) => ({ name, count, modifier: 'model' }));
|
||||||
if (topModels.length === 0) {
|
list.innerHTML = barRankList(topModels, { emptyText: 'No model data yet' });
|
||||||
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('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Exports ──────────────────────────────────────────────
|
// ── Exports ──────────────────────────────────────────────
|
||||||
@@ -828,6 +788,7 @@ export async function renderDashboard(routeToken) {
|
|||||||
recentEvents: [],
|
recentEvents: [],
|
||||||
recentEventIDs: new Set(),
|
recentEventIDs: new Set(),
|
||||||
toolCounts: {},
|
toolCounts: {},
|
||||||
|
toolDurations: {},
|
||||||
modelCounts: {},
|
modelCounts: {},
|
||||||
rightPanelMode: localStorage.getItem('agentmon:dash:right-panel') || 'framework',
|
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 class="summary-card-sub" id="dash-errors-sub"> </div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metrics-strip">
|
<div style="margin-bottom:1.25rem">${metricStrip([
|
||||||
<div class="metric-pill">
|
{ label: 'Tokens today', valueId: 'dash-tokens-today' },
|
||||||
<span class="metric-pill-label">Tokens today</span>
|
{ label: 'Cost today', valueId: 'dash-cost-today' },
|
||||||
<span class="metric-pill-value" id="dash-tokens-today">-</span>
|
{ label: 'Avg run duration', valueId: 'dash-avg-duration' },
|
||||||
</div>
|
{ label: 'Error rate', valueId: 'dash-error-rate' },
|
||||||
<div class="metric-pill">
|
{ label: 'Cost / run', valueId: 'dash-cost-per-run' },
|
||||||
<span class="metric-pill-label">Cost today</span>
|
])}</div>
|
||||||
<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 class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
|
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
|
||||||
<div class="vm-strip" id="dash-vm-strip"></div>
|
<div class="vm-strip" id="dash-vm-strip"></div>
|
||||||
<div class="charts-row">
|
<div class="charts-row">
|
||||||
<div class="chart-panel">
|
<div class="chart-panel">
|
||||||
<div class="chart-header">
|
${chartHeader({
|
||||||
<div class="chart-title-group">
|
title: 'Event Rate',
|
||||||
<span class="chart-title">Event Rate</span>
|
subtitle: 'Runs, tool spans, and errors over time',
|
||||||
<span class="chart-subtitle">Runs, tool spans, and errors over time</span>
|
controls: `
|
||||||
</div>
|
|
||||||
<div class="chart-header-controls">
|
|
||||||
<div class="chart-legend">
|
<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 total"></span>total</span>
|
||||||
<span class="chart-legend-item"><span class="chart-legend-dot" style="background:#34d399"></span>runs</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="6h">6h</button>
|
||||||
<button class="window-btn" data-w="24h">24h</button>
|
<button class="window-btn" data-w="24h">24h</button>
|
||||||
<button class="window-btn" data-w="7d">7d</button>
|
<button class="window-btn" data-w="7d">7d</button>
|
||||||
</div>
|
</div>`,
|
||||||
</div>
|
})}
|
||||||
</div>
|
<div id="dash-chart-insights" style="margin-bottom:0.9rem"></div>
|
||||||
<div class="chart-insights" id="dash-chart-insights"></div>
|
|
||||||
<div class="chart-container" id="dash-chart"></div>
|
<div class="chart-container" id="dash-chart"></div>
|
||||||
<div class="chart-hover-panel" id="dash-chart-hover"></div>
|
<div class="chart-hover-panel" id="dash-chart-hover"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-panel right-panel">
|
<div class="chart-panel right-panel">
|
||||||
<div class="chart-header">
|
${chartHeader({
|
||||||
<div class="right-panel-tabs" id="dash-right-tabs">
|
controls: `
|
||||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button>
|
<div class="right-panel-tabs" id="dash-right-tabs">
|
||||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button>
|
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button>
|
||||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button>
|
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button>
|
||||||
</div>
|
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button>
|
||||||
</div>
|
</div>`,
|
||||||
|
})}
|
||||||
<div class="right-panel-body" id="dash-right-panel">
|
<div class="right-panel-body" id="dash-right-panel">
|
||||||
<p class="empty-state" style="padding:1rem">Loading...</p>
|
<p class="empty-state" style="padding:1rem">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -923,28 +871,20 @@ export async function renderDashboard(routeToken) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="bottom-panels">
|
<div class="bottom-panels">
|
||||||
<div class="feed-panel">
|
<div class="feed-panel">
|
||||||
<div class="chart-header">
|
${chartHeader({ title: 'Recent Activity' })}
|
||||||
<span class="chart-title">Recent Activity</span>
|
|
||||||
</div>
|
|
||||||
<div class="timeline" id="dash-feed">
|
<div class="timeline" id="dash-feed">
|
||||||
<p class="empty-state" style="padding:1rem">Loading...</p>
|
<p class="empty-state" style="padding:1rem">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tools-panel">
|
<div class="tools-panel">
|
||||||
<div class="chart-header">
|
${chartHeader({ title: 'Top Usage' })}
|
||||||
<span class="chart-title">Top Usage</span>
|
|
||||||
</div>
|
|
||||||
<div class="usage-rank-group">
|
<div class="usage-rank-group">
|
||||||
<div class="usage-rank-header">Tools</div>
|
<div class="usage-rank-header">Tools</div>
|
||||||
<ul class="stat-list" id="dash-top-tools">
|
<div id="dash-top-tools"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
|
||||||
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="usage-rank-group">
|
<div class="usage-rank-group">
|
||||||
<div class="usage-rank-header">Models</div>
|
<div class="usage-rank-header">Models</div>
|
||||||
<ul class="stat-list" id="dash-top-models">
|
<div id="dash-top-models"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
|
||||||
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1028,6 +968,7 @@ export async function renderDashboard(routeToken) {
|
|||||||
|
|
||||||
for (const t of (topToolsData.tools || [])) {
|
for (const t of (topToolsData.tools || [])) {
|
||||||
dashboardState.toolCounts[t.name] = t.count;
|
dashboardState.toolCounts[t.name] = t.count;
|
||||||
|
dashboardState.toolDurations[t.name] = (t.avg_ms || 0) * (t.count || 0);
|
||||||
}
|
}
|
||||||
for (const m of (topModelsData.models || [])) {
|
for (const m of (topModelsData.models || [])) {
|
||||||
dashboardState.modelCounts[m.name] = m.count;
|
dashboardState.modelCounts[m.name] = m.count;
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ export function cleanup() {
|
|||||||
runLiveOps = {};
|
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) {
|
function renderSpanPayload(sp) {
|
||||||
const outer = sp.payload || {};
|
const outer = sp.payload || {};
|
||||||
const inner = outer.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))
|
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||||||
: 'ongoing';
|
: 'ongoing';
|
||||||
const runUsage = extractRunUsage(spans);
|
const runUsage = extractRunUsage(spans);
|
||||||
|
const promptPreview = extractPromptPreview(spans);
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">← Back to Session</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
${!r.ended_at ? '<div class="run-live-ops" id="run-live-ops"></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">
|
<div class="section-title">
|
||||||
Spans <span class="count" id="run-detail-span-count">${spans.length}</span>
|
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>' : ''}
|
${!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();
|
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() {
|
function updateSessionTimers() {
|
||||||
const tbody = document.getElementById('sessions-body');
|
const tbody = document.getElementById('sessions-body');
|
||||||
|
|||||||
@@ -1,6 +1,128 @@
|
|||||||
import { app, isRouteCurrent } from '../router.js';
|
import { app, isRouteCurrent } from '../router.js';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { escapeHTML, formatTokenCount, formatCost } from '../utils.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) {
|
export async function renderUsage(routeToken) {
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
@@ -18,90 +140,115 @@ export async function renderUsage(routeToken) {
|
|||||||
|
|
||||||
const tools = toolsData.tools || [];
|
const tools = toolsData.tools || [];
|
||||||
const models = modelsData.models || [];
|
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 || {};
|
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');
|
const content = document.getElementById('usage-content');
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="usage-summary-tiles">
|
<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"><div class="meta-tile-label">Runs Today</div><div class="meta-tile-value">${s.runs_today || 0}</div></div>
|
<div class="meta-tile-label">Active Sessions</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-value">${s.active_sessions || 0}</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>
|
||||||
<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"><div class="meta-tile-label">Cost Today</div><div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div></div>
|
<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>
|
||||||
|
|
||||||
<div class="usage-section-row">
|
<div class="usage-section-row">
|
||||||
<div class="usage-panel">
|
<div class="usage-panel usage-chart-panel">
|
||||||
<div class="section-title">7-Day Totals</div>
|
${chartHeader({
|
||||||
<div class="usage-7d-tiles">
|
title: '7-Day Trend',
|
||||||
<div class="usage-7d-tile"><span class="usage-7d-label">Runs</span><strong>${totals7d.runs}</strong></div>
|
controls: `
|
||||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tool Calls</span><strong>${totals7d.tools}</strong></div>
|
<div class="usage-chart-tabs" id="usage-chart-tabs">
|
||||||
<div class="usage-7d-tile"><span class="usage-7d-label">Errors</span><strong>${totals7d.errors}</strong></div>
|
<button class="usage-chart-tab active" data-mode="activity">Activity</button>
|
||||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tokens</span><strong>${formatTokenCount(totals7d.tokens)}</strong></div>
|
<button class="usage-chart-tab" data-mode="tokens">Tokens</button>
|
||||||
<div class="usage-7d-tile"><span class="usage-7d-label">Est. Cost</span><strong>${formatCost(totals7d.cost)}</strong></div>
|
<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>
|
||||||
|
<div id="usage-fw-breakdown"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="usage-section-row">
|
<div class="usage-section-row">
|
||||||
<div class="usage-panel">
|
<div class="usage-panel">
|
||||||
<div class="section-title">Top Models <span class="count">${models.length}</span></div>
|
<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>' : `
|
${barRankList(models, {
|
||||||
<ul class="stat-list" id="usage-models-list">
|
mapItem: m => ({ name: m.name, count: m.count, modifier: 'model' }),
|
||||||
${(() => {
|
maxOverride: maxModel,
|
||||||
const max = models[0]?.count || 1;
|
emptyText: 'No model data',
|
||||||
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>`}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="usage-panel">
|
<div class="usage-panel">
|
||||||
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div>
|
<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>' : `
|
${barRankList(tools, {
|
||||||
<ul class="stat-list" id="usage-tools-list">
|
mapItem: x => ({ name: x.name, count: x.count }),
|
||||||
${(() => {
|
maxOverride: maxTool,
|
||||||
const max = tools[0]?.count || 1;
|
emptyText: 'No tool data',
|
||||||
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>`}
|
|
||||||
</div>
|
</div>
|
||||||
</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 { renderAgents, cleanup as cleanupAgents } from './pages/agents.js';
|
||||||
import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js';
|
import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js';
|
||||||
import { renderSettings } from './pages/settings.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
|
// Exported so all page modules can write into it without querying the DOM each time
|
||||||
export const app = document.getElementById('app');
|
export const app = document.getElementById('app');
|
||||||
@@ -31,6 +31,7 @@ export function cleanupLiveViews() {
|
|||||||
cleanupSessionDetail();
|
cleanupSessionDetail();
|
||||||
cleanupRunDetail();
|
cleanupRunDetail();
|
||||||
cleanupDashboard();
|
cleanupDashboard();
|
||||||
|
cleanupUsage();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function route() {
|
export function route() {
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export function getVMName(evt) {
|
|||||||
|
|
||||||
export function getVMClassName(vmName) {
|
export function getVMClassName(vmName) {
|
||||||
const normalized = String(vmName || 'unknown').toLowerCase();
|
const normalized = String(vmName || 'unknown').toLowerCase();
|
||||||
return ['zap', 'orb', 'sun'].includes(normalized) ? normalized : 'unknown';
|
return ['zap'].includes(normalized) ? normalized : 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEventIcon(eventType) {
|
export function getEventIcon(eventType) {
|
||||||
|
|||||||
+351
-289
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
--text: #b8c5d4;
|
--text: #b8c5d4;
|
||||||
--text-dim: #465a6e;
|
--text-dim: #465a6e;
|
||||||
|
--text-mute: #6b7f94;
|
||||||
--text-bright: #e4edf5;
|
--text-bright: #e4edf5;
|
||||||
|
|
||||||
--accent: #22d3ee;
|
--accent: #22d3ee;
|
||||||
@@ -1276,18 +1277,6 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
border: 1px solid rgba(34, 211, 238, 0.2);
|
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 {
|
.timeline-vm-tag.unknown {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -1416,57 +1405,214 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
margin-top: 0.1rem;
|
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;
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-list li {
|
.am-bar-list li {
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.35rem 0;
|
padding: 0.35rem 0;
|
||||||
border-bottom: 1px solid var(--border-soft);
|
border-bottom: 1px solid var(--border-soft);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-list li:last-child {
|
.am-bar-list li:last-child { border-bottom: none; }
|
||||||
border-bottom: none;
|
|
||||||
|
.am-bar-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-list-name {
|
.am-bar-row-head {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-list-bar-track {
|
.am-bar-row-name {
|
||||||
height: 3px;
|
font-family: var(--font-mono);
|
||||||
background: var(--surface-2);
|
font-size: 0.78rem;
|
||||||
border-radius: 2px;
|
color: var(--text);
|
||||||
margin-top: 0.3rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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%;
|
height: 100%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-radius: 2px;
|
transition: width 0.4s ease;
|
||||||
transition: width 0.3s 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 {
|
.event-icon {
|
||||||
@@ -1593,7 +1739,7 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
100% { transform: scale(1); }
|
100% { transform: scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-pill-value.bumped {
|
.am-pill-value.bumped {
|
||||||
animation: counterBump 400ms ease;
|
animation: counterBump 400ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1636,44 +1782,6 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
min-height: 280px;
|
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 {
|
.window-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -1747,50 +1855,6 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
background: rgba(52, 211, 153, 0.12);
|
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 {
|
.chart-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
@@ -1870,55 +1934,6 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
.chart-hover-metric.errors strong { color: #f87171; }
|
.chart-hover-metric.errors strong { color: #f87171; }
|
||||||
.chart-hover-metric.delta strong { color: var(--accent); }
|
.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 {
|
.bottom-panels {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 320px;
|
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);
|
box-shadow: 0 0 0 1px rgba(248, 250, 252, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-list-bar-fill.model {
|
|
||||||
background: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Framework dots ───────────────────────────────────────── */
|
/* ── Framework dots ───────────────────────────────────────── */
|
||||||
.fw-dot {
|
.fw-dot {
|
||||||
display: inline-block;
|
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.openclaw { background: var(--accent); --fw-glow: var(--accent); }
|
||||||
.fw-dot.claude-code { background: var(--success); --fw-glow: var(--success); }
|
.fw-dot.claude-code { background: var(--success); --fw-glow: var(--success); }
|
||||||
.fw-dot.opencode { background: var(--purple); --fw-glow: var(--purple); }
|
.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.unknown { background: var(--text-dim); --fw-glow: var(--text-dim); }
|
||||||
.fw-dot.ended { opacity: 0.3; }
|
.fw-dot.ended { opacity: 0.3; }
|
||||||
.fw-dot.active { box-shadow: 0 0 6px var(--fw-glow); animation: fwPulse 2s ease-in-out infinite; }
|
.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: #3d4a5c;
|
||||||
--text-dim: #8b9ab0;
|
--text-dim: #8b9ab0;
|
||||||
|
--text-mute: #5c6b80;
|
||||||
--text-bright: #1a2332;
|
--text-bright: #1a2332;
|
||||||
|
|
||||||
--accent: #0891b2;
|
--accent: #0891b2;
|
||||||
@@ -2244,7 +2260,8 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-mute);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -2307,7 +2324,7 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-mute);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Agent Lane Sparklines ────────────────────────────────── */
|
/* ── Agent Lane Sparklines ────────────────────────────────── */
|
||||||
@@ -2321,10 +2338,10 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
|
|
||||||
.agent-lane-sparkline-bar {
|
.agent-lane-sparkline-bar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--accent);
|
background: linear-gradient(to top, var(--accent), var(--accent-glow));
|
||||||
border-radius: 1px 1px 0 0;
|
border-radius: 1px 1px 0 0;
|
||||||
opacity: 0.5;
|
opacity: 0.8;
|
||||||
min-height: 1px;
|
min-height: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-lane-dot {
|
.agent-lane-dot {
|
||||||
@@ -2348,7 +2365,7 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
.agent-lane-status {
|
.agent-lane-status {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-dim);
|
color: var(--text-mute);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
@@ -2373,6 +2390,7 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
padding: 0.35rem 0.625rem;
|
padding: 0.35rem 0.625rem;
|
||||||
background: var(--accent-dim);
|
background: var(--accent-dim);
|
||||||
border: 1px solid rgba(34, 211, 238, 0.15);
|
border: 1px solid rgba(34, 211, 238, 0.15);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
animation: fadeUp 0.2s ease both;
|
animation: fadeUp 0.2s ease both;
|
||||||
@@ -2380,11 +2398,17 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
|
|
||||||
[data-theme="light"] .active-op {
|
[data-theme="light"] .active-op {
|
||||||
border-color: rgba(8, 145, 178, 0.2);
|
border-color: rgba(8, 145, 178, 0.2);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-op.run {
|
||||||
|
border-left-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-op.subagent {
|
.active-op.subagent {
|
||||||
background: rgba(167, 139, 250, 0.12);
|
background: rgba(167, 139, 250, 0.12);
|
||||||
border-color: rgba(167, 139, 250, 0.22);
|
border-color: rgba(167, 139, 250, 0.22);
|
||||||
|
border-left-color: var(--purple);
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-op.stale {
|
.active-op.stale {
|
||||||
@@ -2456,6 +2480,7 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
border: 1px solid var(--border-soft);
|
border: 1px solid var(--border-soft);
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
@@ -2468,6 +2493,29 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
font-size: 0.6rem;
|
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 {
|
.agent-lane-events .empty-state {
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -3115,44 +3163,6 @@ tr.clickable.active-session td:first-child {
|
|||||||
margin: 0;
|
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 ─────────────────────────────────────── */
|
||||||
.right-panel-tabs {
|
.right-panel-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3179,7 +3189,7 @@ tr.clickable.active-session td:first-child {
|
|||||||
.right-panel-tab.active {
|
.right-panel-tab.active {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: rgba(var(--accent-rgb, 99, 102, 241), 0.08);
|
background: var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-panel-body {
|
.right-panel-body {
|
||||||
@@ -3211,7 +3221,7 @@ tr.clickable.active-session td:first-child {
|
|||||||
.token-stat-value {
|
.token-stat-value {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: 'Fira Code', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
@@ -3222,47 +3232,6 @@ tr.clickable.active-session td:first-child {
|
|||||||
gap: 0.5rem;
|
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 {
|
.token-cost-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -3291,27 +3260,6 @@ tr.clickable.active-session td:first-child {
|
|||||||
justify-content: space-between;
|
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 {
|
.latency-mini-bars {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -3633,7 +3581,7 @@ tr.clickable.active-session td:first-child {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-header-controls {
|
.am-chart-controls {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -3932,3 +3880,117 @@ tr.clickable:focus-visible {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: var(--radius);
|
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:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16.13
|
||||||
container_name: agentmon-db
|
container_name: agentmon-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: pass
|
POSTGRES_PASSWORD: pass
|
||||||
@@ -22,7 +22,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
nats:
|
nats:
|
||||||
image: nats:latest
|
image: nats@sha256:7971c76fcd4057c090faf5bc7673199ffe0ae586704518e9a469f156155b4e47
|
||||||
container_name: agentmon-nats
|
container_name: agentmon-nats
|
||||||
ports:
|
ports:
|
||||||
- "4222:4222"
|
- "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 INGEST_URL = process.env.AGENTMON_INGEST_URL || "http://localhost:8080";
|
||||||
var FRAMEWORK = process.env.AGENTMON_FRAMEWORK || "claude-code";
|
var FRAMEWORK = process.env.AGENTMON_FRAMEWORK || "claude-code";
|
||||||
var HOST = process.env.AGENTMON_HOST || hostname();
|
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 { enqueue, flush } = createTransport(INGEST_URL);
|
||||||
var STATE_DIR = join(homedir(), ".agentmon-state");
|
var STATE_DIR = join(homedir(), ".agentmon-state");
|
||||||
function ensureStateDir() {
|
function ensureStateDir() {
|
||||||
@@ -231,6 +232,15 @@ function isNonPersistentClaudeLaunch() {
|
|||||||
({ cmd }) => cmd.includes("/claude") && cmd.includes("--no-session-persistence")
|
({ 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 activeRuns = /* @__PURE__ */ new Map();
|
||||||
var activeSpans = /* @__PURE__ */ new Map();
|
var activeSpans = /* @__PURE__ */ new Map();
|
||||||
var activeSubagents = /* @__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");
|
console.error("[agentmon] ignoring claude-code startup from --no-session-persistence launch");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (pickString(input.source) === "startup" && (!ALLOW_STARTUP_SESSIONS || !hasClaudeProcessAncestor())) {
|
||||||
|
console.error("[agentmon] ignoring claude-code startup session");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const runId = randomUUID2();
|
const runId = randomUUID2();
|
||||||
activeRuns.set(sessionKey, runId);
|
activeRuns.set(sessionKey, runId);
|
||||||
saveState(sessionKey, { runId, spans: {} });
|
saveState(sessionKey, { runId, runStartedAt: Date.now(), spans: {} });
|
||||||
const contextWindow = getContextWindow(input);
|
const contextWindow = getContextWindow(input);
|
||||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, {
|
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -348,7 +362,7 @@ async function handleSessionEnd(input) {
|
|||||||
const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0;
|
const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0;
|
||||||
const usage = getUsage(input);
|
const usage = getUsage(input);
|
||||||
const contextWindow = getContextWindow(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) {
|
if (runId) {
|
||||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
|
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
|
||||||
runId,
|
runId,
|
||||||
@@ -384,14 +398,23 @@ async function handlePromptSubmit(input) {
|
|||||||
runId,
|
runId,
|
||||||
payload: {
|
payload: {
|
||||||
status: "success",
|
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();
|
const newRunId = randomUUID2();
|
||||||
if (sessionKey) {
|
if (sessionKey) {
|
||||||
activeRuns.set(sessionKey, newRunId);
|
activeRuns.set(sessionKey, newRunId);
|
||||||
saveState(sessionKey, { runId: newRunId, spans: {} });
|
saveState(sessionKey, { runId: newRunId, runStartedAt: Date.now(), spans: {} });
|
||||||
}
|
}
|
||||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.start", sessionKey, {
|
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.start", sessionKey, {
|
||||||
runId: newRunId,
|
runId: newRunId,
|
||||||
@@ -590,10 +613,10 @@ async function handleNotification(input) {
|
|||||||
const notificationType = pickString(input.notification_type, input.type);
|
const notificationType = pickString(input.notification_type, input.type);
|
||||||
const usage = getUsage(input);
|
const usage = getUsage(input);
|
||||||
const contextWindow = getContextWindow(input);
|
const contextWindow = getContextWindow(input);
|
||||||
const duration = pickNumber(input.duration_ms, input.elapsed_ms);
|
|
||||||
if (notificationType === "Done" || notificationType === "success") {
|
if (notificationType === "Done" || notificationType === "success") {
|
||||||
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
||||||
const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0;
|
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) {
|
if (runId) {
|
||||||
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
|
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
|
||||||
runId,
|
runId,
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import {
|
|||||||
const INGEST_URL = process.env.AGENTMON_INGEST_URL || 'http://localhost:8080';
|
const INGEST_URL = process.env.AGENTMON_INGEST_URL || 'http://localhost:8080';
|
||||||
const FRAMEWORK = process.env.AGENTMON_FRAMEWORK || 'claude-code';
|
const FRAMEWORK = process.env.AGENTMON_FRAMEWORK || 'claude-code';
|
||||||
const HOST = process.env.AGENTMON_HOST || hostname();
|
const HOST = process.env.AGENTMON_HOST || hostname();
|
||||||
|
const ALLOW_STARTUP_SESSIONS = process.env.AGENTMON_CLAUDE_ALLOW_STARTUP === '1';
|
||||||
|
|
||||||
const { enqueue, flush } = createTransport(INGEST_URL);
|
const { enqueue, flush } = createTransport(INGEST_URL);
|
||||||
|
|
||||||
// ── Persisted state (survives between hook subprocess invocations) ──────────
|
// ── Persisted state (survives between hook subprocess invocations) ──────────
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
runId?: string;
|
runId?: string;
|
||||||
|
runStartedAt?: number; // epoch ms when the current run began
|
||||||
spans: { [key: string]: string }; // key = sessionKey:toolName, value = spanId
|
spans: { [key: string]: string }; // key = sessionKey:toolName, value = spanId
|
||||||
spanStartTimes?: { [spanId: string]: number }; // spanId -> epoch ms
|
spanStartTimes?: { [spanId: string]: number }; // spanId -> epoch ms
|
||||||
subagent?: { name: string; spanId: string };
|
subagent?: { name: string; spanId: string };
|
||||||
@@ -89,6 +91,14 @@ function isNonPersistentClaudeLaunch() {
|
|||||||
cmd.includes('/claude') && cmd.includes('--no-session-persistence')
|
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>();
|
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');
|
console.error('[agentmon] ignoring claude-code startup from --no-session-persistence launch');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (pickString(input.source) === 'startup' && (!ALLOW_STARTUP_SESSIONS || !hasClaudeProcessAncestor())) {
|
||||||
|
console.error('[agentmon] ignoring claude-code startup session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const runId = randomUUID();
|
const runId = randomUUID();
|
||||||
activeRuns.set(sessionKey, runId);
|
activeRuns.set(sessionKey, runId);
|
||||||
saveState(sessionKey, { runId, spans: {} });
|
saveState(sessionKey, { runId, runStartedAt: Date.now(), spans: {} });
|
||||||
|
|
||||||
const contextWindow = getContextWindow(input);
|
const contextWindow = getContextWindow(input);
|
||||||
|
|
||||||
@@ -217,7 +231,7 @@ async function handleSessionEnd(input: Dict) {
|
|||||||
const runId = sessionKey ? (activeRuns.get(sessionKey) || state.runId) : undefined;
|
const runId = sessionKey ? (activeRuns.get(sessionKey) || state.runId) : undefined;
|
||||||
const usage = getUsage(input);
|
const usage = getUsage(input);
|
||||||
const contextWindow = getContextWindow(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) {
|
if (runId) {
|
||||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
|
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
|
||||||
@@ -257,7 +271,17 @@ async function handlePromptSubmit(input: Dict) {
|
|||||||
runId,
|
runId,
|
||||||
payload: {
|
payload: {
|
||||||
status: 'success',
|
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();
|
const newRunId = randomUUID();
|
||||||
if (sessionKey) {
|
if (sessionKey) {
|
||||||
activeRuns.set(sessionKey, newRunId);
|
activeRuns.set(sessionKey, newRunId);
|
||||||
saveState(sessionKey, { runId: newRunId, spans: {} });
|
saveState(sessionKey, { runId: newRunId, runStartedAt: Date.now(), spans: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.start', sessionKey, {
|
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 notificationType = pickString(input.notification_type, input.type);
|
||||||
const usage = getUsage(input);
|
const usage = getUsage(input);
|
||||||
const contextWindow = getContextWindow(input);
|
const contextWindow = getContextWindow(input);
|
||||||
const duration = pickNumber(input.duration_ms, input.elapsed_ms);
|
|
||||||
|
|
||||||
if (notificationType === 'Done' || notificationType === 'success') {
|
if (notificationType === 'Done' || notificationType === 'success') {
|
||||||
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
|
||||||
const runId = sessionKey ? (activeRuns.get(sessionKey) || state.runId) : undefined;
|
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) {
|
if (runId) {
|
||||||
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
|
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,
|
session_id,
|
||||||
MIN(ts) as started_at,
|
MIN(ts) as started_at,
|
||||||
MAX(CASE WHEN type = 'session.end' THEN ts END) as ended_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
|
MAX(payload->'event'->'source'->>'host') as host
|
||||||
FROM events
|
FROM events
|
||||||
WHERE session_id = $1
|
WHERE session_id = $1
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func (d *DB) ListSessions(ctx context.Context, f SessionsFilter) ([]SessionRow,
|
|||||||
session_id,
|
session_id,
|
||||||
MIN(ts) as started_at,
|
MIN(ts) as started_at,
|
||||||
MAX(CASE WHEN type = 'session.end' THEN ts END) as ended_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(client_id) as client_id,
|
||||||
MAX(payload->'event'->'source'->>'host') as host,
|
MAX(payload->'event'->'source'->>'host') as host,
|
||||||
COUNT(DISTINCT run_id) as run_count
|
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()
|
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"`
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
AvgDurationMS float64 `json:"avg_duration_ms"`
|
AvgDurationMS float64 `json:"avg_duration_ms"`
|
||||||
|
ToolAvgMS float64 `json:"tool_avg_ms"`
|
||||||
|
ToolP95MS float64 `json:"tool_p95_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimeseriesResult struct {
|
type TimeseriesResult struct {
|
||||||
@@ -51,7 +53,7 @@ func (d *DB) GetSummary(ctx context.Context) (*Summary, error) {
|
|||||||
WITH session_groups AS (
|
WITH session_groups AS (
|
||||||
SELECT
|
SELECT
|
||||||
session_id,
|
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,
|
MAX(ts) AS last_event_at,
|
||||||
BOOL_OR(type = 'session.start') AS has_start,
|
BOOL_OR(type = 'session.start') AS has_start,
|
||||||
BOOL_OR(type = 'session.end') AS has_end
|
BOOL_OR(type = 'session.end') AS has_end
|
||||||
@@ -157,8 +159,11 @@ func (d *DB) GetSummary(ctx context.Context) (*Summary, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TopTool struct {
|
type TopTool struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
|
AvgMS float64 `json:"avg_ms"`
|
||||||
|
P95MS float64 `json:"p95_ms"`
|
||||||
|
Errors int `json:"errors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TopModel struct {
|
type TopModel struct {
|
||||||
@@ -176,7 +181,11 @@ func (d *DB) GetTopTools(ctx context.Context, limit int) ([]TopTool, error) {
|
|||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
payload->'attributes'->>'name' AS tool_name,
|
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
|
FROM events
|
||||||
WHERE type = 'span.end'
|
WHERE type = 'span.end'
|
||||||
AND payload->'attributes'->>'span_kind' = 'tool'
|
AND payload->'attributes'->>'span_kind' = 'tool'
|
||||||
@@ -195,7 +204,7 @@ func (d *DB) GetTopTools(ctx context.Context, limit int) ([]TopTool, error) {
|
|||||||
var out []TopTool
|
var out []TopTool
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t TopTool
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, t)
|
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)
|
COALESCE(SUM((payload->'payload'->'usage'->>'total_cost')::float8)
|
||||||
FILTER (WHERE type = 'run.end'), 0) AS cost,
|
FILTER (WHERE type = 'run.end'), 0) AS cost,
|
||||||
COALESCE(AVG((payload->'payload'->>'duration_ms')::float8)
|
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
|
FROM events
|
||||||
WHERE ts >= $2
|
WHERE ts >= $2
|
||||||
AND type IN ('run.start', 'run.end', 'span.end', 'error')
|
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() {
|
for rows.Next() {
|
||||||
var b TimeseriesBucket
|
var b TimeseriesBucket
|
||||||
if err := rows.Scan(&b.TS, &b.Runs, &b.Tools, &b.Errors,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
series = append(series, b)
|
series = append(series, b)
|
||||||
|
|||||||
Reference in New Issue
Block a user