Files
agentmon/docs/plans/2026-03-14-realtime-agents-plan.md
2026-03-20 11:17:17 -07:00

17 KiB

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

/* ── 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

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:

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:

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:

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

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:

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:

async function renderAgents() {
  agentsState = createAgentsState();

  app.innerHTML = `
    <div class="page-header">
      <h2>Agents <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
    </div>
    <div class="agents-summary-row" id="agents-summary"></div>
    <div class="agent-lanes" id="agents-lanes">
      <div class="agent-lane"><div class="agent-lane-header"><div class="agent-lane-name">ZAP</div></div><div class="agent-lane-events"><p class="empty-state">Loading...</p></div></div>
      <div class="agent-lane"><div class="agent-lane-header"><div class="agent-lane-name">ORB</div></div><div class="agent-lane-events"><p class="empty-state">Loading...</p></div></div>
      <div class="agent-lane"><div class="agent-lane-header"><div class="agent-lane-name">SUN</div></div><div class="agent-lane-events"><p class="empty-state">Loading...</p></div></div>
    </div>
  `;

  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 =
      `<p class="empty-state">Error loading agent activity: ${escapeHTML(e.message)}</p>`;
  }

  // Start elapsed timer
  agentsState.timerInterval = setInterval(updateAgentTimers, 1000);
  agentsUnsubscribe = subscribeWS(handleAgentsWS);
}

Step 3: Add renderAgentLanes

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 ? `<div class="active-ops">${ops.map(op => {
      const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
      const stale = elapsed > 300;
      return `
        <div class="active-op${stale ? ' stale' : ''}">
          <span class="active-op-dot"></span>
          <span class="active-op-name">${escapeHTML(op.name)}</span>
          <span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>
          ${stale ? '<span class="active-op-stale">(stale?)</span>' : ''}
        </div>`;
    }).join('')}</div>` : '';

    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 ? `<div class="timeline-detail">${escapeHTML(details)}</div>` : '';
      const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';

      return `
        <div class="timeline-event">
          <div class="timeline-event-header">
            ${getEventIcon(eventType)}
            <span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
            <span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
          </div>
          ${getEventBody(evt)}
          ${expandHTML}
          ${detailHTML}
        </div>`;
    }).join('') : '<p class="empty-state">No recent activity</p>';

    return `
      <div class="agent-lane">
        <div class="agent-lane-header">
          <div class="agent-lane-name">
            <span class="agent-lane-dot ${isOnline ? 'online' : 'offline'}"></span>
            ${escapeHTML(name.toUpperCase())}
          </div>
          <span class="agent-lane-status${statusClass}">${statusText}</span>
        </div>
        ${opsHTML}
        <div class="agent-lane-events">${eventsHTML}</div>
      </div>`;
  }).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):

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

function renderAgentSummary() {
  const el = document.getElementById('agents-summary');
  if (!el) return;
  const s = agentsState.stats;
  el.innerHTML = `
    <div class="agents-summary-stat">Messages <span class="value">${s.messages}</span></div>
    <div class="agents-summary-stat">Tool Calls <span class="value">${s.tools}</span></div>
    <div class="agents-summary-stat">Errors <span class="value">${s.errors}</span></div>
  `;
}

Step 6: Commit

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

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

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', '<span class="active-op-stale">(stale?)</span>');
      }
    }
  });
}

Step 3: Clean up timer on navigation

In cleanupLiveViews(), add timer cleanup:

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

git add cmd/web-ui/static/app.js
git commit -m "feat(agents): WS handler, elapsed timer, and stale detection"