diff --git a/docs/plans/2026-03-14-dashboard-design.md b/docs/plans/2026-03-14-dashboard-design.md
new file mode 100644
index 0000000..2f99b7e
--- /dev/null
+++ b/docs/plans/2026-03-14-dashboard-design.md
@@ -0,0 +1,111 @@
+# Dashboard with Real-time Stats and Graphs
+
+**Date:** 2026-03-14
+**Status:** Approved
+
+## Overview
+
+Add a comprehensive dashboard at `/` combining server-side aggregation endpoints, the existing WebSocket stream, and uPlot charts for real-time agent monitoring with stats and graphs.
+
+## Architecture
+
+Three data sources feed the dashboard:
+
+1. **Server-side aggregation endpoints** (new) - historical stats on page load
+2. **WebSocket stream** (existing) - live events update charts in real-time
+3. **Existing REST endpoints** - sessions/runs for linking out
+
+### New Backend Endpoints
+
+#### `GET /v1/stats/summary`
+
+Current-day aggregates:
+
+```json
+{
+ "active_sessions": 3,
+ "runs_today": 47,
+ "tool_calls_today": 312,
+ "errors_today": 2,
+ "by_framework": {
+ "openclaw": { "runs": 30, "tools": 210, "errors": 1 },
+ "claude-code": { "runs": 17, "tools": 102, "errors": 1 }
+ }
+}
+```
+
+Simple `COUNT` + `GROUP BY` over the `events` table using `type` and `source_framework` columns, filtered to `ts >= today midnight`.
+
+#### `GET /v1/stats/timeseries?window=1h&bucket=1m`
+
+Bucketed event counts:
+
+```json
+{
+ "window": "1h",
+ "bucket": "1m",
+ "series": [
+ { "ts": "2026-03-14T10:00:00Z", "runs": 2, "tools": 14, "errors": 0 },
+ { "ts": "2026-03-14T10:01:00Z", "runs": 1, "tools": 8, "errors": 0 }
+ ]
+}
+```
+
+Bucket sizes auto-calculated if not provided: 1h→1m, 6h→5m, 24h→15m, 7d→1h. Uses Postgres `date_bin` for bucketing.
+
+## Dashboard Layout
+
+Single-scroll page with four sections:
+
+### 1. Summary Strip
+
+Four stat cards in a horizontal row:
+- **Active Sessions** - sessions with no `session.end` event, with framework breakdown
+- **Runs Today** - total runs since midnight
+- **Tool Calls** - total tool spans today
+- **Errors** - error events today, red-highlighted if > 0
+
+### 2. OpenClaw VM Strip
+
+Reuse existing VM pill component (zap/orb/sun online/offline status).
+
+### 3. Activity Charts
+
+Two charts side by side:
+- **Left: Event rate** - uPlot stacked area time-series. One series per category (runs, tools, errors). Time window selector (1h/6h/24h/7d) in top-right.
+- **Right: Framework breakdown** - horizontal bar chart showing events by framework. Rendered as styled divs (categorical, no uPlot needed).
+
+### 4. Bottom Panels
+
+Two columns:
+- **Left: Recent activity feed** - last 20 events as compact timeline (reuse existing timeline helpers)
+- **Right: Top tools** - ranked list with counts and bar visualization
+
+## Frontend
+
+- **uPlot** loaded from CDN for time-series charts
+- Thin wrapper `createChart(el, series, opts)` applies dark theme from existing CSS vars
+- Real-time update flow:
+ 1. Page load → fetch summary + timeseries (default 1h window)
+ 2. Connect WebSocket → append live events to charts
+ 3. On each WS event: increment counters, update chart bucket, update top tools, prepend to feed
+ 4. Time window change → re-fetch from server, rebuild chart
+- Framework bars: styled `
` elements with proportional widths
+- Activity feed: reuse `getEventIcon`, `getEventLabel`, `getEventBody` from existing code (extracted as shared helpers)
+- Time window selector: segmented control with 1h / 6h / 24h / 7d buttons
+
+## File Changes
+
+### New files
+- `internal/store/postgres/stats.go` - `Summary()` and `Timeseries()` query functions
+
+### Modified files
+- `cmd/query-api/main.go` - add `/v1/stats/summary` and `/v1/stats/timeseries` handlers
+- `cmd/web-ui/static/index.html` - add uPlot CDN script/link tags
+- `cmd/web-ui/static/app.js` - dashboard route at `/`, shared helpers, chart rendering, real-time updates
+- `cmd/web-ui/static/style.css` - dashboard layout, summary cards, chart containers, time window selector, framework bars
+
+### No changes to
+- Database schema (queries use existing `events` table)
+- Event format or ingestion pipeline
+- Existing pages (sessions, agents, openclaw)
diff --git a/docs/plans/2026-03-14-dashboard-plan.md b/docs/plans/2026-03-14-dashboard-plan.md
new file mode 100644
index 0000000..3ef6dbc
--- /dev/null
+++ b/docs/plans/2026-03-14-dashboard-plan.md
@@ -0,0 +1,1082 @@
+# Dashboard Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add a real-time dashboard at `/` with summary stats, time-series charts (uPlot), framework breakdown, activity feed, and top tools — powered by new server-side aggregation endpoints plus the existing WebSocket stream.
+
+**Architecture:** Two new Postgres query functions (`Summary`, `Timeseries`) exposed as REST endpoints. Frontend fetches historical data on load, then layers live WebSocket events on top. uPlot renders time-series charts; framework bars and top tools are styled HTML.
+
+**Tech Stack:** Go (chi router, pgx), Postgres `date_bin`, uPlot (CDN), vanilla JS
+
+---
+
+### Task 1: Stats Query Functions
+
+**Files:**
+- Create: `internal/store/postgres/stats.go`
+
+**Step 1: Create the summary query function**
+
+```go
+package postgres
+
+import (
+ "context"
+ "time"
+)
+
+type FrameworkStats struct {
+ Runs int `json:"runs"`
+ Tools int `json:"tools"`
+ Errors int `json:"errors"`
+}
+
+type Summary struct {
+ ActiveSessions int `json:"active_sessions"`
+ RunsToday int `json:"runs_today"`
+ ToolCallsToday int `json:"tool_calls_today"`
+ ErrorsToday int `json:"errors_today"`
+ ByFramework map[string]FrameworkStats `json:"by_framework"`
+}
+
+func (d *DB) GetSummary(ctx context.Context) (*Summary, error) {
+ now := time.Now()
+ midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+
+ s := &Summary{ByFramework: make(map[string]FrameworkStats)}
+
+ // Active sessions: sessions that have a session.start but no session.end today
+ err := d.sql.QueryRowContext(ctx, `
+ SELECT COUNT(DISTINCT session_id) FROM events
+ WHERE session_id IS NOT NULL
+ AND ts >= $1
+ AND type = 'session.start'
+ AND session_id NOT IN (
+ SELECT DISTINCT session_id FROM events
+ WHERE session_id IS NOT NULL AND type = 'session.end' AND ts >= $1
+ )
+ `, midnight).Scan(&s.ActiveSessions)
+ if err != nil {
+ return nil, err
+ }
+
+ // Aggregates by framework
+ rows, err := d.sql.QueryContext(ctx, `
+ SELECT
+ COALESCE(source_framework, 'unknown'),
+ COUNT(*) FILTER (WHERE type IN ('run.start', 'run.end')) / 2,
+ COUNT(*) FILTER (WHERE type = 'span.end' AND payload->'attributes'->>'span_kind' = 'tool'),
+ COUNT(*) FILTER (WHERE type = 'error')
+ FROM events
+ WHERE ts >= $1
+ GROUP BY source_framework
+ `, midnight)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var fw string
+ var fs FrameworkStats
+ if err := rows.Scan(&fw, &fs.Runs, &fs.Tools, &fs.Errors); err != nil {
+ return nil, err
+ }
+ s.ByFramework[fw] = fs
+ s.RunsToday += fs.Runs
+ s.ToolCallsToday += fs.Tools
+ s.ErrorsToday += fs.Errors
+ }
+
+ return s, rows.Err()
+}
+```
+
+**Step 2: Create the timeseries query function**
+
+Add to the same file:
+
+```go
+type TimeseriesBucket struct {
+ TS time.Time `json:"ts"`
+ Runs int `json:"runs"`
+ Tools int `json:"tools"`
+ Errors int `json:"errors"`
+}
+
+type TimeseriesResult struct {
+ Window string `json:"window"`
+ Bucket string `json:"bucket"`
+ Series []TimeseriesBucket `json:"series"`
+}
+
+func bucketForWindow(window string) string {
+ switch window {
+ case "6h":
+ return "5 minutes"
+ case "24h":
+ return "15 minutes"
+ case "7d":
+ return "1 hour"
+ default:
+ return "1 minute"
+ }
+}
+
+func durationForWindow(window string) time.Duration {
+ switch window {
+ case "6h":
+ return 6 * time.Hour
+ case "24h":
+ return 24 * time.Hour
+ case "7d":
+ return 7 * 24 * time.Hour
+ default:
+ return time.Hour
+ }
+}
+
+func bucketLabelForWindow(window string) string {
+ switch window {
+ case "6h":
+ return "5m"
+ case "24h":
+ return "15m"
+ case "7d":
+ return "1h"
+ default:
+ return "1m"
+ }
+}
+
+func (d *DB) GetTimeseries(ctx context.Context, window string) (*TimeseriesResult, error) {
+ bucket := bucketForWindow(window)
+ dur := durationForWindow(window)
+ from := time.Now().Add(-dur)
+
+ rows, err := d.sql.QueryContext(ctx, `
+ SELECT
+ date_bin($1::interval, ts, '2000-01-01'::timestamptz) AS bucket_ts,
+ COUNT(*) FILTER (WHERE type IN ('run.start')) AS runs,
+ COUNT(*) FILTER (WHERE type = 'span.end' AND payload->'attributes'->>'span_kind' = 'tool') AS tools,
+ COUNT(*) FILTER (WHERE type = 'error') AS errors
+ FROM events
+ WHERE ts >= $2
+ GROUP BY bucket_ts
+ ORDER BY bucket_ts ASC
+ `, bucket, from)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := &TimeseriesResult{
+ Window: window,
+ Bucket: bucketLabelForWindow(window),
+ }
+
+ for rows.Next() {
+ var b TimeseriesBucket
+ if err := rows.Scan(&b.TS, &b.Runs, &b.Tools, &b.Errors); err != nil {
+ return nil, err
+ }
+ result.Series = append(result.Series, b)
+ }
+
+ return result, rows.Err()
+}
+```
+
+**Step 3: Verify it compiles**
+
+Run: `cd /home/will/lab/agentmon && go build ./internal/store/postgres/`
+Expected: no errors
+
+**Step 4: Commit**
+
+```bash
+git add internal/store/postgres/stats.go
+git commit -m "feat: add summary and timeseries stats queries"
+```
+
+---
+
+### Task 2: Stats API Endpoints
+
+**Files:**
+- Modify: `cmd/query-api/main.go`
+
+**Step 1: Add the summary endpoint**
+
+After the existing `/v1/runs/{runID}` handler block (around line 210), add:
+
+```go
+r.Get("/v1/stats/summary", func(w http.ResponseWriter, r *http.Request) {
+ summary, err := db.GetSummary(r.Context())
+ if err != nil {
+ httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"})
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, summary)
+})
+
+r.Get("/v1/stats/timeseries", func(w http.ResponseWriter, r *http.Request) {
+ window := r.URL.Query().Get("window")
+ switch window {
+ case "1h", "6h", "24h", "7d":
+ default:
+ window = "1h"
+ }
+ ts, err := db.GetTimeseries(r.Context(), window)
+ if err != nil {
+ httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"})
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, ts)
+})
+```
+
+**Step 2: Verify it compiles**
+
+Run: `cd /home/will/lab/agentmon && go build ./cmd/query-api/`
+Expected: no errors
+
+**Step 3: Commit**
+
+```bash
+git add cmd/query-api/main.go
+git commit -m "feat: add stats summary and timeseries API endpoints"
+```
+
+---
+
+### Task 3: Update HTML and Navigation
+
+**Files:**
+- Modify: `cmd/web-ui/static/index.html`
+
+**Step 1: Add uPlot CDN and update nav**
+
+Update the `` to add uPlot CSS before the app stylesheet:
+
+```html
+
+
+```
+
+Add uPlot JS before app.js:
+
+```html
+
+
+```
+
+Update the header logo link and nav to include Dashboard:
+
+```html
+
+
+```
+
+**Step 2: Commit**
+
+```bash
+git add cmd/web-ui/static/index.html
+git commit -m "feat: add uPlot CDN and dashboard nav link"
+```
+
+---
+
+### Task 4: Dashboard CSS
+
+**Files:**
+- Modify: `cmd/web-ui/static/style.css`
+
+**Step 1: Add dashboard styles**
+
+Append to end of `style.css`:
+
+```css
+/* ── Dashboard ────────────────────────────────────────────── */
+.dashboard-summary {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+@media (max-width: 900px) {
+ .dashboard-summary {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 500px) {
+ .dashboard-summary {
+ grid-template-columns: 1fr;
+ }
+}
+
+.summary-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 1.125rem 1.25rem;
+ transition: border-color 0.2s;
+}
+
+.summary-card:hover {
+ border-color: rgba(34, 211, 238, 0.18);
+}
+
+.summary-card-label {
+ font-size: 0.68rem;
+ font-weight: 700;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ margin-bottom: 0.5rem;
+}
+
+.summary-card-value {
+ font-family: var(--font-display);
+ font-size: 2rem;
+ font-weight: 800;
+ color: var(--text-bright);
+ letter-spacing: -0.02em;
+ line-height: 1;
+}
+
+.summary-card-value.has-errors {
+ color: var(--error);
+}
+
+.summary-card-sub {
+ font-size: 0.72rem;
+ color: var(--text-dim);
+ margin-top: 0.35rem;
+ font-family: var(--font-mono);
+}
+
+/* ── Charts row ───────────────────────────────────────────── */
+.charts-row {
+ display: grid;
+ grid-template-columns: 1fr 320px;
+ gap: 1.25rem;
+ margin-bottom: 1.5rem;
+}
+
+@media (max-width: 900px) {
+ .charts-row {
+ grid-template-columns: 1fr;
+ }
+}
+
+.chart-panel {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 1.25rem;
+ min-height: 280px;
+}
+
+.chart-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+}
+
+.chart-title {
+ font-family: var(--font-display);
+ font-size: 0.88rem;
+ font-weight: 700;
+ color: var(--text-bright);
+ letter-spacing: 0.01em;
+}
+
+.window-selector {
+ display: flex;
+ gap: 0;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+
+.window-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-dim);
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ font-weight: 600;
+ padding: 0.3rem 0.65rem;
+ cursor: pointer;
+ letter-spacing: 0.04em;
+ transition: background 0.15s, color 0.15s;
+ border-right: 1px solid var(--border);
+}
+
+.window-btn:last-child {
+ border-right: none;
+}
+
+.window-btn:hover {
+ color: var(--text-bright);
+ background: var(--surface-2);
+}
+
+.window-btn.active {
+ color: var(--accent);
+ background: var(--accent-dim);
+}
+
+.chart-container {
+ width: 100%;
+ min-height: 200px;
+}
+
+/* ── Framework bars ───────────────────────────────────────── */
+.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: 6px;
+ background: var(--surface-2);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.fw-bar-fill {
+ height: 100%;
+ border-radius: 3px;
+ 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;
+ grid-template-columns: 1fr 320px;
+ gap: 1.25rem;
+}
+
+@media (max-width: 900px) {
+ .bottom-panels {
+ grid-template-columns: 1fr;
+ }
+}
+
+.feed-panel {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 1.25rem;
+ max-height: 480px;
+ overflow-y: auto;
+}
+
+.feed-panel .timeline-event {
+ padding: 0.625rem 0.875rem;
+ border-radius: var(--radius);
+ margin-bottom: 0.375rem;
+ border: 1px solid var(--border-soft);
+ background: transparent;
+}
+
+.tools-panel {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 1.25rem;
+}
+
+/* uPlot theme overrides */
+.uplot .u-legend { display: none; }
+```
+
+**Step 2: Commit**
+
+```bash
+git add cmd/web-ui/static/style.css
+git commit -m "feat: add dashboard CSS styles"
+```
+
+---
+
+### Task 5: Dashboard JavaScript
+
+**Files:**
+- Modify: `cmd/web-ui/static/app.js`
+
+**Step 1: Update the router**
+
+Change the route function so `/` renders the dashboard and `/sessions` renders sessions:
+
+In the `route()` function, change:
+```js
+if (path === '/' || path === '/sessions') {
+ renderSessions();
+```
+to:
+```js
+if (path === '/') {
+ renderDashboard();
+} else if (path === '/sessions') {
+ renderSessions();
+```
+
+**Step 2: Add dashboard state and cleanup**
+
+Near the top of the IIFE, alongside the existing state variables, add:
+
+```js
+let dashboardState = null;
+let dashboardUnsubscribe = null;
+let dashboardChart = null;
+```
+
+In `cleanupLiveViews()`, add cleanup for the dashboard:
+
+```js
+if (dashboardUnsubscribe) {
+ dashboardUnsubscribe();
+ dashboardUnsubscribe = null;
+}
+if (dashboardChart) {
+ dashboardChart.destroy();
+ dashboardChart = null;
+}
+```
+
+**Step 3: Add the renderDashboard function**
+
+```js
+async function renderDashboard() {
+ dashboardState = {
+ summary: null,
+ timeseries: null,
+ window: '1h',
+ recentEvents: [],
+ recentEventIDs: new Set(),
+ toolCounts: {},
+ };
+
+ app.innerHTML = `
+
+
+
+
+
+ `;
+
+ // Wire up window selector
+ document.querySelectorAll('.window-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.window-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ dashboardState.window = btn.dataset.w;
+ loadTimeseries();
+ });
+ });
+
+ // Render VM strip
+ renderAgentVMStrip_dash();
+
+ // Load initial data
+ try {
+ const [summaryData, tsData, recentData, snapshots] = await Promise.all([
+ api('/v1/stats/summary'),
+ api('/v1/stats/timeseries?window=1h'),
+ api('/v1/events?limit=20'),
+ api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
+ ]);
+
+ if (!isCurrentPath('/')) return;
+
+ mergeOpenClawEvents(snapshots.events || []);
+ renderAgentVMStrip_dash();
+
+ dashboardState.summary = summaryData;
+ dashboardState.timeseries = tsData;
+ renderSummaryCards();
+ renderTimeseriesChart();
+ renderFrameworkBars();
+
+ // Seed recent events
+ const events = (recentData.events || []).slice().reverse();
+ for (const evt of events) {
+ const id = getRecordID(evt);
+ if (id && !dashboardState.recentEventIDs.has(id)) {
+ dashboardState.recentEventIDs.add(id);
+ dashboardState.recentEvents.push(evt);
+ tallyTool(evt);
+ }
+ }
+ renderDashFeed();
+ renderDashTopTools();
+ } catch (e) {
+ console.error('Dashboard load error:', e);
+ }
+
+ // Subscribe to WebSocket for live updates
+ dashboardUnsubscribe = subscribeWS(handleDashboardWS);
+}
+
+function renderAgentVMStrip_dash() {
+ const strip = document.getElementById('dash-vm-strip');
+ if (!strip) return;
+ const vms = getVMStatus();
+ strip.innerHTML = vms.map(vm => `
+
+
+ ${escapeHTML(vm.name)}
+ ${vm.active ? 'online' : 'offline'}
+
+ `).join('');
+}
+
+function handleDashboardWS(msg) {
+ if (msg.type !== 'message') return;
+
+ const eventType = getEnvelopeType(msg.data);
+
+ if (eventType === 'openclaw.snapshot') {
+ mergeOpenClawEvents([msg.data]);
+ renderAgentVMStrip_dash();
+ return;
+ }
+
+ // Update summary counters
+ if (dashboardState.summary) {
+ if (eventType === 'run.start') dashboardState.summary.runs_today++;
+ if (eventType === 'error') dashboardState.summary.errors_today++;
+ if (eventType === 'span.end') {
+ const attrs = getEnvelopeAttributes(msg.data);
+ if (attrs.span_kind === 'tool') dashboardState.summary.tool_calls_today++;
+ }
+ renderSummaryCards();
+ }
+
+ // Update recent feed
+ const id = getRecordID(msg.data);
+ if (id && !dashboardState.recentEventIDs.has(id)) {
+ dashboardState.recentEventIDs.add(id);
+ dashboardState.recentEvents.push(msg.data);
+ tallyTool(msg.data);
+
+ // Cap at 50, display 20
+ while (dashboardState.recentEvents.length > 50) {
+ const removed = dashboardState.recentEvents.shift();
+ dashboardState.recentEventIDs.delete(getRecordID(removed));
+ }
+
+ renderDashFeed();
+ renderDashTopTools();
+ }
+
+ // Append to current timeseries bucket
+ if (dashboardState.timeseries && dashboardState.window === '1h') {
+ appendToCurrentBucket(msg.data);
+ }
+}
+
+function tallyTool(evt) {
+ const eventType = getEnvelopeType(evt);
+ if (eventType === 'span.end') {
+ const attrs = getEnvelopeAttributes(evt);
+ if (attrs.span_kind === 'tool') {
+ const name = attrs.name || 'unknown';
+ dashboardState.toolCounts[name] = (dashboardState.toolCounts[name] || 0) + 1;
+ }
+ }
+}
+
+function renderSummaryCards() {
+ const s = dashboardState.summary;
+ if (!s) return;
+
+ const el = (id, val) => {
+ const e = document.getElementById(id);
+ if (e) e.textContent = String(val);
+ };
+
+ el('dash-active', s.active_sessions);
+ el('dash-runs', s.runs_today);
+ el('dash-tools', s.tool_calls_today);
+ el('dash-errors', s.errors_today);
+
+ const errEl = document.getElementById('dash-errors');
+ if (errEl) {
+ errEl.classList.toggle('has-errors', s.errors_today > 0);
+ }
+}
+
+async function loadTimeseries() {
+ try {
+ const data = await api('/v1/stats/timeseries?window=' + dashboardState.window);
+ if (!isCurrentPath('/')) return;
+ dashboardState.timeseries = data;
+ renderTimeseriesChart();
+ } catch (e) {
+ console.error('Failed to load timeseries:', e);
+ }
+}
+
+function renderTimeseriesChart() {
+ const container = document.getElementById('dash-chart');
+ if (!container || !dashboardState.timeseries) return;
+
+ const ts = dashboardState.timeseries;
+ if (!ts.series || ts.series.length === 0) {
+ container.innerHTML = '
No data for this window
';
+ return;
+ }
+
+ // Destroy existing chart
+ if (dashboardChart) {
+ dashboardChart.destroy();
+ dashboardChart = null;
+ }
+
+ container.innerHTML = '';
+
+ const timestamps = ts.series.map(b => Math.floor(new Date(b.ts).getTime() / 1000));
+ const runs = ts.series.map(b => b.runs);
+ const tools = ts.series.map(b => b.tools);
+ const errors = ts.series.map(b => b.errors);
+
+ const width = container.clientWidth || 600;
+ const height = 200;
+
+ const opts = {
+ width,
+ height,
+ cursor: { show: true },
+ scales: {
+ x: { time: true },
+ y: { auto: true, min: 0 },
+ },
+ axes: [
+ {
+ 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',
+ },
+ {
+ 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',
+ size: 50,
+ },
+ ],
+ series: [
+ {},
+ {
+ label: 'Runs',
+ stroke: '#34d399',
+ width: 2,
+ fill: 'rgba(52, 211, 153, 0.08)',
+ },
+ {
+ label: 'Tools',
+ stroke: '#22d3ee',
+ width: 2,
+ fill: 'rgba(34, 211, 238, 0.08)',
+ },
+ {
+ label: 'Errors',
+ stroke: '#f87171',
+ width: 2,
+ fill: 'rgba(248, 113, 113, 0.08)',
+ },
+ ],
+ };
+
+ dashboardChart = new uPlot(opts, [timestamps, runs, tools, errors], container);
+}
+
+function appendToCurrentBucket(evt) {
+ const ts = dashboardState.timeseries;
+ if (!ts || !ts.series || ts.series.length === 0) return;
+
+ const now = Math.floor(Date.now() / 60000) * 60000; // current minute
+ const last = ts.series[ts.series.length - 1];
+ const lastTs = new Date(last.ts).getTime();
+
+ let bucket;
+ if (Math.abs(now - lastTs) < 60000) {
+ bucket = last;
+ } else {
+ bucket = { ts: new Date(now).toISOString(), runs: 0, tools: 0, errors: 0 };
+ ts.series.push(bucket);
+ }
+
+ const eventType = getEnvelopeType(evt);
+ if (eventType === 'run.start') bucket.runs++;
+ if (eventType === 'error') bucket.errors++;
+ if (eventType === 'span.end') {
+ const attrs = getEnvelopeAttributes(evt);
+ if (attrs.span_kind === 'tool') bucket.tools++;
+ }
+
+ renderTimeseriesChart();
+}
+
+function renderFrameworkBars() {
+ const container = document.getElementById('dash-fw-bars');
+ if (!container || !dashboardState.summary) return;
+
+ const byFw = dashboardState.summary.by_framework || {};
+ const entries = Object.entries(byFw).sort((a, b) => {
+ const totalA = a[1].runs + a[1].tools + a[1].errors;
+ const totalB = b[1].runs + b[1].tools + b[1].errors;
+ return totalB - totalA;
+ });
+
+ if (entries.length === 0) {
+ container.innerHTML = '
No framework data
';
+ return;
+ }
+
+ const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors));
+
+ container.innerHTML = entries.map(([name, stats]) => {
+ const total = stats.runs + stats.tools + stats.errors;
+ const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0;
+ const cssClass = name.replace(/[^a-z0-9-]/g, '-');
+ return `
+
+
+ ${escapeHTML(name)}
+ ${total} events
+
+
+
+ `;
+ }).join('');
+}
+
+function renderDashFeed() {
+ const feed = document.getElementById('dash-feed');
+ if (!feed) return;
+
+ const recent = dashboardState.recentEvents.slice(-20).reverse();
+ if (recent.length === 0) {
+ feed.innerHTML = '
Waiting for events...
';
+ return;
+ }
+
+ feed.innerHTML = recent.map(evt => {
+ const eventType = getEnvelopeType(evt);
+ const vmName = getVMName(evt);
+ const vmClass = getVMClassName(vmName);
+ const source = getEnvelopeSource(evt);
+ const framework = source.framework || '';
+ const tag = framework
+ ? `
${escapeHTML(framework)}`
+ : '';
+
+ return `
+
+
+ ${getEventBody(evt)}
+
+ `;
+ }).join('');
+}
+
+function renderDashTopTools() {
+ const list = document.getElementById('dash-top-tools');
+ if (!list) return;
+
+ const topTools = Object.entries(dashboardState.toolCounts)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 10);
+
+ if (topTools.length === 0) {
+ list.innerHTML = '
No tool data yet';
+ return;
+ }
+
+ list.innerHTML = topTools.map(([name, count]) => `
+
+ ${escapeHTML(name)}
+ ${count}
+
+ `).join('');
+}
+```
+
+**Step 4: Verify no JS syntax errors**
+
+Open the browser dev tools, navigate to `/`, and verify no console errors.
+
+**Step 5: Commit**
+
+```bash
+git add cmd/web-ui/static/app.js
+git commit -m "feat: add real-time dashboard with charts, stats, and activity feed"
+```
+
+---
+
+### Task 6: Resize handling and polish
+
+**Files:**
+- Modify: `cmd/web-ui/static/app.js`
+
+**Step 1: Add resize observer for the chart**
+
+Inside the `renderTimeseriesChart` function, after creating the uPlot instance, add:
+
+```js
+const ro = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ if (dashboardChart) {
+ dashboardChart.setSize({ width: entry.contentRect.width, height: 200 });
+ }
+ }
+});
+ro.observe(container);
+```
+
+**Step 2: Commit**
+
+```bash
+git add cmd/web-ui/static/app.js
+git commit -m "feat: add chart resize handling"
+```
+
+---
+
+### Task 7: End-to-end verification
+
+**Step 1: Build all binaries**
+
+Run: `cd /home/will/lab/agentmon && go build ./...`
+Expected: no errors
+
+**Step 2: Start the stack locally**
+
+Run: `cd /home/will/lab/agentmon && ./start-all.sh`
+Verify: all services start
+
+**Step 3: Open the dashboard**
+
+Navigate to `http://localhost:
/`
+Verify:
+- Summary cards show numbers (may be 0 if no data)
+- VM strip shows zap/orb/sun status
+- Chart renders (empty is OK with no data)
+- Framework bars section renders
+- Activity feed and top tools sections render
+- Time window buttons switch the chart
+- WebSocket connects (check browser console for "WebSocket connected")
+
+**Step 4: Final commit if any fixes needed**
+
+```bash
+git add -A
+git commit -m "fix: dashboard polish and fixes"
+```
diff --git a/docs/plans/2026-03-14-realtime-agents-design.md b/docs/plans/2026-03-14-realtime-agents-design.md
new file mode 100644
index 0000000..b3d4821
--- /dev/null
+++ b/docs/plans/2026-03-14-realtime-agents-design.md
@@ -0,0 +1,59 @@
+# Realtime Agents Activity View
+
+**Date:** 2026-03-14
+**Status:** Approved
+
+## Overview
+
+Replace the single-timeline Agents page with a per-agent lane layout showing live presence, in-progress operations, and recent events grouped by VM (client_id).
+
+## Data Model: Live State Tracker
+
+Track open events (start without matching end) to derive live status:
+
+- **Active sessions**: `session.start` events keyed by `session_id`, removed on `session.end`. Each knows its VM, start time, framework.
+- **In-progress operations**: `span.start`/`run.start` keyed by `span_id`/`run_id`. Marked complete on matching end event. Show elapsed timer while open.
+- **Per-agent grouping**: All state grouped by `client_id` (zap, orb, sun).
+
+Initial REST load seeds state, WebSocket updates keep it current.
+
+## Layout: Per-Agent Lanes
+
+Three columns (one per VM), replacing the single timeline + stats sidebar:
+
+```
+┌─────────────────────────────────────────────────┐
+│ Agents ● Live │
+├───────────────┬───────────────┬─────────────────┤
+│ ZAP │ ORB │ SUN │
+│ ● 2 sessions │ ● 1 session │ ○ idle │
+│ ───────────── │ ───────────── │ ─────────────── │
+│ ▶ Bash (3.2s) │ ▶ Read (1.1s) │ │
+│ ▶ Grep (...) │ │ (recent events) │
+│ ───────────── │ ───────────── │ │
+│ recent events │ recent events │ │
+└───────────────┴───────────────┴─────────────────┘
+```
+
+Each lane has:
+- **Header**: VM name, online/offline dot, active session count
+- **Active operations**: In-progress spans/runs with pulsing dot + live elapsed timer
+- **Recent events**: Completed events, same card style, scoped to this agent
+
+Summary stats (messages/tools/errors) move to a compact row above the lanes.
+
+Responsive: lanes stack vertically below 900px.
+
+## Active Operations Display
+
+- `span.start` (tool): pulsing green dot + tool name + elapsed counter (1s setInterval)
+- `run.start`: shows as "Thinking..." until `run.end`
+- On matching end event: pill fades out, completed event appears in recent list
+- If session has both active run + active tool spans, show tool spans (more specific)
+- Stale guard: after 5 minutes with no update, dim and show "(stale?)"
+
+## Implementation Notes
+
+- All changes in `style.css` and `app.js` (no backend changes needed)
+- Reuse existing WebSocket subscription and REST API calls
+- Existing event envelope fields (`correlation.session_id`, `correlation.span_id`, `correlation.run_id`, `source.client_id`) provide all grouping keys
diff --git a/docs/plans/2026-03-14-realtime-agents-plan.md b/docs/plans/2026-03-14-realtime-agents-plan.md
new file mode 100644
index 0000000..97fb3f0
--- /dev/null
+++ b/docs/plans/2026-03-14-realtime-agents-plan.md
@@ -0,0 +1,619 @@
+# Realtime Agents Activity View — Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Replace the single-timeline Agents page with per-agent lanes showing live presence, in-progress operations, and scoped event feeds.
+
+**Architecture:** Pure frontend change. Replace `createAgentsState()`, `renderAgents()`, `handleAgentsWS()`, and related functions in `app.js`. Add new CSS classes in `style.css`. Reuse existing envelope helpers, WS subscription, and REST API.
+
+**Tech Stack:** Vanilla JS, CSS custom properties, existing WebSocket + REST API
+
+---
+
+### Task 1: CSS — Lane layout and active operation styles
+
+**Files:**
+- Modify: `cmd/web-ui/static/style.css` (append after existing agents styles, ~line 1430)
+
+**Step 1: Add lane layout CSS**
+
+```css
+/* ── Agent lanes ──────────────────────────────────────────── */
+.agents-summary-row {
+ display: flex;
+ gap: 1.25rem;
+ margin-bottom: 1.25rem;
+}
+
+.agents-summary-stat {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 0.6rem 1rem;
+ font-family: var(--font-mono);
+ font-size: 0.78rem;
+ color: var(--text-dim);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.agents-summary-stat .value {
+ color: var(--text-bright);
+ font-weight: 600;
+}
+
+.agent-lanes {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1.25rem;
+}
+
+@media (max-width: 900px) {
+ .agent-lanes {
+ grid-template-columns: 1fr;
+ }
+}
+
+.agent-lane {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.agent-lane-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.875rem 1.125rem;
+ border-bottom: 1px solid var(--border-soft);
+}
+
+.agent-lane-name {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-family: var(--font-display);
+ font-size: 0.9rem;
+ font-weight: 700;
+ color: var(--text-bright);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.agent-lane-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.agent-lane-dot.online {
+ background: var(--success);
+ box-shadow: 0 0 6px rgba(52, 211, 153, 0.5);
+ animation: livePulse 2s ease-in-out infinite;
+}
+
+.agent-lane-dot.offline {
+ background: var(--text-dim);
+ opacity: 0.5;
+}
+
+.agent-lane-status {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.agent-lane-status.has-sessions {
+ color: var(--success);
+}
+
+/* ── Active operations ────────────────────────────────────── */
+.active-ops {
+ padding: 0.625rem 1.125rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ border-bottom: 1px solid var(--border-soft);
+}
+
+.active-op {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.35rem 0.625rem;
+ background: var(--accent-dim);
+ border: 1px solid rgba(34, 211, 238, 0.15);
+ border-radius: var(--radius);
+ font-size: 0.75rem;
+ animation: fadeUp 0.2s ease both;
+}
+
+[data-theme="light"] .active-op {
+ border-color: rgba(8, 145, 178, 0.2);
+}
+
+.active-op.stale {
+ opacity: 0.5;
+}
+
+.active-op-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--success);
+ box-shadow: 0 0 6px rgba(52, 211, 153, 0.5);
+ animation: livePulse 1.5s ease-in-out infinite;
+ flex-shrink: 0;
+}
+
+.active-op.stale .active-op-dot {
+ background: var(--warning);
+ box-shadow: none;
+ animation: none;
+}
+
+.active-op-name {
+ font-family: var(--font-mono);
+ color: var(--accent);
+ font-weight: 500;
+}
+
+.active-op-time {
+ font-family: var(--font-mono);
+ color: var(--text-dim);
+ font-size: 0.7rem;
+ margin-left: auto;
+}
+
+.active-op-stale {
+ color: var(--warning);
+ font-size: 0.65rem;
+ font-weight: 600;
+}
+
+/* ── Lane event feed ──────────────────────────────────────── */
+.agent-lane-events {
+ flex: 1;
+ overflow-y: auto;
+ max-height: 520px;
+ padding: 0.625rem;
+ position: relative;
+}
+
+.agent-lane-events::after {
+ content: '';
+ position: sticky;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 32px;
+ background: linear-gradient(to bottom, transparent, var(--surface));
+ pointer-events: none;
+ display: block;
+}
+
+.agent-lane-events .timeline-event {
+ padding: 0.5rem 0.625rem;
+ border-radius: var(--radius);
+ margin-bottom: 0.25rem;
+ border: 1px solid var(--border-soft);
+ background: transparent;
+ font-size: 0.82rem;
+}
+
+.agent-lane-events .timeline-event-header {
+ margin-bottom: 0.2rem;
+}
+
+.agent-lane-events .timeline-event-time {
+ font-size: 0.6rem;
+}
+
+.agent-lane-events .empty-state {
+ padding: 2rem 1rem;
+ font-size: 0.78rem;
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add cmd/web-ui/static/style.css
+git commit -m "feat(agents): add CSS for per-agent lane layout and active operation pills"
+```
+
+---
+
+### Task 2: JS — Live state tracker data model
+
+**Files:**
+- Modify: `cmd/web-ui/static/app.js` — replace `createAgentsState()` (~line 665) and add `processAgentEvent()` and `getAgentDisplayOps()`
+
+**Step 1: Replace createAgentsState**
+
+Replace the existing `createAgentsState()` function (lines ~626-643) with:
+
+```js
+function createAgentsState() {
+ function agentBucket() {
+ return { sessions: {}, operations: {}, events: [], eventIDs: new Set() };
+ }
+ return {
+ agents: { zap: agentBucket(), orb: agentBucket(), sun: agentBucket() },
+ stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} },
+ timerInterval: null,
+ };
+}
+```
+
+**Step 2: Add processAgentEvent function**
+
+Add after `createAgentsState`:
+
+```js
+function getAgentBucket(evt) {
+ const name = getVMName(evt).toLowerCase();
+ return agentsState.agents[name] || null;
+}
+
+function processAgentEvent(evt) {
+ const agent = getAgentBucket(evt);
+ if (!agent) return;
+
+ const eventType = getEnvelopeType(evt);
+ const correlation = getEnvelopeCorrelation(evt);
+ const attrs = getEnvelopeAttributes(evt);
+
+ // Track active sessions
+ if (eventType === 'session.start' && correlation.session_id) {
+ agent.sessions[correlation.session_id] = { ts: getEnvelopeTS(evt) };
+ }
+ if (eventType === 'session.end' && correlation.session_id) {
+ delete agent.sessions[correlation.session_id];
+ }
+
+ // Track active operations (span)
+ if (eventType === 'span.start' && correlation.span_id) {
+ agent.operations['s:' + correlation.span_id] = {
+ type: 'span',
+ name: attrs.name || attrs.span_kind || 'unknown',
+ kind: attrs.span_kind || '',
+ startedAt: Date.now(),
+ };
+ }
+ if (eventType === 'span.end' && correlation.span_id) {
+ delete agent.operations['s:' + correlation.span_id];
+ }
+
+ // Track active operations (run)
+ if (eventType === 'run.start' && correlation.run_id) {
+ agent.operations['r:' + correlation.run_id] = {
+ type: 'run',
+ name: 'Thinking\u2026',
+ kind: 'run',
+ startedAt: Date.now(),
+ };
+ }
+ if (eventType === 'run.end' && correlation.run_id) {
+ delete agent.operations['r:' + correlation.run_id];
+ }
+
+ // Add to recent events list
+ const id = getRecordID(evt);
+ if (id && !agent.eventIDs.has(id)) {
+ agent.eventIDs.add(id);
+ agent.events.push(evt);
+ while (agent.events.length > 100) {
+ const removed = agent.events.shift();
+ agent.eventIDs.delete(getRecordID(removed));
+ }
+ }
+}
+
+function getAgentDisplayOps(agent) {
+ const ops = Object.values(agent.operations);
+ const hasTools = ops.some(op => op.kind === 'tool');
+ return hasTools ? ops.filter(op => op.kind === 'tool') : ops;
+}
+```
+
+**Step 3: Update recomputeAgentStats**
+
+Replace existing `recomputeAgentStats()` to iterate over all agent buckets:
+
+```js
+function recomputeAgentStats() {
+ const stats = { messages: 0, tools: 0, errors: 0, toolCounts: {} };
+
+ for (const agent of Object.values(agentsState.agents)) {
+ for (const evt of agent.events) {
+ const eventType = getEnvelopeType(evt);
+ const attrs = getEnvelopeAttributes(evt);
+
+ if (eventType === 'run.start' || eventType === 'run.end') stats.messages++;
+ if (eventType === 'span.end' && attrs.span_kind === 'tool') {
+ stats.tools++;
+ const toolName = attrs.name || 'unknown';
+ stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1;
+ }
+ if (eventType === 'error') stats.errors++;
+ }
+ }
+
+ agentsState.stats = stats;
+}
+```
+
+**Step 4: Commit**
+
+```bash
+git add cmd/web-ui/static/app.js
+git commit -m "feat(agents): add live state tracker with session/operation pairing"
+```
+
+---
+
+### Task 3: JS — Rewrite renderAgents and lane rendering
+
+**Files:**
+- Modify: `cmd/web-ui/static/app.js` — replace `renderAgents()`, `renderAgentTimeline()`, `renderAgentStats()`, `addAgentEvents()`
+
+**Step 1: Replace addAgentEvents**
+
+Replace existing `addAgentEvents()` with a version that routes to processAgentEvent:
+
+```js
+function addAgentEvents(events) {
+ let changed = false;
+
+ for (const evt of events) {
+ const id = getRecordID(evt);
+ const agent = getAgentBucket(evt);
+ if (!id || !agent || agent.eventIDs.has(id)) continue;
+ processAgentEvent(evt);
+ changed = true;
+ }
+
+ if (changed) {
+ // Sort each agent's events
+ for (const agent of Object.values(agentsState.agents)) {
+ agent.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime());
+ }
+ recomputeAgentStats();
+ }
+}
+```
+
+**Step 2: Replace renderAgents with lane layout**
+
+Replace the existing `renderAgents()` function:
+
+```js
+async function renderAgents() {
+ agentsState = createAgentsState();
+
+ app.innerHTML = `
+
+
+
+ `;
+
+ try {
+ const [snapshots, events] = await Promise.all([
+ api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
+ api('/v1/events?framework=openclaw&limit=200'),
+ ]);
+
+ if (!isCurrentPath('/agents')) return;
+
+ mergeOpenClawEvents(snapshots.events || []);
+ addAgentEvents((events.events || []).slice().reverse());
+ renderAgentLanes();
+ renderAgentSummary();
+ } catch (e) {
+ document.getElementById('agents-lanes').innerHTML =
+ `Error loading agent activity: ${escapeHTML(e.message)}
`;
+ }
+
+ // Start elapsed timer
+ agentsState.timerInterval = setInterval(updateAgentTimers, 1000);
+ agentsUnsubscribe = subscribeWS(handleAgentsWS);
+}
+```
+
+**Step 3: Add renderAgentLanes**
+
+```js
+function renderAgentLanes() {
+ const lanesEl = document.getElementById('agents-lanes');
+ if (!lanesEl) return;
+
+ const vmNames = ['zap', 'orb', 'sun'];
+
+ lanesEl.innerHTML = vmNames.map(name => {
+ const agent = agentsState.agents[name];
+ const vmStatus = getVMStatus().find(v => v.name === name);
+ const isOnline = vmStatus && vmStatus.active;
+ const sessionCount = Object.keys(agent.sessions).length;
+ const ops = getAgentDisplayOps(agent);
+
+ const statusClass = sessionCount > 0 ? ' has-sessions' : '';
+ const statusText = !isOnline ? 'offline'
+ : sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '')
+ : 'idle';
+
+ const opsHTML = ops.length > 0 ? `${ops.map(op => {
+ const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
+ const stale = elapsed > 300;
+ return `
+
+
+ ${escapeHTML(op.name)}
+ ${formatElapsed(elapsed)}
+ ${stale ? '(stale?)' : ''}
+
`;
+ }).join('')}
` : '';
+
+ const recent = agent.events.slice(-40).reverse();
+ const eventsHTML = recent.length > 0 ? recent.map(evt => {
+ const eventType = getEnvelopeType(evt);
+ const vmClass = getVMClassName(name);
+ const details = getEventDetails(evt);
+ const detailHTML = details ? `${escapeHTML(details)}
` : '';
+ const expandHTML = details ? '' : '';
+
+ return `
+
+
+ ${getEventBody(evt)}
+ ${expandHTML}
+ ${detailHTML}
+
`;
+ }).join('') : 'No recent activity
';
+
+ return `
+
+
+ ${opsHTML}
+
${eventsHTML}
+
`;
+ }).join('');
+
+ // Wire up detail expand buttons
+ lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => {
+ button.addEventListener('click', () => {
+ button.parentElement.classList.toggle('expanded');
+ });
+ });
+}
+```
+
+**Step 4: Add formatElapsed helper**
+
+Add near the other format helpers (around line 202):
+
+```js
+function formatElapsed(seconds) {
+ if (seconds < 60) return seconds + 's';
+ if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
+ return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
+}
+```
+
+**Step 5: Add renderAgentSummary**
+
+```js
+function renderAgentSummary() {
+ const el = document.getElementById('agents-summary');
+ if (!el) return;
+ const s = agentsState.stats;
+ el.innerHTML = `
+ Messages ${s.messages}
+ Tool Calls ${s.tools}
+ Errors ${s.errors}
+ `;
+}
+```
+
+**Step 6: Commit**
+
+```bash
+git add cmd/web-ui/static/app.js
+git commit -m "feat(agents): per-agent lane layout with active operations and scoped event feeds"
+```
+
+---
+
+### Task 4: JS — WS handler and elapsed timer
+
+**Files:**
+- Modify: `cmd/web-ui/static/app.js` — replace `handleAgentsWS()`, add `updateAgentTimers()`
+
+**Step 1: Replace handleAgentsWS**
+
+```js
+function handleAgentsWS(msg) {
+ if (msg.type !== 'message') return;
+
+ const eventType = getEnvelopeType(msg.data);
+ if (eventType === 'openclaw.snapshot') {
+ mergeOpenClawEvents([msg.data]);
+ renderAgentLanes();
+ return;
+ }
+
+ const framework = getEnvelopeSource(msg.data).framework || msg.data.source_framework;
+ if (framework !== 'openclaw') return;
+
+ addAgentEvents([msg.data]);
+ renderAgentLanes();
+ renderAgentSummary();
+}
+```
+
+**Step 2: Add updateAgentTimers**
+
+```js
+function updateAgentTimers() {
+ document.querySelectorAll('.active-op-time[data-start]').forEach(el => {
+ const start = parseInt(el.dataset.start, 10);
+ if (!start) return;
+ const elapsed = Math.floor((Date.now() - start) / 1000);
+ el.textContent = formatElapsed(elapsed);
+
+ // Check stale
+ const op = el.closest('.active-op');
+ if (op && elapsed > 300 && !op.classList.contains('stale')) {
+ op.classList.add('stale');
+ if (!op.querySelector('.active-op-stale')) {
+ op.insertAdjacentHTML('beforeend', '(stale?)');
+ }
+ }
+ });
+}
+```
+
+**Step 3: Clean up timer on navigation**
+
+In `cleanupLiveViews()`, add timer cleanup:
+
+```js
+if (agentsState && agentsState.timerInterval) {
+ clearInterval(agentsState.timerInterval);
+ agentsState.timerInterval = null;
+}
+```
+
+**Step 4: Remove old functions that are no longer needed**
+
+Delete: `renderAgentTimeline()`, `renderAgentStats()` (replaced by `renderAgentLanes()` and `renderAgentSummary()`), `renderAgentVMStrip()` (VM status is now in lane headers).
+
+**Step 5: Commit**
+
+```bash
+git add cmd/web-ui/static/app.js
+git commit -m "feat(agents): WS handler, elapsed timer, and stale detection"
+```