From 27d40ce28f1ae03747ba343ec9bce188d660c534 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 20 May 2026 17:35:56 -0700 Subject: [PATCH] feat(hooks): add Hermes telemetry handler --- .gitignore | 2 + .../2026-04-22-web-ui-improvements-plan.md | 1027 +++++++++++++++++ hooks/claude-code/handler.js | 23 + hooks/claude-code/handler.ts | 23 + hooks/hermes/handler.js | 489 ++++++++ hooks/hermes/handler.ts | 386 +++++++ hooks/hermes/hooks.yaml | 21 + hooks/hermes/package-lock.json | 463 ++++++++ hooks/hermes/package.json | 18 + 9 files changed, 2452 insertions(+) create mode 100644 docs/plans/2026-04-22-web-ui-improvements-plan.md create mode 100755 hooks/hermes/handler.js create mode 100644 hooks/hermes/handler.ts create mode 100644 hooks/hermes/hooks.yaml create mode 100644 hooks/hermes/package-lock.json create mode 100644 hooks/hermes/package.json diff --git a/.gitignore b/.gitignore index 702ad1b..ec24e30 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ /web-ui /swarm-monitor +/event-processor +/openclaw-monitor /hooks/*/node_modules/ /build/ diff --git a/docs/plans/2026-04-22-web-ui-improvements-plan.md b/docs/plans/2026-04-22-web-ui-improvements-plan.md new file mode 100644 index 0000000..a3f6739 --- /dev/null +++ b/docs/plans/2026-04-22-web-ui-improvements-plan.md @@ -0,0 +1,1027 @@ +# Web UI Improvements — Plan + +**Date:** 2026-04-22 +**Status:** Pending + +> **For Claude:** Use `code-implementer` agent to execute tasks. Each task is self-contained and can be committed independently. All changes are frontend-only (`cmd/web-ui/static/`) unless noted. + +**Context:** The previous UI plan (`2026-03-28-ui-ux-improvements-design.md`) has been fully implemented. This plan covers the next layer of improvements: data richness, UX gaps, bug fixes, code quality, and new pages. + +**Architecture:** Vanilla JS SPA, no build tools. `app.js` is a single IIFE (~3700 lines). `style.css` uses CSS custom properties. Go server is a static file host + API reverse proxy + WebSocket proxy. No backend changes are needed for most tasks; backend tasks are noted explicitly. + +--- + +## Summary + +| # | Task | Files | Impact | Effort | +|---|------|-------|--------|--------| +| 1 | Fix Ctrl+K label on Linux | `app.js`, `index.html` | Low | Trivial | +| 2 | Fix WS unsubscribe variable reuse bug | `app.js` | Medium | Small | +| 3 | Stack multiple toasts | `app.js`, `style.css` | Low | Small | +| 4 | Show token usage + cost in run meta tiles | `app.js` | High | Small | +| 5 | Show aggregate tokens + cost in session detail | `app.js` | High | Small | +| 6 | Error count column in sessions table | `app.js`, `style.css` | Medium | Small | +| 7 | "Showing X of Y" pagination indicator | `app.js`, `query-api/main.go`, `store/` | Medium | Medium | +| 8 | Sortable sessions table | `app.js`, `style.css` | Medium | Medium | +| 9 | Relax + expand global search | `app.js` | Medium | Small | +| 10 | Agent deep-link routes (`/agents/:key`) | `app.js`, `style.css` | Medium | Medium | +| 11 | Infrastructure manual refresh | `app.js`, `style.css` | Low | Small | +| 12 | Settings/admin page (data retention UI) | `app.js`, `style.css` | Medium | Medium | +| 13 | Cost/usage analytics panel | `app.js`, `style.css` | High | Medium | +| 14 | Span waterfall / trace view | `app.js`, `style.css` | High | Large | +| 15 | Error boundary for render functions | `app.js` | Medium | Medium | +| 16 | Split app.js into logical modules | `app.js` → multiple files, `index.html` | Medium | Large | + +--- + +## Task 1: Fix Ctrl+K Label on Linux + +**Problem:** The command palette hint button in the header shows `⌘K` (macOS), but on Linux the keyboard shortcut is `Ctrl+K`. The keyboard handler already correctly handles both (`e.metaKey || e.ctrlKey`), but the visible hint is wrong. + +**Files:** `cmd/web-ui/static/index.html`, `cmd/web-ui/static/app.js` + +**Changes:** + +In `index.html`, the static `⌘K` text in the button: +```html + +``` + +Replace with a JS-rendered label. In `app.js`, in the `DOMContentLoaded` handler where `cmd-k-hint` is wired up, also set the button text: + +```javascript +const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgentData?.platform || ''); +const btn = document.getElementById('cmd-k-hint'); +if (btn) { + btn.innerHTML = `${isMac ? '⌘K' : 'Ctrl+K'}`; + btn.addEventListener('click', openCommandPalette); +} +``` + +Remove the static `⌘K` from `index.html` (leave the button element, just clear its body — JS will fill it). + +**Commit:** `fix(web-ui): show Ctrl+K shortcut hint on non-Mac platforms` + +--- + +## Task 2: Fix WebSocket Unsubscribe Variable Reuse Bug + +**Problem:** `sessionsUnsubscribe` (declared at line 326 of `app.js`) is reused as the cleanup handle for three different pages: the sessions list (`renderSessions`), the session detail (`renderSession`), and the run detail (`renderRun`). If navigation happens quickly, the previous page's cleanup may not run correctly or may clean up the wrong subscription. + +**Files:** `cmd/web-ui/static/app.js` + +**Changes:** + +Rename the per-page subscription variables to be more specific. The `cleanupLiveViews` function already calls `sessionsUnsubscribe()` — keep that. But use the right variable in each context: + +1. Rename the declaration at line 326: + ```javascript + let sessionsPageUnsubscribe = null; // was sessionsUnsubscribe + let sessionDetailUnsubscribe = null; + let runDetailUnsubscribe = null; + ``` + +2. In `cleanupLiveViews`, clean up all three: + ```javascript + if (sessionsPageUnsubscribe) { sessionsPageUnsubscribe(); sessionsPageUnsubscribe = null; } + if (sessionDetailUnsubscribe) { sessionDetailUnsubscribe(); sessionDetailUnsubscribe = null; } + if (runDetailUnsubscribe) { runDetailUnsubscribe(); runDetailUnsubscribe = null; } + ``` + +3. In `renderSessions`, assign to `sessionsPageUnsubscribe`. +4. In `renderSession`, assign to `sessionDetailUnsubscribe`. +5. In `renderRun`, assign to `runDetailUnsubscribe`. +6. In `loadRunDetailData`, when unsetting the subscription after run ends, reference `runDetailUnsubscribe`. + +**Commit:** `fix(web-ui): rename per-page WS unsubscribe variables to prevent reuse bug` + +--- + +## Task 3: Stack Multiple Toast Notifications + +**Problem:** The current `showToast` function removes any existing toast before adding a new one. If two events arrive in quick succession (e.g., a copy success followed by an API error), the first toast disappears immediately. + +**Files:** `cmd/web-ui/static/app.js`, `cmd/web-ui/static/style.css` + +**Changes:** + +In `showToast`, instead of removing the existing toast, append new toasts and position them as a stack. Limit to 3 visible at a time. + +```javascript +function showToast(message, type) { + // Limit to 3 toasts + const existing = document.querySelectorAll('.toast'); + if (existing.length >= 3) existing[0].remove(); + + const toast = document.createElement('div'); + toast.className = 'toast toast-' + (type || 'info'); + toast.textContent = message; + document.body.appendChild(toast); + + // Stack: offset each toast by its position in the stack + const toasts = document.querySelectorAll('.toast'); + toasts.forEach((t, i) => { + t.style.bottom = (2 + i * 3.5) + 'rem'; + }); + + requestAnimationFrame(() => toast.classList.add('visible')); + setTimeout(() => { + toast.classList.remove('visible'); + setTimeout(() => { + toast.remove(); + // Re-stack remaining + document.querySelectorAll('.toast').forEach((t, i) => { + t.style.bottom = (2 + i * 3.5) + 'rem'; + }); + }, 300); + }, 4000); +} +``` + +Add a `transition: bottom 200ms ease` to the `.toast` CSS rule so stacking animates smoothly. + +**Commit:** `feat(web-ui): stack multiple toast notifications instead of replacing` + +--- + +## Task 4: Token Usage + Cost in Run Meta Tiles + +**Problem:** The run detail page shows Started, Duration, Model, and Tool Calls in its meta tiles. Token usage and cost are available in `run.end` span payload data but are not surfaced anywhere in the run detail view. + +The `run` object returned by `/v1/runs/:id` already includes whatever the processor stores — check what fields are available. The run detail page renders from `data.run` (`r`). + +**Files:** `cmd/web-ui/static/app.js` + +**Changes:** + +In `renderRun` (around line 1374), extend the meta tiles section to include token/cost data if present. The run object fields to look for: `total_tokens`, `input_tokens`, `output_tokens`, `total_cost` (or nested under `usage`). + +```javascript +// After existing meta tiles, add conditionally: +${r.total_tokens ? ` +
+
Tokens
+
${formatTokenCount(r.total_tokens)}
+ ${r.input_tokens || r.output_tokens ? `
${formatTokenCount(r.input_tokens || 0)} in · ${formatTokenCount(r.output_tokens || 0)} out
` : ''} +
` : ''} +${r.total_cost != null ? ` +
+
Cost
+
${formatCost(r.total_cost)}
+
` : ''} +``` + +Also add a `.meta-tile-sub` CSS rule for the secondary line: +```css +.meta-tile-sub { + font-family: var(--font-mono); + font-size: 0.68rem; + color: var(--text-dim); + margin-top: 0.2rem; +} +``` + +**Backend check:** Verify that the `runs` table in the postgres store returns these fields. If not, they may need to be populated by the event processor from `run.end` payload. Check `internal/store/postgres/` and `cmd/event-processor/` — if the fields are missing from the schema, add a small backend task here. + +**Commit:** `feat(web-ui): show token usage and cost in run detail meta tiles` + +--- + +## Task 5: Aggregate Tokens + Cost in Session Detail + +**Problem:** The session detail page shows Started, Framework, Host, and Duration. It has no aggregate view of tokens consumed or cost incurred across all runs in the session. + +**Files:** `cmd/web-ui/static/app.js` + +**Changes:** + +In `renderSession`, after the existing meta tiles, compute totals from the `runs` array and add two more tiles: + +```javascript +// After building the meta tiles section: +const totalTokens = runs.reduce((sum, r) => sum + (r.total_tokens || 0), 0); +const totalCost = runs.reduce((sum, r) => sum + (r.total_cost || 0), 0); +const totalTools = runs.reduce((sum, r) => sum + (r.tool_count || 0), 0); + +// Add to meta tiles HTML: +${totalTokens > 0 ? ` +
+
Total Tokens
+
${formatTokenCount(totalTokens)}
+
` : ''} +${totalCost > 0 ? ` +
+
Total Cost
+
${formatCost(totalCost)}
+
` : ''} +${totalTools > 0 ? ` +
+
Total Tools
+
${totalTools}
+
` : ''} +``` + +These tiles should also re-render when `loadSessionData` refreshes the runs list. + +**Commit:** `feat(web-ui): aggregate token count and cost in session detail header` + +--- + +## Task 6: Error Count Column in Sessions Table + +**Problem:** Sessions track `_errorCount` in client-side state and WS updates increment it, but the sessions table never shows it. Users have no way to know which sessions had errors without clicking into each one. + +**Files:** `cmd/web-ui/static/app.js`, `cmd/web-ui/static/style.css` + +**Changes:** + +1. Add an **Errors** column to the sessions table header in `renderSessions`: + ```html + Errors + ``` + +2. Update `refreshSessionsTable` to render the error count in each row. If `s._errorCount > 0`, show a red badge: + ```javascript + const errorCell = s._errorCount > 0 + ? `${s._errorCount}` + : ''; + ``` + +3. Add the CSS badge: + ```css + .error-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 5px; + background: rgba(248, 113, 113, 0.15); + color: var(--error); + border: 1px solid rgba(248, 113, 113, 0.25); + border-radius: 10px; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + } + ``` + +4. Update `renderSessionRow` to include the new column as well (used by the WS update path). + +5. Update the `colspan` on empty-state rows from `5` to `6`. + +**Note:** The API response for sessions doesn't currently include an `error_count` field — the count is only maintained client-side via WS events. Sessions loaded before connecting to WS will show `—`. A follow-up could add this to the DB query. + +**Commit:** `feat(web-ui): add error count column to sessions table` + +--- + +## Task 7: "Showing X of Y" Pagination Indicator + +**Problem:** The sessions page has no count indicator. Users don't know how many sessions exist, how many are loaded, or how many are hidden by their current filter. + +**Files:** `cmd/web-ui/static/app.js`, `cmd/web-ui/static/style.css`, `cmd/query-api/main.go`, `internal/store/postgres/sessions.go` + +**Backend changes required:** + +Add a `total` field to the `/v1/sessions` response. This requires: + +1. In `internal/store/postgres/sessions.go`, add a `CountSessions(ctx, filter)` method that runs a `SELECT COUNT(*)` with the same WHERE clause as `ListSessions`. + +2. In `cmd/query-api/main.go`, call it in parallel with `ListSessions` and include `total` in the response: + ```go + total, err := db.CountSessions(r.Context(), f) + // ... + resp["total"] = total + ``` + +**Frontend changes:** + +In `loadSessions`, read `data.total` and store it: +```javascript +sessionsState.total = data.total || 0; +``` + +In `renderSessions`, add a count indicator above the table: +```html +
+``` + +After each `loadSessions` call, update it: +```javascript +function updatePaginationInfo() { + const el = document.getElementById('pagination-info'); + if (!el) return; + const loaded = sessionsState.sessions.length; + const total = sessionsState.total || loaded; + const filtered = /* count after applying sessionFilterMode */; + el.textContent = filtered < loaded + ? `Showing ${filtered} of ${loaded} loaded (${total} total)` + : `Showing ${loaded} of ${total}`; +} +``` + +Add CSS: +```css +.pagination-info { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-dim); + margin-bottom: 0.75rem; + letter-spacing: 0.02em; +} +``` + +**Commit:** `feat: show session count with pagination indicator (backend + frontend)` + +--- + +## Task 8: Sortable Sessions Table + +**Problem:** The sessions table is always sorted newest-first. There's no way to sort by duration, run count, or framework. + +**Files:** `cmd/web-ui/static/app.js`, `cmd/web-ui/static/style.css` + +**Approach:** Client-side sort of the already-loaded `sessionsState.sessions` array. No new API endpoints needed. + +**Changes:** + +1. Add sort state: + ```javascript + let sessionSortKey = 'started_at'; // default + let sessionSortDir = 'desc'; // 'asc' | 'desc' + ``` + +2. In `renderSessions`, make table headers clickable with sort indicators: + ```html + Session + Framework + Host + Runs + Time + Errors + ``` + +3. Add sort handler: + ```javascript + document.querySelectorAll('th.sortable').forEach(th => { + th.addEventListener('click', () => { + const key = th.dataset.sort; + if (sessionSortKey === key) { + sessionSortDir = sessionSortDir === 'asc' ? 'desc' : 'asc'; + } else { + sessionSortKey = key; + sessionSortDir = 'desc'; + } + refreshSessionsTable(); + }); + }); + ``` + +4. In `refreshSessionsTable`, sort before filtering: + ```javascript + function sortSessions(sessions) { + return [...sessions].sort((a, b) => { + let av = a[sessionSortKey], bv = b[sessionSortKey]; + if (sessionSortKey === 'started_at') { av = new Date(av).getTime(); bv = new Date(bv).getTime(); } + if (av < bv) return sessionSortDir === 'asc' ? -1 : 1; + if (av > bv) return sessionSortDir === 'asc' ? 1 : -1; + return 0; + }); + } + ``` + +5. CSS for sortable headers and icons: + ```css + th.sortable { cursor: pointer; user-select: none; } + th.sortable:hover { color: var(--text-bright); } + th.sortable.sort-asc .sort-icon::after { content: ' ↑'; } + th.sortable.sort-desc .sort-icon::after { content: ' ↓'; } + .sort-icon { color: var(--accent); font-size: 0.7rem; } + ``` + +**Commit:** `feat(web-ui): sortable columns in sessions table` + +--- + +## Task 9: Relax and Expand Global Search + +**Problem:** The global search (header input + command palette) requires 8+ characters and only searches by exact session or run ID prefix. This is unnecessarily restrictive. + +**Files:** `cmd/web-ui/static/app.js` + +**Changes:** + +1. **Lower minimum from 8 to 4 characters** in `handleGlobalSearch`: + ```javascript + if (id.length < 4) { + showToast('Search ID must be at least 4 characters', 'info'); + return; + } + ``` + Also update the command palette ID detection threshold. + +2. **Search by framework/host shortcut:** If the query doesn't look like a hex ID (contains letters A-Z or common words), navigate to `/sessions?framework=` or `/sessions?host=`: + ```javascript + async function handleGlobalSearch(query) { + query = query.trim(); + if (query.length < 4) { showToast('Enter at least 4 characters', 'info'); return; } + + // Hex ID pattern + if (/^[a-f0-9-]{4,}$/i.test(query)) { + // Try session, then run (existing logic) + // ... + } else { + // Non-hex: treat as framework or host search + navigate('/sessions?framework=' + encodeURIComponent(query)); + } + } + ``` + +3. Update the `⌘K` palette search hint text from `"Search for ID: "` to `"Search: "`. + +**Commit:** `feat(web-ui): relax search minimum to 4 chars, support framework/host queries` + +--- + +## Task 10: Agent Deep-Link Routes (`/agents/:key`) + +**Problem:** The agents page has no per-agent URL. You can't share a link to a specific agent's live view, and the browser back button doesn't restore the selected agent. + +**Files:** `cmd/web-ui/static/app.js` + +**Changes:** + +1. **Add route handler** in the `route()` function: + ```javascript + } else if (path.startsWith('/agents/')) { + const agentKey = path.split('/agents/')[1]; + renderAgents(agentKey); // pass initial selected key + } else if (path.startsWith('/agents')) { + renderAgents(); + } + ``` + +2. **Update `renderAgents`** to accept an optional `initialKey` parameter and pre-select that agent after data loads. + +3. **Update `selectAgent`** to push a URL state change: + ```javascript + function selectAgent(key, nextMode) { + if (!key || !agentsState.agents[key]) return; + agentsState.selectedAgentKey = key; + if (nextMode) agentsState.viewMode = nextMode; + // Push URL so it's shareable/bookmarkable + const newPath = '/agents/' + encodeURIComponent(key); + if (window.location.pathname !== newPath) { + history.pushState(null, '', newPath); + } + renderAgentsContent(); + if (agentsState.viewMode === 'live') void loadSelectedAgentLiveData(); + } + ``` + +4. **Update breadcrumbs** to show the agent name for `/agents/:key` paths. + +5. **Update `renderBreadcrumbs`** to show a readable label (agent name) rather than the raw key for agent paths. + +**Commit:** `feat(web-ui): deep-link routes for individual agents (/agents/:key)` + +--- + +## Task 11: Infrastructure Manual Refresh + +**Problem:** The infrastructure page only updates when WebSocket events arrive. If the page is stale (e.g., no new snapshots have arrived), there's no way to force a refresh. + +**Files:** `cmd/web-ui/static/app.js`, `cmd/web-ui/static/style.css` + +**Changes:** + +1. Add a refresh button to the infra page header: + ```javascript + // In renderInfraGrid(), update the page-header to include: + + ``` + +2. Wire the button to re-fetch all infra data: + ```javascript + document.getElementById('infra-refresh-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('infra-refresh-btn'); + if (btn) btn.disabled = true; + try { + const [ocData, swarmData] = await Promise.all([ + api('/v1/events?event_type=openclaw.snapshot&limit=100'), + api('/v1/events?event_type=swarm.snapshot&limit=10').catch(() => ({ events: [] })), + ]); + mergeOpenClawEvents(ocData.events || []); + for (const evt of swarmData.events || []) mergeSwarmSnapshot(evt); + renderInfraGrid(); + } finally { + if (btn) btn.disabled = false; + } + }); + ``` + +3. Add CSS for `.refresh-btn` (small, subtle button with a spinner animation on `:disabled`). + +**Commit:** `feat(web-ui): manual refresh button on infrastructure page` + +--- + +## Task 12: Settings / Admin Page + +**Problem:** The API exposes `POST /v1/admin/retention` for managing data retention, but there is no UI for it. There's no settings page at all. + +**Files:** `cmd/web-ui/static/app.js`, `cmd/web-ui/static/style.css`, `cmd/web-ui/static/index.html` + +**Changes:** + +1. **Add a Settings nav link** in `index.html`: + ```html + Settings + ``` + This is the only nav change. + +2. **Add route handler** in `route()`: + ```javascript + } else if (path === '/settings') { + renderSettings(); + } + ``` + +3. **Add keyboard shortcut** in the `g`-prefix block: `g+p` → `/settings`. + +4. **Implement `renderSettings()`:** + + ```javascript + async function renderSettings() { + app.innerHTML = ` + + +
+

