diff --git a/README.md b/README.md index a7ee428..ac1a083 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,19 @@ AGENTMON_VM_NAME=zap # or orb, sun Deployment is automated via Ansible — see the [swarm ansible playbook](https://gitea-http.taildb3494.ts.net/will/swarm) `playbooks/customize.yml`. +## Codex Hook + +The `hooks/codex/` directory contains a TypeScript handler for Codex CLI telemetry. Current Codex support is session/run oriented: + +- `sessionStart` and `sessionEnd` map to `session.start`, `run.start`, `run.end`, and `session.end` +- `notify` maps turn-complete notifications into `run.end` +- prompt-submit hooks map user prompts into the next `run.start` +- usage payloads emit both `run.end.payload.usage` and a `metric.snapshot` event + +Sample Codex hook configuration lives in [hooks/codex/hooks.json](/home/will/lab/agentmon/hooks/codex/hooks.json). On the local Codex CLI version we checked (`0.116.0`), `notify` is confirmed. Online reports suggest prompt-submit hooks may appear as `userpromptsubmit` or `userPromptSubmit`, so the sample config includes those aliases. + +The current Codex integration does not assume tool or subagent span hooks exist. If a newer Codex CLI exposes official tool/span hooks, they can be added separately without changing the run/session flow above. + ## Go SDK Emit events from Go applications: diff --git a/cmd/query-api/main.go b/cmd/query-api/main.go index c4a3afd..0915263 100644 --- a/cmd/query-api/main.go +++ b/cmd/query-api/main.go @@ -125,6 +125,7 @@ func main() { Limit: limit, EventType: r.URL.Query().Get("event_type"), Framework: r.URL.Query().Get("framework"), + ClientID: r.URL.Query().Get("client_id"), } events, err := db.ListRecentEvents(r.Context(), f) if err != nil { @@ -195,6 +196,23 @@ func main() { httpx.WriteJSON(w, http.StatusOK, map[string]any{"session": session, "runs": runs}) }) + r.Get("/v1/agents/live", func(w http.ResponseWriter, r *http.Request) { + clientID := r.URL.Query().Get("client_id") + framework := r.URL.Query().Get("framework") + if clientID == "" || framework == "" { + httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "missing_agent_selector"}) + return + } + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + events, err := db.ListAgentLiveEvents(r.Context(), framework, clientID, limit) + if err != nil { + httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"}) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"events": events}) + }) + r.Get("/v1/runs/{runID}", func(w http.ResponseWriter, r *http.Request) { runID := chi.URLParam(r, "runID") run, spans, err := db.GetRunWithSpans(r.Context(), runID) diff --git a/cmd/web-ui/static/app.js b/cmd/web-ui/static/app.js index 9d5b6c8..d3e2372 100644 --- a/cmd/web-ui/static/app.js +++ b/cmd/web-ui/static/app.js @@ -475,6 +475,7 @@ const data = await api('/v1/sessions/' + sessionID); const s = data.session; const runs = data.runs || []; + const active = !s.ended_at; const duration = s.ended_at ? formatDuration(new Date(s.ended_at) - new Date(s.started_at)) : 'ongoing'; @@ -483,6 +484,10 @@ ← Back to Sessions
Loading...
'; @@ -1020,13 +1078,12 @@ } function createAgentsState() { - function agentBucket() { - return { sessions: {}, operations: {}, events: [], eventIDs: new Set() }; - } return { - agents: { zap: agentBucket(), orb: agentBucket(), sun: agentBucket() }, + agents: {}, stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} }, dbStats: { messages: 0, tools: 0, errors: 0 }, + viewMode: 'overview', + selectedAgentKey: '', timerInterval: null, }; } @@ -1044,9 +1101,134 @@ }); } + function normalizeAgentKey(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-'); + } + + function getAgentIdentity(evt) { + const source = getEnvelopeSource(evt); + const correlation = getEnvelopeCorrelation(evt); + const framework = source.framework || evt.source_framework || 'unknown'; + const host = source.host || ''; + const clientID = source.client_id || ''; + const sessionID = correlation.session_id || ''; + const name = clientID || host || framework || sessionID || 'unknown'; + const key = normalizeAgentKey(clientID || host || sessionID || framework || 'unknown'); + return { + key, + name, + framework, + host, + clientID, + sessionID, + }; + } + + function ensureAgentBucket(evt) { + const identity = getAgentIdentity(evt); + if (!identity.key) { + return null; + } + + if (!agentsState.agents[identity.key]) { + agentsState.agents[identity.key] = { + key: identity.key, + name: identity.name, + framework: identity.framework, + host: identity.host, + clientID: identity.clientID, + sessions: {}, + operations: {}, + events: [], + eventIDs: new Set(), + lastSeenAt: 0, + liveLoaded: false, + liveLoading: false, + }; + } + + const agent = agentsState.agents[identity.key]; + agent.name = identity.name || agent.name || identity.key; + agent.framework = identity.framework || agent.framework; + agent.host = identity.host || agent.host; + agent.clientID = identity.clientID || agent.clientID; + return agent; + } + + function getSortedAgentKeys() { + return Object.keys(agentsState.agents).sort((a, b) => { + const left = agentsState.agents[a]; + const right = agentsState.agents[b]; + const leftOnline = isAgentOnline(left); + const rightOnline = isAgentOnline(right); + + if (leftOnline !== rightOnline) { + return leftOnline ? -1 : 1; + } + return (left.name || left.key).localeCompare(right.name || right.key); + }); + } + + function ensureSelectedAgentKey() { + const keys = getSortedAgentKeys(); + if (keys.length === 0) { + agentsState.selectedAgentKey = ''; + return ''; + } + if (!agentsState.selectedAgentKey || !agentsState.agents[agentsState.selectedAgentKey]) { + agentsState.selectedAgentKey = keys[0]; + } + return agentsState.selectedAgentKey; + } + + function setAgentsViewMode(mode) { + agentsState.viewMode = mode === 'live' ? 'live' : 'overview'; + renderAgentsContent(); + if (agentsState.viewMode === 'live') { + void loadSelectedAgentLiveData(); + } + } + + function selectAgent(key, nextMode) { + if (!key || !agentsState.agents[key]) return; + agentsState.selectedAgentKey = key; + if (nextMode) { + agentsState.viewMode = nextMode; + } + renderAgentsContent(); + if (agentsState.viewMode === 'live') { + void loadSelectedAgentLiveData(); + } + } + + function isOpenClawVM(agent) { + const key = normalizeAgentKey(agent && agent.name); + return ['zap', 'orb', 'sun'].includes(key); + } + + function isAgentOnline(agent) { + if (!agent) { + return false; + } + + if (isOpenClawVM(agent)) { + const vmStatus = getVMStatus().find(v => v.name === normalizeAgentKey(agent.name)); + if (vmStatus) { + return vmStatus.active; + } + } + + const hasSessions = Object.keys(agent.sessions).length > 0; + const hasOps = Object.keys(agent.operations).length > 0; + const seenRecently = agent.lastSeenAt > 0 && (Date.now() - agent.lastSeenAt) < 300000; + return hasSessions || hasOps || seenRecently; + } + function getAgentBucket(evt) { - const name = getVMName(evt).toLowerCase(); - return agentsState.agents[name] || null; + return ensureAgentBucket(evt); } function processAgentEvent(evt) { @@ -1056,6 +1238,8 @@ const eventType = getEnvelopeType(evt); const correlation = getEnvelopeCorrelation(evt); const attrs = getEnvelopeAttributes(evt); + const ts = new Date(getEnvelopeTS(evt)).getTime(); + agent.lastSeenAt = Number.isFinite(ts) ? ts : Date.now(); if (eventType === 'session.start' && correlation.session_id) { agent.sessions[correlation.session_id] = { ts: getEnvelopeTS(evt) }; @@ -1069,6 +1253,7 @@ type: 'span', name: attrs.name || attrs.span_kind || 'unknown', kind: attrs.span_kind || '', + subType: attrs.type || '', startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(), }; } @@ -1102,8 +1287,21 @@ function getAgentDisplayOps(agent) { const now = Date.now(); const ops = Object.values(agent.operations).filter(op => (now - op.startedAt) < 300000); - const hasTools = ops.some(op => op.kind === 'tool'); - return hasTools ? ops.filter(op => op.kind === 'tool') : ops; + const hasSpecificSpans = ops.some(op => op.kind && op.kind !== 'run'); + return hasSpecificSpans ? ops.filter(op => op.kind && op.kind !== 'run') : ops; + } + + function isAgentTimelineEvent(evt) { + const eventType = getEnvelopeType(evt); + return [ + 'session.start', + 'session.end', + 'run.start', + 'run.end', + 'span.start', + 'span.end', + 'error', + ].includes(eventType); } async function renderAgents() { @@ -1113,36 +1311,38 @@Loading...
Loading...
Loading...
Loading...
Error loading agent activity: ${escapeHTML(e.message)}
`; } @@ -1150,29 +1350,96 @@ agentsUnsubscribe = subscribeWS(handleAgentsWS); } + function bindAgentViewToggle() { + const root = document.getElementById('agents-view-toggle'); + if (!root) return; + root.querySelectorAll('[data-mode]').forEach(button => { + button.addEventListener('click', () => { + setAgentsViewMode(button.dataset.mode || 'overview'); + }); + }); + } + + function updateAgentViewToggle() { + const root = document.getElementById('agents-view-toggle'); + if (!root) return; + root.querySelectorAll('[data-mode]').forEach(button => { + button.classList.toggle('active', button.dataset.mode === agentsState.viewMode); + }); + } + + function renderAgentsContent() { + renderAgentSummary(); + updateAgentViewToggle(); + if (agentsState.viewMode === 'live') { + renderAgentsLive(); + return; + } + renderAgentLanes(); + } + + async function loadSelectedAgentLiveData() { + const selectedKey = ensureSelectedAgentKey(); + if (!selectedKey) return; + + const agent = agentsState.agents[selectedKey]; + if (!agent || agent.liveLoaded || agent.liveLoading || !agent.clientID || !agent.framework) { + return; + } + + agent.liveLoading = true; + try { + const params = new URLSearchParams(); + params.set('client_id', agent.clientID); + params.set('framework', agent.framework); + params.set('limit', '250'); + const data = await api('/v1/agents/live?' + params.toString()); + addAgentEvents((data.events || []).slice().reverse()); + agent.liveLoaded = true; + } catch (err) { + console.error('Failed to load live agent context:', err); + } finally { + agent.liveLoading = false; + if (isCurrentPath('/agents') && agentsState.viewMode === 'live' && agentsState.selectedAgentKey === selectedKey) { + renderAgentsContent(); + } + } + } + function renderAgentLanes() { + const contentEl = document.getElementById('agents-content'); + if (!contentEl) return; + contentEl.innerHTML = ''; + const lanesEl = document.getElementById('agents-lanes'); if (!lanesEl) return; - const vmNames = ['zap', 'orb', 'sun']; + const agentKeys = getSortedAgentKeys(); - lanesEl.innerHTML = vmNames.map(name => { - const agent = agentsState.agents[name]; - const vmStatus = getVMStatus().find(v => v.name === name); - const isOnline = vmStatus && vmStatus.active; + if (agentKeys.length === 0) { + lanesEl.innerHTML = 'No recent agent activity
'; + return; + } + + lanesEl.innerHTML = agentKeys.map(key => { + const agent = agentsState.agents[key]; + const isOnline = isAgentOnline(agent); const sessionCount = Object.keys(agent.sessions).length; const ops = getAgentDisplayOps(agent); + const subagentCount = ops.filter(op => op.kind === 'agent' || op.subType === 'subagent').length; const statusClass = sessionCount > 0 ? ' has-sessions' : ''; const statusText = !isOnline ? 'offline' + : subagentCount > 0 ? subagentCount + ' subagent' + (subagentCount > 1 ? 's' : '') : sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '') : 'idle'; const opsHTML = ops.length > 0 ? `No recent activity
'; return ` -No recent agent activity
'; + return; + } + + const selected = agentsState.agents[selectedKey]; + const summary = getAgentLiveSummary(selected); + const recent = selected.events.slice(-80).reverse(); + const runGroups = groupAgentEventsByRun(recent); + + contentEl.innerHTML = ` +No recent activity
'} +