diff --git a/site/beacon/index.html b/site/beacon/index.html
index 25415c738..46df11acc 100644
--- a/site/beacon/index.html
+++ b/site/beacon/index.html
@@ -70,6 +70,11 @@
+
+
+
DRAG rotate | SCROLL zoom | RIGHT-DRAG pan
diff --git a/site/beacon/leaderboard.mjs b/site/beacon/leaderboard.mjs
new file mode 100644
index 000000000..fcaa00cfa
--- /dev/null
+++ b/site/beacon/leaderboard.mjs
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: MIT
+
+const MAX_METRIC = 1_000_000_000;
+const DEFAULT_LIMIT = 10;
+const MAX_LIMIT = 50;
+
+function safeCount(value) {
+ const count = Number(value);
+ if (!Number.isFinite(count) || count <= 0) return 0;
+ return Math.min(MAX_METRIC, Math.floor(count));
+}
+
+function safeLimit(value) {
+ const limit = Number(value);
+ if (!Number.isFinite(limit) || limit <= 0) return DEFAULT_LIMIT;
+ return Math.min(MAX_LIMIT, Math.floor(limit));
+}
+
+function reputationFor(reputation, agentId) {
+ if (reputation instanceof Map) return reputation.get(agentId) || {};
+ if (!reputation || typeof reputation !== 'object') return {};
+ return reputation[agentId] || {};
+}
+
+function visibleContractCounts(contracts) {
+ const counts = new Map();
+ const seenByAgent = new Map();
+
+ for (const [index, contract] of (Array.isArray(contracts) ? contracts : []).entries()) {
+ if (!contract || typeof contract !== 'object') continue;
+ const contractKey = String(contract.id ?? `row-${index}`);
+ const participants = new Set([contract.from, contract.to].filter(value => (
+ typeof value === 'string' && value.length > 0
+ )));
+
+ for (const agentId of participants) {
+ if (!seenByAgent.has(agentId)) seenByAgent.set(agentId, new Set());
+ const seen = seenByAgent.get(agentId);
+ if (seen.has(contractKey)) continue;
+ seen.add(contractKey);
+ counts.set(agentId, (counts.get(agentId) || 0) + 1);
+ }
+ }
+
+ return counts;
+}
+
+function compareText(left, right) {
+ const a = String(left ?? '').toLowerCase();
+ const b = String(right ?? '').toLowerCase();
+ if (a < b) return -1;
+ if (a > b) return 1;
+ return 0;
+}
+
+/**
+ * Build a deterministic leaderboard without mutating live Atlas data.
+ *
+ * Contract totals use the larger of currently-visible relationships and the
+ * backend's completed-contract counter so historical work remains represented.
+ */
+export function buildAgentLeaderboard(
+ agents,
+ contracts,
+ reputation = {},
+ sortBy = 'beats',
+ limit = DEFAULT_LIMIT,
+) {
+ const contractCounts = visibleContractCounts(contracts);
+ const entries = [];
+ const seenAgentIds = new Set();
+
+ for (const agent of (Array.isArray(agents) ? agents : [])) {
+ if (!agent || typeof agent !== 'object') continue;
+ const id = typeof agent.id === 'string' ? agent.id : '';
+ if (!id || seenAgentIds.has(id)) continue;
+ seenAgentIds.add(id);
+
+ const rep = reputationFor(reputation, id);
+ const beats = safeCount(agent.beat_count);
+ const contractCount = Math.max(
+ safeCount(contractCounts.get(id)),
+ safeCount(rep.contracts_completed),
+ );
+
+ if (beats === 0 && contractCount === 0) continue;
+ entries.push({
+ id,
+ name: typeof agent.name === 'string' && agent.name.trim() ? agent.name.trim() : id,
+ beats,
+ contracts: contractCount,
+ });
+ }
+
+ const mode = sortBy === 'contracts' ? 'contracts' : 'beats';
+ const secondary = mode === 'beats' ? 'contracts' : 'beats';
+ entries.sort((a, b) => (
+ b[mode] - a[mode]
+ || b[secondary] - a[secondary]
+ || compareText(a.name, b.name)
+ || compareText(a.id, b.id)
+ ));
+
+ return entries.slice(0, safeLimit(limit));
+}
diff --git a/site/beacon/styles.css b/site/beacon/styles.css
index 05b060fc1..922d8335d 100644
--- a/site/beacon/styles.css
+++ b/site/beacon/styles.css
@@ -90,6 +90,125 @@ html, body {
color: var(--green);
}
+/* --- Agent Leaderboard (left sidebar) --- */
+.agent-leaderboard {
+ position: fixed;
+ top: 150px;
+ left: 16px;
+ z-index: 50;
+ width: min(340px, calc(100vw - 32px));
+ max-height: calc(100vh - 220px);
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-panel);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ box-shadow: 0 0 20px rgba(0, 255, 0, 0.05);
+ color: var(--text-body);
+ font-family: var(--font-mono);
+ pointer-events: auto;
+ overflow: hidden;
+}
+
+.leaderboard-header {
+ padding: 8px 10px;
+ background: rgba(0, 20, 0, 0.65);
+ border-bottom: 1px solid var(--border);
+}
+
+.leaderboard-title {
+ color: var(--amber);
+ font-family: var(--font-display);
+ font-size: 20px;
+ font-weight: 400;
+ letter-spacing: 1px;
+}
+
+.leaderboard-controls {
+ display: flex;
+ gap: 6px;
+ margin-top: 5px;
+}
+
+.leaderboard-mode {
+ padding: 2px 7px;
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 2px;
+ color: var(--text-dim);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ cursor: pointer;
+}
+
+.leaderboard-mode[aria-pressed="true"] {
+ border-color: var(--amber-dim);
+ color: var(--amber);
+ background: rgba(255, 176, 0, 0.08);
+}
+
+.leaderboard-list {
+ min-height: 34px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--green-dim) transparent;
+}
+
+.leaderboard-item {
+ margin: 0;
+ padding: 0;
+}
+
+.leaderboard-row {
+ width: 100%;
+ display: grid;
+ grid-template-columns: 26px minmax(0, 1fr) 54px 54px;
+ gap: 5px;
+ align-items: center;
+ padding: 6px 9px;
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid rgba(26, 51, 26, 0.65);
+ color: var(--text-body);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.leaderboard-row:hover,
+.leaderboard-row:focus-visible {
+ background: rgba(51, 255, 51, 0.08);
+ color: var(--green);
+ outline: 1px solid var(--green-dim);
+ outline-offset: -1px;
+}
+
+.leaderboard-rank {
+ color: var(--amber);
+}
+
+.leaderboard-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.leaderboard-metric {
+ color: var(--text-dim);
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.leaderboard-empty {
+ padding: 10px;
+ color: var(--text-dim);
+ font-size: 11px;
+}
+
/* --- Controls hint (bottom-left) --- */
.controls-hint {
position: fixed;
@@ -753,6 +872,17 @@ html, body {
.hud-title { font-size: 22px; }
.hud-stats { font-size: 14px; }
+ .agent-leaderboard {
+ top: 132px;
+ left: 8px;
+ width: min(330px, calc(100vw - 16px));
+ max-height: 36vh;
+ }
+
+ .leaderboard-row {
+ padding: 5px 7px;
+ }
+
.controls-hint { display: none; }
}
diff --git a/site/beacon/ui.js b/site/beacon/ui.js
index bcc27d986..e2aee4a81 100644
--- a/site/beacon/ui.js
+++ b/site/beacon/ui.js
@@ -11,6 +11,7 @@ import { getAgentPosition, highlightAgent } from './agents.js';
import { getCityCenter } from './cities.js';
import { highlightAgentConnections, addContractLine } from './connections.js';
import { initChat, setCurrentAgent, getChatHTML, bindChatEvents } from './chat.js';
+import { buildAgentLeaderboard } from './leaderboard.mjs';
const BEACON_API = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
? 'http://localhost:8071'
@@ -20,15 +21,8 @@ let panel, panelContent, panelPath, tooltip;
let selectedAgent = null;
let selectedCity = null;
let hoveredId = null;
-
-function escapeHtml(value) {
- return String(value ?? '')
- .replaceAll('&', '&')
- .replaceAll('<', '<')
- .replaceAll('>', '>')
- .replaceAll('"', '"')
- .replaceAll("'", ''');
-}
+let leaderboardRoot = null;
+let leaderboardMode = 'beats';
function safeNumber(value, fallback = 0, min = 0, max = Number.MAX_SAFE_INTEGER) {
const number = Number(value);
@@ -89,6 +83,7 @@ async function loadReputation() {
});
}
reputationTs = Date.now();
+ renderAgentLeaderboard();
}
} catch (e) {
console.warn('[rep] Failed to load reputation:', e.message);
@@ -111,6 +106,7 @@ export function initUI() {
// HUD stats
updateHUD();
+ initAgentLeaderboard();
// Click handlers
setClickHandler(onObjectClick);
@@ -155,6 +151,94 @@ function updateHUD() {
}
}
+function initAgentLeaderboard() {
+ leaderboardRoot = document.querySelector('.agent-leaderboard');
+ renderAgentLeaderboard();
+}
+
+function renderAgentLeaderboard() {
+ if (!leaderboardRoot) return;
+
+ const header = document.createElement('div');
+ header.className = 'leaderboard-header';
+
+ const title = document.createElement('h2');
+ title.className = 'leaderboard-title';
+ title.textContent = '[TOP AGENTS]';
+ header.appendChild(title);
+
+ const controls = document.createElement('div');
+ controls.className = 'leaderboard-controls';
+ for (const mode of ['beats', 'contracts']) {
+ const modeButton = document.createElement('button');
+ modeButton.type = 'button';
+ modeButton.className = 'leaderboard-mode';
+ modeButton.textContent = mode === 'beats' ? 'BEATS' : 'CONTRACTS';
+ modeButton.setAttribute('aria-pressed', String(leaderboardMode === mode));
+ modeButton.addEventListener('click', () => {
+ leaderboardMode = mode;
+ renderAgentLeaderboard();
+ });
+ controls.appendChild(modeButton);
+ }
+ header.appendChild(controls);
+
+ const entries = buildAgentLeaderboard(
+ AGENTS,
+ CONTRACTS,
+ reputationCache,
+ leaderboardMode,
+ 10,
+ );
+ const list = document.createElement('ol');
+ list.className = 'leaderboard-list';
+
+ if (entries.length === 0) {
+ const empty = document.createElement('li');
+ empty.className = 'leaderboard-empty';
+ empty.textContent = 'No heartbeat or contract activity yet.';
+ list.appendChild(empty);
+ }
+
+ entries.forEach((entry, index) => {
+ const item = document.createElement('li');
+ item.className = 'leaderboard-item';
+
+ const row = document.createElement('button');
+ row.type = 'button';
+ row.className = 'leaderboard-row';
+ row.setAttribute(
+ 'aria-label',
+ `${index + 1}. ${entry.name}; ${entry.beats} heartbeats; ${entry.contracts} contracts`,
+ );
+ row.addEventListener('click', () => selectAgent(entry.id));
+
+ const rank = document.createElement('span');
+ rank.className = 'leaderboard-rank';
+ rank.textContent = String(index + 1).padStart(2, '0');
+
+ const rowName = document.createElement('span');
+ rowName.className = 'leaderboard-name';
+ rowName.textContent = entry.name;
+
+ const beats = document.createElement('span');
+ beats.className = 'leaderboard-metric';
+ beats.title = 'Heartbeats';
+ beats.textContent = `${entry.beats} B`;
+
+ const contracts = document.createElement('span');
+ contracts.className = 'leaderboard-metric';
+ contracts.title = 'Contracts';
+ contracts.textContent = `${entry.contracts} C`;
+
+ row.append(rank, rowName, beats, contracts);
+ item.appendChild(row);
+ list.appendChild(item);
+ });
+
+ leaderboardRoot.replaceChildren(header, list);
+}
+
function setPanelPath(path) {
const prompt = document.createElement('span');
prompt.className = 'prompt';
@@ -643,6 +727,7 @@ async function submitContract() {
// Success - add to data, create 3D line, update HUD
const normalized = addContract(data);
addContractLine(normalized);
+ renderAgentLeaderboard();
updateHUD();
successEl.textContent = `CONTRACT ${data.id} TRANSMITTED. State: ${data.state}`;
diff --git a/tests/beacon_agent_leaderboard.test.mjs b/tests/beacon_agent_leaderboard.test.mjs
new file mode 100644
index 000000000..af228351e
--- /dev/null
+++ b/tests/beacon_agent_leaderboard.test.mjs
@@ -0,0 +1,82 @@
+// SPDX-License-Identifier: MIT
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { buildAgentLeaderboard } from '../site/beacon/leaderboard.mjs';
+
+const agents = [
+ { id: 'bcn_beta', name: 'Beta', beat_count: 8 },
+ { id: 'bcn_alpha', name: 'Alpha', beat_count: 8 },
+ { id: 'bcn_gamma', name: 'Gamma', beat_count: 3 },
+ { id: 'bcn_idle', name: 'Idle', beat_count: 0 },
+];
+
+const contracts = [
+ { id: 'ctr-1', from: 'bcn_alpha', to: 'bcn_gamma' },
+ { id: 'ctr-2', from: 'bcn_gamma', to: 'bcn_beta' },
+ { id: 'ctr-3', from: 'bcn_gamma', to: 'bcn_gamma' },
+];
+
+test('beat mode ranks by heartbeats, then contracts, then name', () => {
+ const ranked = buildAgentLeaderboard(agents, contracts, {}, 'beats');
+
+ assert.deepEqual(ranked.map(entry => entry.id), [
+ 'bcn_alpha',
+ 'bcn_beta',
+ 'bcn_gamma',
+ ]);
+ assert.deepEqual(ranked.map(entry => entry.beats), [8, 8, 3]);
+});
+
+test('contract mode combines visible relationships with reputation history', () => {
+ const ranked = buildAgentLeaderboard(
+ agents,
+ contracts,
+ { bcn_beta: { contracts_completed: 7 } },
+ 'contracts',
+ );
+
+ assert.equal(ranked[0].id, 'bcn_beta');
+ assert.equal(ranked[0].contracts, 7);
+ assert.equal(ranked.find(entry => entry.id === 'bcn_gamma').contracts, 3);
+});
+
+test('duplicate contract ids and self-contracts count once per agent', () => {
+ const ranked = buildAgentLeaderboard(
+ [{ id: 'bcn_one', name: 'One', beat_count: 1 }],
+ [
+ { id: 'same', from: 'bcn_one', to: 'bcn_one' },
+ { id: 'same', from: 'bcn_one', to: 'bcn_other' },
+ ],
+ {},
+ 'contracts',
+ );
+
+ assert.equal(ranked[0].contracts, 1);
+});
+
+test('malformed counts fail closed and source arrays are not mutated', () => {
+ const sourceAgents = [
+ { id: 'bcn_bad', name: 'Bad', beat_count: '