Data Retention

+

Delete events older than the specified number of days. This runs automatically every 24 hours. Currently configured via RETENTION_DAYS environment variable (default: 30 days).

+
+ +
+ + days + +
+
+
+
+ `; + + document.getElementById('run-retention-btn')?.addEventListener('click', async () => { + const days = parseInt(document.getElementById('retention-days').value, 10); + if (!days || days < 1) { showToast('Enter a valid number of days', 'error'); return; } + const btn = document.getElementById('run-retention-btn'); + btn.disabled = true; + btn.textContent = 'Running…'; + try { + const resp = await fetch('/api/v1/admin/retention', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ days }), + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Request failed'); + const result = document.getElementById('retention-result'); + if (result) result.innerHTML = `Deleted ${data.deleted} events older than ${new Date(data.cutoff).toLocaleDateString()}.`; + showToast(`Deleted ${data.deleted} events`, 'success'); + } catch (e) { + showToast('Retention failed: ' + e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Run Now'; + } + }); + } + ``` + +5. **Add settings page CSS:** + + ```css + .settings-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; + margin-bottom: 1.5rem; + max-width: 640px; + } + .settings-section-title { + font-family: var(--font-display); + font-size: 1rem; + font-weight: 700; + color: var(--text-bright); + margin-bottom: 0.5rem; + } + .settings-section-desc { + font-size: 0.82rem; + color: var(--text-dim); + margin-bottom: 1.25rem; + line-height: 1.6; + } + .settings-row { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .settings-label { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.06em; + } + .settings-input-group { + display: flex; + align-items: center; + gap: 0.75rem; + } + .settings-input { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + padding: 0.45rem 0.75rem; + font-family: var(--font-mono); + font-size: 0.88rem; + width: 80px; + outline: none; + } + .settings-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } + .settings-input-suffix { font-size: 0.82rem; color: var(--text-dim); } + .settings-btn { + background: var(--accent-dim); + border: 1px solid var(--accent-glow); + border-radius: var(--radius); + color: var(--accent); + font-family: var(--font-body); + font-size: 0.82rem; + font-weight: 600; + padding: 0.45rem 1rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + } + .settings-btn:hover { background: rgba(34, 211, 238, 0.15); border-color: var(--accent); } + .settings-btn:disabled { opacity: 0.5; cursor: default; } + .settings-result { margin-top: 0.75rem; font-size: 0.82rem; } + .settings-result-ok { color: var(--success); } + ``` + +**Commit:** `feat(web-ui): add Settings page with data retention UI` + +--- + +## Task 13: Cost / Usage Analytics Panel + +**Problem:** The dashboard shows top tools and top models in the right panel, but there's no dedicated view for cost trends over time, per-framework costs, or a historical breakdown. The API already has all needed endpoints: `/v1/stats/timeseries`, `/v1/stats/top-tools`, `/v1/stats/top-models`, `/v1/stats/summary`. + +**Files:** `cmd/web-ui/static/app.js`, `cmd/web-ui/static/style.css` + +**Approach:** Add a "Usage" tab to the dashboard (not a new nav page) that shows cost/token analytics. Alternatively, add a dedicated `/usage` page. A `/usage` page is cleaner given the existing nav structure. + +**Changes:** + +1. Add a `/usage` route and nav link in `index.html`: + ```html + Usage + ``` + +2. Add `renderUsage()` function: + + - **Fetch:** `summary`, `top-tools` (limit 20), `top-models` (limit 10), `timeseries` (window=7d) + - **Render sections:** + - Summary bar: Sessions, Runs, Tool Calls, Errors — with "today" vs "7d" comparisons + - Token & cost overview: total tokens (7d), estimated total cost (7d) + - Top models: bar chart of usage counts + cost breakdown per model + - Top tools: ranked list with usage bars + - Activity chart: reuse the uPlot timeseries chart (stacked runs/tools/errors over 7d) + +3. The `top-models` endpoint returns `{ models: [{ model, count, total_cost, total_tokens }] }`. Display as a ranked table with inline bar tracks. + +4. The `top-tools` endpoint returns `{ tools: [{ name, count }] }`. Display as a ranked list. + +5. Add CSS for the usage page layout (reuse existing `.stat-card`, `.fw-bars`, `.fw-bar-*` classes where possible). + +**Commit:** `feat(web-ui): add Usage analytics page with token/cost breakdown` + +--- + +## Task 14: Span Waterfall / Trace View + +**Problem:** The run detail page shows spans in a flat table (name, kind, status, duration). There's no visualization of how spans overlap in time, which spans are children of others, or the overall execution timeline. For debugging long runs or understanding parallelism, a Gantt-style waterfall is much more useful. + +**Files:** `cmd/web-ui/static/app.js`, `cmd/web-ui/static/style.css` + +**Approach:** Add a "Waterfall" toggle button on the run detail page that switches the spans view from the existing table to a SVG/HTML timeline. Keep the table as the default (for backward compatibility); waterfall is opt-in. + +**Data requirements:** Each span needs `started_at`, `ended_at` (or `duration_ms`), `name`, `kind`, `status`. The `/v1/runs/:id` response already returns spans with this data via `data.spans`. + +**Changes:** + +1. **Add a view toggle** in the run detail section title row: + ```javascript + `
+ Spans ${spans.length} +
+ + +
+
` + ``` + +2. **Implement `renderSpanWaterfall(spans, runStarted)`:** + + Compute the run's start time from `r.started_at`. For each span, compute `left%` and `width%` relative to the total run duration: + + ```javascript + function renderSpanWaterfall(spans, runStartedAt, runDurationMS) { + if (!spans || spans.length === 0) return '

