← back to Loops Dashboard
server.js
427 lines
// loops-dashboard — single-pane aggregator for every AI loop running on Steve's stack.
// Dark theme, auto-refresh every 60s. KeepAlive supervised by launchd at :9930.
//
// Sources (all read-only):
// - idea-loop: ~/.claude/skills/idea-loop/data/{ideas,rejects,decisions}.jsonl
// - codex-3way: ~/Projects/*/.git/codex-3way-hook/latest.summary.md
// - morning-review: http://127.0.0.1:9762/api/findings
// - launchd: `launchctl list | grep com.steve.`
// - pm2: `pm2 jlist` (best-effort)
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const PORT = process.env.PORT || 9930;
const HOME = os.homedir();
const IDEA_DATA = path.join(HOME, '.claude/skills/idea-loop/data');
const REPOS = ['ventura-claw-leads', 'Designer-Wallcoverings'];
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
function readJsonl(p) {
if (!fs.existsSync(p)) return [];
return fs.readFileSync(p, 'utf8').split('\n').filter(Boolean)
.map(l => { try { return JSON.parse(l); } catch { return null; } })
.filter(Boolean);
}
function safeRun(bin, args, opts = {}) {
try {
return execFileSync(bin, args, { encoding: 'utf8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'], ...opts });
} catch { return ''; }
}
function ageHuman(ts) {
if (!ts) return '—';
const ms = Date.now() - new Date(ts).getTime();
if (isNaN(ms) || ms < 0) return '—';
const sec = Math.floor(ms / 1000);
if (sec < 60) return sec + 's ago';
const min = Math.floor(sec / 60);
if (min < 60) return min + 'm ago';
const hr = Math.floor(min / 60);
if (hr < 24) return hr + 'h ' + (min % 60) + 'm ago';
return Math.floor(hr / 24) + 'd ago';
}
// --------------------------------- Sources ---------------------------------
function getIdeaLoopState() {
const ideas = readJsonl(path.join(IDEA_DATA, 'ideas.jsonl'));
const rejects = readJsonl(path.join(IDEA_DATA, 'rejects.jsonl'));
const decisions = readJsonl(path.join(IDEA_DATA, 'decisions.jsonl'));
const lastTickPath = path.join(IDEA_DATA, 'tick.log');
let lastTick = null;
try {
const log = fs.readFileSync(lastTickPath, 'utf8').trim().split('\n');
for (let i = log.length - 1; i >= 0; i--) {
if (/tick \d+-\d+ complete/.test(log[i])) { lastTick = log[i].match(/^\[([^\]]+)\]/)?.[1]; break; }
}
} catch {}
const sorted = [...ideas].sort((a, b) => (b.score_total || 0) - (a.score_total || 0));
return {
accepted: ideas.length,
rejected: rejects.length,
decisions: decisions.length,
last_tick: lastTick,
last_tick_age: ageHuman(lastTick),
top: sorted.slice(0, 5).map(i => ({
score: i.score_total, type: i.type, title: i.title,
first_step: i.first_step, why: i.why
}))
};
}
function getCodex3WayState() {
const out = [];
for (const repo of REPOS) {
const dir = path.join(HOME, 'Projects', repo);
const hookDir = path.join(dir, '.git/codex-3way-hook');
if (!fs.existsSync(hookDir)) continue;
let entries = [];
try { entries = fs.readdirSync(hookDir); } catch {}
const logs = entries
.filter(f => /^auto-[a-f0-9]{6,12}\.log$/.test(f))
.map(f => {
const full = path.join(hookDir, f);
let mtime = 0; try { mtime = fs.statSync(full).mtimeMs; } catch {}
return { sha: f.replace(/^auto-/, '').replace(/\.log$/, ''), mtime, full };
})
.sort((a, b) => b.mtime - a.mtime);
const latest = logs[0];
let subject = '';
if (latest) {
try { subject = execFileSync('git', ['-C', dir, 'show', '-s', '--format=%s', latest.sha], { encoding: 'utf8', timeout: 3000 }).trim(); } catch {}
}
out.push({
repo,
total_debates: logs.length,
last_sha: latest?.sha || null,
last_mtime: latest ? new Date(latest.mtime).toISOString() : null,
last_age: latest ? ageHuman(new Date(latest.mtime).toISOString()) : '—',
last_subject: subject
});
}
return out;
}
function getMorningReviewState() {
return new Promise((resolve) => {
const auth = 'Basic ' + Buffer.from('admin:DWSecure2024!').toString('base64');
const req = http.request({
hostname: '127.0.0.1', port: 9762, path: '/api/findings',
method: 'GET', headers: { Authorization: auth }, timeout: 4000
}, (res) => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => {
if (res.statusCode !== 200) {
return resolve({ total: 0, by_source: {}, error: 'http-' + res.statusCode });
}
try {
const d = JSON.parse(body);
const findings = d.findings || (Array.isArray(d) ? d : []);
const bySource = {};
for (const f of findings) {
const s = f.source || 'unknown';
bySource[s] = (bySource[s] || 0) + 1;
}
resolve({ total: findings.length, by_source: bySource });
} catch { resolve({ total: 0, by_source: {}, error: 'parse-fail' }); }
});
});
req.on('error', () => resolve({ total: 0, by_source: {}, error: 'unreachable' }));
req.on('timeout', () => { req.destroy(); resolve({ total: 0, by_source: {}, error: 'timeout' }); });
req.end();
});
}
function getLaunchdState() {
const raw = safeRun('/bin/launchctl', ['list']);
const lines = raw.split('\n').filter(l => l.includes('com.steve.'));
const jobs = lines.map(l => {
const parts = l.split(/\s+/);
return { pid: parts[0], status: parts[1], label: parts[2] };
});
const problems = jobs.filter(j => j.status !== '-' && j.status !== '0');
const ai_loop_jobs = jobs.filter(j =>
/idea-loop|morning-review|codex-meeting|claude-codex|yolo|debate/.test(j.label || '')
);
return { total: jobs.length, problems: problems.length, problem_jobs: problems.slice(0, 8), ai_loop_jobs };
}
function getPm2State() {
const out = safeRun('/usr/local/bin/pm2', ['jlist']) || safeRun(`${HOME}/.npm-global/bin/pm2`, ['jlist']) || safeRun('pm2', ['jlist']);
if (!out) return { total: 0, online: 0, errored: 0, ai_loop: [] };
let procs = [];
try { procs = JSON.parse(out); } catch { return { total: 0, online: 0, errored: 0, ai_loop: [] }; }
const online = procs.filter(p => p.pm2_env?.status === 'online').length;
const errored = procs.filter(p => p.pm2_env?.status === 'errored' || p.pm2_env?.status === 'stopped').length;
const aiLoop = procs.filter(p =>
/viewer|morning|review|idea|codex|loop|hawk/.test(p.name || '')
).map(p => ({ name: p.name, status: p.pm2_env?.status, mem_mb: Math.round((p.monit?.memory || 0) / 1024 / 1024), cpu: p.monit?.cpu }));
return { total: procs.length, online, errored, ai_loop: aiLoop };
}
// --------------------------------- Render ---------------------------------
function renderPage(data) {
const { idea, codex3, morning, launchd, pm2 } = data;
const css = `
* { box-sizing: border-box; }
body { font-family: -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif; background: #0a0a0a; color: #f5f1e8; margin: 0; padding: 24px 28px; line-height: 1.5; }
h1 { font-family: -apple-system, "SF Pro Display", Georgia, serif; font-weight: 200; font-size: 24px; letter-spacing: 0.32em; text-transform: uppercase; margin: 0 0 6px; }
.sub { color: #888; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; margin: 0 0 32px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 18px; max-width: 1500px; margin: 0 auto; }
.panel { background: #141414; border: 1px solid #222; border-left: 3px solid #c9a14b; padding: 16px 20px; border-radius: 4px; }
.panel h2 { font-family: Georgia, serif; font-weight: 400; font-size: 16px; margin: 0 0 6px; color: #f5f1e8; }
.panel .label { color: #888; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 12px; }
.stats { display: flex; gap: 18px; flex-wrap: wrap; margin: 0 0 12px; }
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat .v { font-family: ui-monospace, "SF Mono", monospace; font-size: 22px; color: #c9a14b; line-height: 1; }
.stat .k { font-size: 10px; color: #888; letter-spacing: 0.1em; text-transform: uppercase; }
.row { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px dotted #222; font-size: 13px; }
.row:first-of-type { border-top: 0; }
.row .left { color: #ddd; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.row .right { color: #888; font-family: ui-monospace, monospace; font-size: 12px; flex-shrink: 0; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; margin-left: 6px; }
.b-online { background: #064e3b; color: #6ee7b7; }
.b-error { background: #7f1d1d; color: #fecaca; }
.b-stale { background: #422006; color: #fed7aa; }
.top-idea { padding: 8px 0; border-top: 1px dotted #222; font-size: 12px; }
.top-idea:first-child { border-top: 0; }
.top-idea .head { display: flex; justify-content: space-between; gap: 8px; }
.top-idea .score { font-family: ui-monospace, monospace; color: #c9a14b; font-weight: 600; font-size: 13px; }
.top-idea .title { color: #f5f1e8; font-weight: 500; flex: 1; }
.top-idea .why { color: #aaa; font-size: 11px; margin: 4px 0 0; line-height: 1.45; }
.links { font-size: 11px; color: #666; margin-top: 24px; text-align: center; }
.links a { color: #c9a14b; text-decoration: none; margin: 0 8px; }
.meta-row { font-size: 11px; color: #666; margin: 0 0 12px; font-family: ui-monospace, monospace; }
.severity-HIGH { color: #fca5a5; }
.severity-MEDIUM { color: #fcd34d; }
.severity-LOW { color: #888; }
.auto-refresh { font-size: 10px; color: #555; text-align: right; margin: 0 0 8px; letter-spacing: 0.06em; }
`;
// idea-loop top picks
const topIdeasHtml = idea.top.length
? idea.top.map(i => `
<div class="top-idea">
<div class="head">
<span class="score">${i.score}/40</span>
<span class="title">${esc(i.title || '')}</span>
<span class="severity-${i.score >= 32 ? 'HIGH' : i.score >= 28 ? 'MEDIUM' : 'LOW'}">${esc((i.type || '').toUpperCase())}</span>
</div>
${i.why ? `<div class="why">${esc(i.why.slice(0, 140))}${i.why.length > 140 ? '…' : ''}</div>` : ''}
</div>`).join('')
: '<div class="top-idea"><div class="why">No accepted ideas yet — wait for the next 10-min tick.</div></div>';
// codex-3way per-repo
const codex3Html = codex3.length
? codex3.map(c => `
<div class="row">
<span class="left">${esc(c.repo)} · <span style="color:#666">${c.total_debates} debates</span></span>
<span class="right">${esc(c.last_age)}</span>
</div>
${c.last_subject ? `<div style="font-size:11px;color:#aaa;margin:0 0 8px;padding-left:10px">latest: <code style="color:#c9a14b">${esc(c.last_sha?.slice(0,7))}</code> ${esc(c.last_subject.slice(0, 80))}${c.last_subject.length > 80 ? '…' : ''}</div>` : ''}`).join('')
: '<div class="row"><span class="left">No debates yet</span></div>';
// launchd issues
const launchdProblemsHtml = launchd.problem_jobs.length
? launchd.problem_jobs.map(j => `
<div class="row">
<span class="left">${esc(j.label)}</span>
<span class="right">exit ${esc(j.status)}<span class="badge b-error">err</span></span>
</div>`).join('')
: '<div class="row"><span class="left">All launchd jobs healthy</span><span class="right"><span class="badge b-online">ok</span></span></div>';
// launchd AI-loop subset
const aiJobsHtml = launchd.ai_loop_jobs.length
? launchd.ai_loop_jobs.map(j => {
const statusClass = j.status === '-' || j.status === '0' ? 'b-online' : 'b-error';
const statusText = j.status === '-' || j.status === '0' ? 'ok' : `exit ${j.status}`;
return `<div class="row"><span class="left">${esc(j.label)}</span><span class="right"><span class="badge ${statusClass}">${esc(statusText)}</span></span></div>`;
}).join('')
: '<div class="row"><span class="left">No AI-loop launchd jobs</span></div>';
// pm2 ai-loop
const pm2Html = pm2.ai_loop.length
? pm2.ai_loop.map(p => {
const sc = p.status === 'online' ? 'b-online' : 'b-error';
return `<div class="row"><span class="left">${esc(p.name)}</span><span class="right">${p.mem_mb}MB · ${p.cpu || 0}%<span class="badge ${sc}">${esc(p.status)}</span></span></div>`;
}).join('')
: '<div class="row"><span class="left">No AI-loop pm2 procs</span></div>';
// morning-review by source
const sortedSources = Object.entries(morning.by_source || {}).sort((a, b) => b[1] - a[1]);
const morningSourcesHtml = sortedSources.length
? sortedSources.map(([s, n]) => `
<div class="row"><span class="left">${esc(s)}</span><span class="right">${n}</span></div>`).join('')
: '<div class="row"><span class="left">no findings</span></div>';
return `<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>loops-dashboard · ${idea.accepted}/${idea.decisions} ideas · ${morning.total} findings</title>
<meta http-equiv="refresh" content="60">
<style>${css}</style>
</head><body>
<h1>Loops Dashboard</h1>
<div class="sub">all AI loops · aggregator · auto-refresh 60s</div>
<div class="auto-refresh">refreshed ${new Date().toISOString()}</div>
<div class="grid">
<!-- IDEA-LOOP -->
<div class="panel">
<div class="label">Idea-loop · autonomous R&D</div>
<h2>${idea.accepted} accepted · ${idea.decisions} decided</h2>
<div class="meta-row">last tick ${esc(idea.last_tick_age)} · <a href="http://127.0.0.1:9920" style="color:#c9a14b">viewer →</a></div>
<div class="stats">
<div class="stat"><div class="v">${idea.accepted}</div><div class="k">accepted</div></div>
<div class="stat"><div class="v">${idea.rejected}</div><div class="k">rejected</div></div>
<div class="stat"><div class="v">${idea.decisions}</div><div class="k">decided</div></div>
<div class="stat"><div class="v">${idea.accepted - idea.decisions}</div><div class="k">awaiting</div></div>
</div>
<div style="margin-top:8px">
<div class="label" style="margin-bottom:6px">Top accepted</div>
${topIdeasHtml}
</div>
</div>
<!-- CODEX-3WAY -->
<div class="panel">
<div class="label">Codex-3way · auto-debate</div>
<h2>${codex3.reduce((s, c) => s + c.total_debates, 0)} debates total</h2>
<div class="meta-row">post-commit hook on ${codex3.length} repos</div>
${codex3Html}
</div>
<!-- MORNING-REVIEW -->
<div class="panel">
<div class="label">Morning-review · 8:30 AM digest</div>
<h2>${morning.total} findings ${morning.error ? `<span class="badge b-error">${esc(morning.error)}</span>` : ''}</h2>
<div class="meta-row">6 scanners aggregated · <a href="http://127.0.0.1:9762" style="color:#c9a14b">viewer →</a></div>
${morningSourcesHtml}
</div>
<!-- LAUNCHD AI-LOOP JOBS -->
<div class="panel">
<div class="label">Launchd · AI-loop subset</div>
<h2>${launchd.ai_loop_jobs.length} jobs · ${launchd.total} total</h2>
<div class="meta-row">${launchd.problems} problems across full fleet</div>
${aiJobsHtml}
</div>
<!-- PM2 -->
<div class="panel">
<div class="label">pm2 · running services</div>
<h2>${pm2.online}/${pm2.total} online${pm2.errored ? ` · ${pm2.errored} errored` : ''}</h2>
<div class="meta-row">filtered to AI-loop processes</div>
${pm2Html}
</div>
<!-- LAUNCHD PROBLEMS -->
<div class="panel">
<div class="label">Launchd · problems</div>
<h2>${launchd.problems} jobs with non-zero exit</h2>
<div class="meta-row">across all com.steve.* labels</div>
${launchdProblemsHtml}
</div>
</div>
<div class="links">
<a href="http://127.0.0.1:9920">idea-loop viewer</a> ·
<a href="http://127.0.0.1:9762">morning-review</a> ·
<a href="https://leads.venturaclaw.com">VCL</a> ·
<a href="https://novasuede.com">novasuede</a> ·
<a href="/api/state">JSON state</a>
</div>
</body></html>`;
}
// --------------------------------- Cache + background refresh ---------------------------------
// Each source has a TTL. Page renders read cache instantly (snappy). A background
// loop refreshes stale entries every REFRESH_MS so the page never blocks on slow ops.
const REFRESH_MS = 30_000;
const cache = {
idea: { value: null, ts: 0, ttl: 5_000 }, // cheap (file reads), short TTL
codex3: { value: null, ts: 0, ttl: 30_000 }, // git show calls, 30s
morning: { value: null, ts: 0, ttl: 60_000 }, // 6 scanners on the other end, 1 min
launchd: { value: null, ts: 0, ttl: 15_000 }, // launchctl list, fast
pm2: { value: null, ts: 0, ttl: 30_000 } // pm2 jlist, slow, 30s
};
async function refresh(key, fn) {
try {
const v = await fn();
cache[key].value = v;
cache[key].ts = Date.now();
} catch (e) {
console.error(`[cache:${key}] refresh error:`, e.message);
}
}
async function refreshAll() {
await Promise.all([
refresh('idea', async () => getIdeaLoopState()),
refresh('codex3', async () => getCodex3WayState()),
refresh('morning', async () => await getMorningReviewState()),
refresh('launchd', async () => getLaunchdState()),
refresh('pm2', async () => getPm2State())
]);
}
// Read cache (refresh in background if stale; never block the read).
function readCache(key, fallback) {
const c = cache[key];
if (!c.value) return fallback;
return c.value;
}
// Kick off initial refresh + interval
refreshAll().catch(() => {});
setInterval(() => { refreshAll().catch(() => {}); }, REFRESH_MS);
// --------------------------------- Server ---------------------------------
const server = http.createServer(async (req, res) => {
if (req.url === '/healthz') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({
ok: true, ts: new Date().toISOString(),
cache_ages_s: Object.fromEntries(Object.entries(cache).map(([k, v]) => [k, v.ts ? Math.round((Date.now() - v.ts) / 1000) : -1]))
}));
}
try {
const idea = readCache('idea', { accepted: 0, rejected: 0, decisions: 0, top: [], last_tick_age: '—' });
const codex3 = readCache('codex3', []);
const morning = readCache('morning', { total: 0, by_source: {} });
const launchd = readCache('launchd', { total: 0, problems: 0, problem_jobs: [], ai_loop_jobs: [] });
const pm2 = readCache('pm2', { total: 0, online: 0, errored: 0, ai_loop: [] });
if (req.url === '/api/state') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ idea, codex3, morning, launchd, pm2 }, null, 2));
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(renderPage({ idea, codex3, morning, launchd, pm2 }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('error: ' + e.message);
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`loops-dashboard → http://127.0.0.1:${PORT}`);
});