No spans

'; + const runStart = new Date(runStartedAt).getTime(); + const totalMS = runDurationMS || Math.max(...spans.map(sp => { + const s = new Date(sp.started_at || runStartedAt).getTime(); + return (s - runStart) + (sp.duration_ms || 0); + }), 1); + + return ` +
+
+
Span
+
+
${renderTimescale(totalMS)}
+
+
+ ${spans.map(sp => { + const spStart = sp.started_at ? new Date(sp.started_at).getTime() - runStart : 0; + const spDur = sp.duration_ms || 0; + const leftPct = (spStart / totalMS * 100).toFixed(2); + const widthPct = Math.max(0.5, (spDur / totalMS * 100)).toFixed(2); + const kindClass = sp.kind || 'unknown'; + const statusClass = sp.status === 'error' ? ' wf-error' : sp.status === 'success' ? ' wf-success' : ''; + return ` +
+
+ ${sp.kind || '?'} + ${escapeHTML((sp.name || '(unnamed)').slice(0, 40))} +
+
+
+
+ ${spDur > totalMS * 0.05 ? formatDuration(spDur) : ''} +
+
+
+
`; + }).join('')} +
`; + } + + function renderTimescale(totalMS) { + const ticks = 5; + return Array.from({ length: ticks + 1 }, (_, i) => { + const pct = (i / ticks * 100).toFixed(0); + return `${formatDuration(totalMS * i / ticks)}`; + }).join(''); + } + ``` + +3. **Wire the toggle buttons** to swap the spans container between table and waterfall: + + ```javascript + document.getElementById('spans-view-waterfall')?.addEventListener('click', () => { + document.getElementById('spans-container').innerHTML = renderSpanWaterfall(spans, r.started_at, r.ended_at ? new Date(r.ended_at) - new Date(r.started_at) : null); + document.getElementById('spans-view-waterfall').classList.add('active'); + document.getElementById('spans-view-table').classList.remove('active'); + }); + ``` + +4. **CSS for the waterfall:** + + ```css + .waterfall { + overflow-x: auto; + } + .waterfall-header, + .waterfall-row { + display: grid; + grid-template-columns: 240px 1fr; + gap: 0.75rem; + align-items: center; + padding: 0.4rem 1.25rem; + border-bottom: 1px solid var(--border-soft); + } + .waterfall-header { background: var(--surface-2); font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); } + .waterfall-row:hover { background: var(--surface-2); } + .waterfall-name-col { display: flex; align-items: center; gap: 0.4rem; min-width: 0; } + .waterfall-name { font-size: 0.8rem; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .waterfall-bar-col { position: relative; } + .waterfall-bar-track { position: relative; height: 20px; background: var(--surface-2); border-radius: 3px; } + .waterfall-bar { + position: absolute; + top: 2px; + height: 16px; + border-radius: 3px; + background: var(--accent); + opacity: 0.7; + display: flex; + align-items: center; + overflow: hidden; + transition: opacity 0.15s; + } + .waterfall-bar:hover { opacity: 1; } + .waterfall-bar.wf-error { background: var(--error); } + .waterfall-bar.wf-success { background: var(--success); } + .waterfall-bar-label { font-family: var(--font-mono); font-size: 0.6rem; padding: 0 4px; color: #fff; white-space: nowrap; } + .waterfall-timescale { position: relative; height: 16px; } + .waterfall-timescale span { position: absolute; transform: translateX(-50%); font-family: var(--font-mono); font-size: 0.62rem; color: var(--text-dim); } + ``` + +**Commit:** `feat(web-ui): span waterfall / trace view on run detail page` + +--- + +## Task 15: Error Boundary for Render Functions + +**Problem:** An unhandled exception in any render function (e.g., unexpected data shape from the API) leaves `#app` in a broken or empty state with no recovery path. The user sees a blank page with no indication of what happened. + +**Files:** `cmd/web-ui/static/app.js` + +**Changes:** + +1. Wrap the render dispatch in `route()` with a try/catch: + + ```javascript + try { + if (path === '/') renderDashboard(); + else if (path === '/sessions') renderSessions(); + // ... etc + } catch (err) { + console.error('Render error:', err); + app.innerHTML = ` +
+

Something went wrong

+

An error occurred while rendering this page.

+
${escapeHTML(err.message)}
+ +
+ `; + } + ``` + +2. Also wrap each individual page render function's top-level body in a try/catch so errors are attributed to the correct page. + +3. Add CSS for the error boundary: + + ```css + .error-boundary { + padding: 3rem 2rem; + max-width: 560px; + margin: 0 auto; + } + .error-boundary h2 { + font-family: var(--font-display); + font-size: 1.4rem; + color: var(--error); + margin-bottom: 0.5rem; + } + .error-boundary p { color: var(--text-dim); margin-bottom: 1rem; } + .error-boundary-detail { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem 1rem; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--code-text); + margin-bottom: 1.25rem; + white-space: pre-wrap; + word-break: break-word; + } + ``` + +**Commit:** `feat(web-ui): error boundary with fallback UI for render failures` + +--- + +## Task 16: Split app.js into Logical Modules + +**Problem:** `app.js` is a single ~3700-line IIFE with no internal separation. Everything is tangled: routing, WebSocket management, per-page state, render functions, and utilities. This makes it hard to navigate, test, or extend. + +**Approach:** Split into focused files loaded as ES modules. No bundler required — use native ` + + +``` + +**Commit strategy:** Do this in multiple commits — one per module extracted. Start with the ones at the bottom of the dependency tree. + +**Commits:** +``` +refactor(web-ui): extract utility functions to modules/utils.js +refactor(web-ui): extract theme, api, ws to separate modules +refactor(web-ui): extract state declarations to modules/state.js +refactor(web-ui): extract router to modules/router.js +refactor(web-ui): extract dashboard page to modules/pages/dashboard.js +refactor(web-ui): extract sessions pages to modules/pages/sessions*.js +refactor(web-ui): extract agents page to modules/pages/agents.js +refactor(web-ui): extract infrastructure to modules/pages/infrastructure.js + infra/ +refactor(web-ui): extract command palette to modules/palette.js +refactor(web-ui): finalize module split, remove monolithic app.js IIFE +``` + +--- + +## Execution Order + +Recommended sequence, grouped by dependency and risk: + +### Phase 1 — Bug Fixes (low risk, do first) +1. **Task 2** — Fix WS unsubscribe variable reuse bug +2. **Task 1** — Fix Ctrl+K label on Linux + +### Phase 2 — Data Richness (high value, easy wins) +3. **Task 4** — Token usage + cost in run meta tiles +4. **Task 5** — Aggregate tokens + cost in session detail +5. **Task 6** — Error count column in sessions table + +### Phase 3 — UX Improvements +6. **Task 3** — Stack multiple toasts +7. **Task 9** — Relax + expand global search +8. **Task 11** — Infrastructure manual refresh +9. **Task 8** — Sortable sessions table + +### Phase 4 — New Features +10. **Task 10** — Agent deep-link routes +11. **Task 12** — Settings/admin page +12. **Task 13** — Cost/usage analytics page +13. **Task 7** — Pagination indicator (requires backend changes) + +### Phase 5 — Advanced Features +14. **Task 14** — Span waterfall / trace view +15. **Task 15** — Error boundary + +### Phase 6 — Code Quality (do last, after features are stable) +16. **Task 16** — Split app.js into modules + +--- + +## Notes + +- **No build tooling:** All JS is plain ES2020+ (or IIFE). Tasks 1–15 keep the existing IIFE structure. Task 16 migrates to native ES modules — only do this after all feature tasks are complete and stable. +- **Backend tasks:** Only Task 7 (pagination count) requires a backend change. All other tasks are frontend-only. +- **Testing:** Run `make test` after any changes to verify Go code (if Task 7 is done). For frontend changes, manually verify in a browser against a running instance. +- **CSS naming:** Follow the existing convention — BEM-like flat class names, no nesting, CSS custom properties for all colors. diff --git a/hooks/claude-code/handler.js b/hooks/claude-code/handler.js index 9eef47f..6ed5399 100755 --- a/hooks/claude-code/handler.js +++ b/hooks/claude-code/handler.js @@ -172,6 +172,7 @@ async function readStdin() { var INGEST_URL = process.env.AGENTMON_INGEST_URL || "http://localhost:8080"; var FRAMEWORK = process.env.AGENTMON_FRAMEWORK || "claude-code"; var HOST = process.env.AGENTMON_HOST || hostname(); +var ALLOW_STARTUP_SESSIONS = process.env.AGENTMON_CLAUDE_ALLOW_STARTUP === "1"; var { enqueue, flush } = createTransport(INGEST_URL); var STATE_DIR = join(homedir(), ".agentmon-state"); function ensureStateDir() { @@ -231,6 +232,15 @@ function isNonPersistentClaudeLaunch() { ({ cmd }) => cmd.includes("/claude") && cmd.includes("--no-session-persistence") ); } +function hasClaudeProcessAncestor() { + return getProcessTree().some(({ cmd }) => { + if (!cmd) + return false; + if (cmd.includes("agentmon-handler") || cmd.includes("/hooks/claude-code/handler")) + return false; + return /(^|[/\s])claude(\s|$)/.test(cmd) || cmd.includes("@anthropic-ai/claude-code"); + }); +} var activeRuns = /* @__PURE__ */ new Map(); var activeSpans = /* @__PURE__ */ new Map(); var activeSubagents = /* @__PURE__ */ new Map(); @@ -319,6 +329,10 @@ async function handleSessionStart(input) { console.error("[agentmon] ignoring claude-code startup from --no-session-persistence launch"); return; } + if (pickString(input.source) === "startup" && (!ALLOW_STARTUP_SESSIONS || !hasClaudeProcessAncestor())) { + console.error("[agentmon] ignoring claude-code startup session"); + return; + } const runId = randomUUID2(); activeRuns.set(sessionKey, runId); saveState(sessionKey, { runId, spans: {} }); @@ -388,6 +402,15 @@ async function handlePromptSubmit(input) { } })); } + if (!runId && sessionKey) { + enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, { + attributes: { + cwd: pickString(input.cwd), + transcript_path: pickString(input.transcript_path), + source: pickString(input.source) + } + })); + } const newRunId = randomUUID2(); if (sessionKey) { activeRuns.set(sessionKey, newRunId); diff --git a/hooks/claude-code/handler.ts b/hooks/claude-code/handler.ts index 94d40df..377eb4a 100644 --- a/hooks/claude-code/handler.ts +++ b/hooks/claude-code/handler.ts @@ -17,6 +17,7 @@ import { const INGEST_URL = process.env.AGENTMON_INGEST_URL || 'http://localhost:8080'; const FRAMEWORK = process.env.AGENTMON_FRAMEWORK || 'claude-code'; const HOST = process.env.AGENTMON_HOST || hostname(); +const ALLOW_STARTUP_SESSIONS = process.env.AGENTMON_CLAUDE_ALLOW_STARTUP === '1'; const { enqueue, flush } = createTransport(INGEST_URL); @@ -89,6 +90,14 @@ function isNonPersistentClaudeLaunch() { cmd.includes('/claude') && cmd.includes('--no-session-persistence') ); } + +function hasClaudeProcessAncestor() { + return getProcessTree().some(({ cmd }) => { + if (!cmd) return false; + if (cmd.includes('agentmon-handler') || cmd.includes('/hooks/claude-code/handler')) return false; + return /(^|[/\s])claude(\s|$)/.test(cmd) || cmd.includes('@anthropic-ai/claude-code'); + }); +} // ───────────────────────────────────────────────────────────────────────────── const activeRuns = new Map(); @@ -182,6 +191,10 @@ async function handleSessionStart(input: Dict) { console.error('[agentmon] ignoring claude-code startup from --no-session-persistence launch'); return; } + if (pickString(input.source) === 'startup' && (!ALLOW_STARTUP_SESSIONS || !hasClaudeProcessAncestor())) { + console.error('[agentmon] ignoring claude-code startup session'); + return; + } const runId = randomUUID(); activeRuns.set(sessionKey, runId); @@ -262,6 +275,16 @@ async function handlePromptSubmit(input: Dict) { })); } + 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 = randomUUID(); if (sessionKey) { activeRuns.set(sessionKey, newRunId); diff --git a/hooks/hermes/handler.js b/hooks/hermes/handler.js new file mode 100755 index 0000000..592b4a5 --- /dev/null +++ b/hooks/hermes/handler.js @@ -0,0 +1,489 @@ +#!/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 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" } + })); + } + const runId = randomUUID2(); + state.runId = runId; + 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), + model: getModel(input), + response_preview: truncate(getExtra(input).assistant_response, 500) + } + })); + state.runId = void 0; + 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; + } + 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", + model: getModel(input) + } + })); + } + 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(); diff --git a/hooks/hermes/handler.ts b/hooks/hermes/handler.ts new file mode 100644 index 0000000..9e915f8 --- /dev/null +++ b/hooks/hermes/handler.ts @@ -0,0 +1,386 @@ +#!/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; + spans: { [key: string]: string }; +} + +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' }, + })); + } + + const runId = randomUUID(); + state.runId = runId; + 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), + model: getModel(input), + response_preview: truncate(getExtra(input).assistant_response, 500), + }, + })); + state.runId = undefined; + 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; + } + + 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', + model: getModel(input), + }, + })); + } + + 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(); diff --git a/hooks/hermes/hooks.yaml b/hooks/hermes/hooks.yaml new file mode 100644 index 0000000..9887bdf --- /dev/null +++ b/hooks/hermes/hooks.yaml @@ -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 diff --git a/hooks/hermes/package-lock.json b/hooks/hermes/package-lock.json new file mode 100644 index 0000000..6638f9d --- /dev/null +++ b/hooks/hermes/package-lock.json @@ -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" + } + } + } +} diff --git a/hooks/hermes/package.json b/hooks/hermes/package.json new file mode 100644 index 0000000..5f64eab --- /dev/null +++ b/hooks/hermes/package.json @@ -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" + } +}