[object Object]

← back to Harness Viewer

harness cockpit: engines + live lanes + tickets + gate queue + ledger

b3648139a359e6268b832adb4dd18c0ca2352433 · 2026-09-22 12:00:17 -0700 · Steve

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2KFGQK7p7ahEzug7HBMZF

Files touched

Diff

commit b3648139a359e6268b832adb4dd18c0ca2352433
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Sep 22 12:00:17 2026 -0700

    harness cockpit: engines + live lanes + tickets + gate queue + ledger
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01D2KFGQK7p7ahEzug7HBMZF
---
 .gitignore |   4 ++
 index.html | 170 ++++++++++++++++++++++++++++++++++++++++++++++++
 server.js  | 216 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 390 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1bcdbdc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.env*
+*.log
+.DS_Store
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..925829d
--- /dev/null
+++ b/index.html
@@ -0,0 +1,170 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Harness Cockpit</title>
+<style>
+  :root{--bg:#0f1117;--panel:#171a22;--panel2:#1d212b;--line:#272c38;--fg:#e6e9ef;--dim:#8a92a6;--accent:#5aa9ff;--green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#58a6ff;--purple:#bc8cff;}
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,monospace}
+  a{color:var(--accent);text-decoration:none}
+  header{position:sticky;top:0;z-index:10;background:linear-gradient(180deg,#12141b,#0f1117);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;flex-wrap:wrap;align-items:center;gap:14px}
+  header h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.3px}
+  .counts{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:12px}
+  .pill{background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:3px 10px;color:var(--dim)}
+  .pill b{color:var(--fg)}
+  .grow{flex:1}
+  .updated{color:var(--dim);font-size:11px}
+  .chiprow{display:flex;flex-wrap:wrap;gap:8px;padding:12px 16px 4px}
+  .chip{display:flex;flex-direction:column;gap:2px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 12px;min-width:150px}
+  .chip .top{display:flex;align-items:center;gap:7px;font-weight:600}
+  .dot{width:9px;height:9px;border-radius:50%;flex:none}
+  .dot.up{background:var(--green);box-shadow:0 0 6px var(--green)}
+  .dot.down{background:var(--red);box-shadow:0 0 6px var(--red)}
+  .chip .ep{color:var(--dim);font-size:11px;word-break:break-all}
+  .chip .pt{color:var(--dim);font-size:10px}
+  .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:14px;padding:14px 16px 40px}
+  .panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;max-height:560px}
+  .panel h2{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);padding:10px 14px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px;background:var(--panel2)}
+  .panel h2 .n{color:var(--fg);background:#0f1117;border:1px solid var(--line);border-radius:10px;padding:1px 8px;font-size:11px}
+  .panel .body{overflow:auto;padding:4px 0}
+  .row{padding:7px 14px;border-bottom:1px solid #1e222c}
+  .row:last-child{border-bottom:none}
+  .row .l1{display:flex;align-items:center;gap:8px;justify-content:space-between}
+  .row .name{font-weight:600;color:var(--fg)}
+  .row .meta{color:var(--dim);font-size:11px;white-space:nowrap}
+  .row .txt{color:#c3c9d6;font-size:12px;margin-top:2px;word-break:break-word}
+  .badge{font-size:10px;font-weight:700;border-radius:5px;padding:1px 7px;letter-spacing:.4px}
+  .b-DOING{background:#153a5b;color:#79c0ff}
+  .b-BLOCKED{background:#5b2a15;color:#ffa657}
+  .b-DONE{background:#183a1f;color:#56d364}
+  .ages{display:flex;gap:6px;margin-left:auto}
+  .age{font-size:10px;border-radius:10px;padding:1px 8px;border:1px solid var(--line);color:var(--dim)}
+  .age.hot{color:#ffa657;border-color:#5b2a15}
+  .memo-link{cursor:pointer}
+  .memo-link:hover{color:var(--accent)}
+  .undo{color:#7d8598;font-size:11px;margin-top:2px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}
+  button{background:var(--panel2);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer}
+  button:hover{border-color:var(--accent)}
+  pre{white-space:pre-wrap;word-break:break-word;margin:0;padding:10px 14px;font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#c3c9d6}
+  .modal{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:50;padding:20px}
+  .modal.show{display:flex}
+  .modal .box{background:var(--panel);border:1px solid var(--line);border-radius:10px;max-width:820px;width:100%;max-height:80vh;display:flex;flex-direction:column}
+  .modal .box h3{margin:0;padding:12px 16px;border-bottom:1px solid var(--line);font-size:13px;display:flex;justify-content:space-between;align-items:center}
+  .modal .box .c{overflow:auto}
+  .empty{color:var(--dim);padding:14px;font-size:12px}
+  .spin{color:var(--dim)}
+</style>
+</head>
+<body>
+<header>
+  <h1>🛰 Harness Cockpit</h1>
+  <div class="counts" id="counts"><span class="spin">loading…</span></div>
+  <div class="grow"></div>
+  <div class="updated" id="updated"></div>
+</header>
+
+<div class="chiprow" id="chips"></div>
+
+<div class="grid">
+  <section class="panel"><h2>Live Lanes <span class="n" id="n-lanes">0</span><span class="grow" style="flex:1"></span></h2><div class="body" id="p-lanes"></div></section>
+  <section class="panel"><h2>Tickets <span class="n" id="n-tickets">0</span></h2><div class="body" id="p-tickets"></div></section>
+  <section class="panel"><h2>Gate Queue <span class="n" id="n-gate">0</span></h2><div class="body" id="p-gate"></div></section>
+  <section class="panel"><h2>Reversible Ledger <span class="n" id="n-ledger">0</span></h2><div class="body" id="p-ledger"></div></section>
+  <section class="panel" style="grid-column:1/-1">
+    <h2>Overnight Audit <span class="grow" style="flex:1"></span><button id="run-audit">Run audit</button></h2>
+    <div class="body"><pre id="p-audit" class="empty">On-demand — click "Run audit" to run audit-overnight-closes.mjs (proof-join of closes backed by evidence vs not). Not part of auto-refresh.</pre></div>
+  </section>
+</div>
+
+<div class="modal" id="modal"><div class="box"><h3><span id="m-title">memo</span><button onclick="closeModal()">close</button></h3><div class="c"><pre id="m-body"></pre></div></div></div>
+
+<script>
+const $ = (id) => document.getElementById(id);
+const esc = (s) => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+function ago(iso){ if(!iso) return ''; const d=Date.now()-Date.parse(iso); if(isNaN(d)) return ''; const m=Math.floor(d/60000); if(m<1) return 'just now'; if(m<60) return m+'m ago'; const h=Math.floor(m/60); if(h<24) return h+'h '+(m%60)+'m ago'; return Math.floor(h/24)+'d ago'; }
+
+async function load(){
+  let s;
+  try{ const r=await fetch('/api/state'); s=await r.json(); }
+  catch(e){ $('counts').innerHTML='<span class="pill">fetch failed — retrying</span>'; return; }
+  render(s);
+}
+
+function render(s){
+  // header counts
+  $('counts').innerHTML =
+    pill('engines','<b>'+s.enginesUp+'</b>/'+s.enginesTotal+' up') +
+    pill('active lanes','<b>'+(s.lanes?s.lanes.length:0)+'</b>') +
+    pill('tickets DOING','<b>'+(s.doing||0)+'</b>') +
+    pill('gated','<b>'+(s.gate?s.gate.total:0)+'</b>');
+  $('updated').textContent = 'updated '+ago(s.ts);
+
+  // engine chips
+  $('chips').innerHTML = (s.engines||[]).map(e=>
+    '<div class="chip"><div class="top"><span class="dot '+(e.up?'up':'down')+'"></span>'+esc(e.label)+'</div>'+
+    '<div class="ep">'+esc(e.endpoint)+'</div><div class="pt">probed '+ago(e.ts)+'</div></div>').join('');
+
+  // lanes
+  $('n-lanes').textContent = (s.lanes||[]).length;
+  $('p-lanes').innerHTML = (s.lanes&&s.lanes.length) ? s.lanes.map(l=>
+    '<div class="row"><div class="l1"><span class="name">'+esc(l.agent)+'</span>'+
+    '<span class="meta">'+l.actions+' act · '+ago(l.ts)+'</span></div>'+
+    '<div class="txt">'+esc(l.lastText)+'</div></div>').join('')
+    : '<div class="empty">No agent activity in the last 30 minutes.</div>';
+
+  // tickets
+  $('n-tickets').textContent = (s.tickets||[]).length;
+  $('p-tickets').innerHTML = (s.tickets&&s.tickets.length) ? s.tickets.map(t=>
+    '<div class="row"><div class="l1"><span class="name">'+esc(t.id)+' <span class="badge b-'+esc(t.state)+'">'+esc(t.state)+'</span></span>'+
+    '<span class="meta">'+esc(t.agent||'—')+' · '+ago(t.ts)+'</span></div>'+
+    '<div class="txt">'+esc(t.lastText)+'</div></div>').join('')
+    : '<div class="empty">No recent ticket activity.</div>';
+
+  // gate queue
+  const g=s.gate||{total:0,newest:[]};
+  $('n-gate').textContent = g.total;
+  let ghdr='<div class="row"><div class="l1"><span class="name">'+g.total+' pending memos</span>'+
+    '<span class="ages"><span class="age'+(g.p3?' hot':'')+'">&gt;3h: '+g.p3+'</span>'+
+    '<span class="age'+(g.p24?' hot':'')+'">&gt;24h: '+g.p24+'</span>'+
+    '<span class="age'+(g.p7d?' hot':'')+'">&gt;7d: '+g.p7d+'</span></span></div></div>';
+  const grows=(g.newest||[]).map(m=>
+    '<div class="row"><div class="l1"><span class="name memo-link" onclick="openMemo(\''+esc(m.f).replace(/'/g,"\\'")+'\')">'+esc(m.f)+'</span>'+
+    '<span class="meta">'+ago(m.ts)+'</span></div></div>').join('');
+  $('p-gate').innerHTML = ghdr + (grows || '<div class="empty">Queue empty.</div>');
+
+  // ledger
+  $('n-ledger').textContent = (s.ledger||[]).length;
+  $('p-ledger').innerHTML = (s.ledger&&s.ledger.length) ? s.ledger.map(r=>
+    '<div class="row"><div class="l1"><span class="name">'+esc(r.agent||'—')+'</span>'+
+    '<span class="meta">'+esc(r.ticket||'')+' · '+ago(r.ts)+'</span></div>'+
+    '<div class="txt">'+esc(r.action)+'</div>'+
+    (r.undo_cmd?'<div class="undo">↩ '+esc(r.undo_cmd)+'</div>':'')+'</div>').join('')
+    : '<div class="empty">No ledger rows.</div>';
+}
+function pill(k,v){ return '<span class="pill">'+k+' '+v+'</span>'; }
+
+async function openMemo(f){
+  $('m-title').textContent=f; $('m-body').textContent='loading…'; $('modal').classList.add('show');
+  try{ const r=await fetch('/api/memo?f='+encodeURIComponent(f)); const j=await r.json();
+    $('m-body').textContent = j.error ? ('error: '+j.error) : (j.lines||'(empty)'); }
+  catch(e){ $('m-body').textContent='fetch failed'; }
+}
+function closeModal(){ $('modal').classList.remove('show'); }
+$('modal').addEventListener('click',e=>{ if(e.target.id==='modal') closeModal(); });
+
+$('run-audit').addEventListener('click', async ()=>{
+  const btn=$('run-audit'); btn.disabled=true; btn.textContent='running…';
+  $('p-audit').className=''; $('p-audit').textContent='running audit-overnight-closes.mjs (up to 30s)…';
+  try{ const r=await fetch('/api/audit'); const j=await r.json();
+    $('p-audit').textContent = j.error ? ('error: '+j.error) : (j.output||'(no output)'); }
+  catch(e){ $('p-audit').textContent='fetch failed'; }
+  finally{ btn.disabled=false; btn.textContent='Run audit'; }
+});
+
+load();
+setInterval(load, 10000); // auto-refresh 10s (audit excluded)
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..aea1749
--- /dev/null
+++ b/server.js
@@ -0,0 +1,216 @@
+#!/usr/bin/env node
+// Harness Cockpit — live viewer for Steve's agent-orchestration harness.
+// Basic-auth admin/DW2024!  ·  dep-free (Node built-in http)  ·  live-scan + ~6s cache.
+// Mirrors the architecture of ~/Projects/stack-map-viewer/server.js.
+const http = require('http');
+const https = require('https');
+const { execSync } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const HOME = os.homedir();
+const EVENTS = path.join(HOME, '.claude/tickets/events.jsonl');
+const PENDING = path.join(HOME, '.claude/yolo-queue/pending-approval');
+const LEDGER = path.join(HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl');
+const AUDIT_SCRIPT = path.join(HOME, 'Projects/ticket-system/scripts/audit-overnight-closes.mjs');
+const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
+const PORT = process.env.PORT || 9770;
+
+// ── safe shell: 6s timeout, graceful empty on any failure ────────────────────
+const sh = (c, ms = 6000) => { try { return execSync(c, { encoding: 'utf8', timeout: ms, maxBuffer: 32 * 1024 * 1024 }); } catch { return ''; } };
+const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
+const trunc = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
+const shortId = (id) => { const m = String(id || '').match(/^(TK-\d+)/); return m ? m[1] : String(id || ''); };
+
+// Read the last N lines of a big file WITHOUT loading it whole. Never read
+// events.jsonl (57MB) or ledger (86k+ lines) in full — tail + per-line parse.
+function tailJSON(file, n) {
+  const raw = sh(`tail -n ${n} ${shq(file)} 2>/dev/null`);
+  if (!raw) return [];
+  const out = [];
+  for (const ln of raw.split('\n')) {
+    const t = ln.trim();
+    if (!t) continue;
+    try { out.push(JSON.parse(t)); } catch { /* skip malformed line */ }
+  }
+  return out;
+}
+
+// ── ENGINES: probe the "full AI setup" (2s each) ─────────────────────────────
+function probeHTTP(url, timeoutMs = 2000) {
+  return new Promise((resolve) => {
+    let done = false;
+    const finish = (up) => { if (!done) { done = true; resolve(up); } };
+    try {
+      const lib = url.startsWith('https') ? https : http;
+      const req = lib.get(url, (r) => { finish(r.statusCode === 200); r.resume(); });
+      req.on('error', () => finish(false));
+      req.setTimeout(timeoutMs, () => { req.destroy(); finish(false); });
+    } catch { finish(false); }
+  });
+}
+const ENGINE_DEFS = [
+  { key: 'exo', label: 'exo ring', kind: 'http', endpoint: 'http://127.0.0.1:52415/' },
+  { key: 'openclaw', label: 'openclaw MLX', kind: 'http', endpoint: 'http://127.0.0.1:8000/v1/models' },
+  { key: 'gateway', label: 'ai-cluster gateway', kind: 'http', endpoint: 'http://127.0.0.1:8080/' },
+  { key: 'codex', label: 'codex CLI', kind: 'exec', endpoint: '~/.local/bin/codex', test: path.join(HOME, '.local/bin/codex') },
+  { key: 'askopenai', label: 'ask-openai', kind: 'exec', endpoint: '~/bin/ask-openai', test: path.join(HOME, 'bin/ask-openai') },
+];
+async function probeEngines() {
+  const now = new Date().toISOString();
+  const results = await Promise.all(ENGINE_DEFS.map(async (e) => {
+    let up = false;
+    if (e.kind === 'http') up = await probeHTTP(e.endpoint, 2000);
+    else { try { fs.accessSync(e.test, fs.constants.X_OK); up = true; } catch { up = false; } }
+    return { key: e.key, label: e.label, endpoint: e.endpoint, up, ts: now };
+  }));
+  return results;
+}
+
+// ── LIVE LANES + TICKETS: from a single tail of events.jsonl ─────────────────
+function analyzeEvents() {
+  const ev = tailJSON(EVENTS, 4000);
+  const now = Date.now();
+  const WINDOW = 30 * 60 * 1000; // 30 min
+  const lanes = new Map();   // agent -> {agent, actions, lastText, lastTs}
+  const tickets = new Map(); // shortId -> {id, agent, lastText, ts, state}
+
+  for (const e of ev) {
+    if (!e || !e.ts) continue;
+    const t = Date.parse(e.ts);
+    if (isNaN(t)) continue;
+    const text = e.text || (e.status ? '[' + e.status + ']' : e.type || '');
+    // --- lanes: last 30 min, grouped by agent ---
+    if (e.agent && now - t <= WINDOW) {
+      let ln = lanes.get(e.agent);
+      if (!ln) { ln = { agent: e.agent, actions: 0, lastText: '', lastTs: 0 }; lanes.set(e.agent, ln); }
+      ln.actions++;
+      if (t >= ln.lastTs) { ln.lastTs = t; ln.lastText = trunc(text, 140); }
+    }
+    // --- tickets: reduce by short id, derive state ---
+    if (e.id) {
+      const sid = shortId(e.id);
+      let tk = tickets.get(sid);
+      if (!tk) { tk = { id: sid, fullId: e.id, agent: e.agent || '', lastText: '', ts: 0, state: 'DOING' }; tickets.set(sid, tk); }
+      let state = tk.state;
+      if (e.type === 'status') { const s = (e.status || '').toLowerCase(); if (s === 'done') state = 'DONE'; else if (s === 'blocked') state = 'BLOCKED'; else state = 'DOING'; }
+      else if (e.type === 'new' || e.type === 'take') state = 'DOING';
+      else if (e.type === 'comment' || e.type === 'action' || e.type === 'note' || e.type === 'log') state = 'DOING';
+      if (t >= tk.ts) { tk.ts = t; tk.lastText = trunc(text, 160); tk.state = state; if (e.agent) tk.agent = e.agent; }
+      else if (e.type === 'status') { tk.state = state; } // status is authoritative even if slightly older
+    }
+  }
+
+  const laneArr = [...lanes.values()].sort((a, b) => b.lastTs - a.lastTs)
+    .map(l => ({ agent: l.agent, actions: l.actions, lastText: l.lastText, ts: new Date(l.lastTs).toISOString() }));
+  const tkArr = [...tickets.values()].sort((a, b) => b.ts - a.ts).slice(0, 25)
+    .map(t => ({ id: t.id, agent: t.agent, lastText: t.lastText, ts: new Date(t.ts).toISOString(), state: t.state }));
+  const doing = [...tickets.values()].filter(t => t.state === 'DOING').length;
+  return { lanes: laneArr, tickets: tkArr, doing };
+}
+
+// ── GATE QUEUE ───────────────────────────────────────────────────────────────
+function scanGateQueue() {
+  let files = [];
+  try { files = fs.readdirSync(PENDING).filter(f => f.endsWith('.md')); } catch { return { total: 0, p3: 0, p24: 0, p7d: 0, newest: [] }; }
+  const now = Date.now();
+  let p3 = 0, p24 = 0, p7d = 0;
+  const withMtime = [];
+  for (const f of files) {
+    let m = 0;
+    try { m = fs.statSync(path.join(PENDING, f)).mtimeMs; } catch { continue; }
+    const h = (now - m) / 3.6e6;
+    if (h >= 3) p3++;
+    if (h >= 24) p24++;
+    if (h >= 168) p7d++;
+    withMtime.push({ f, m });
+  }
+  withMtime.sort((a, b) => b.m - a.m);
+  const newest = withMtime.slice(0, 25).map(x => ({ f: x.f, ts: new Date(x.m).toISOString() }));
+  return { total: files.length, p3, p24, p7d, newest };
+}
+
+// ── REVERSIBLE LEDGER ────────────────────────────────────────────────────────
+function scanLedger() {
+  const rows = tailJSON(LEDGER, 40);
+  return rows.reverse().map(r => ({
+    ts: r.ts || '', agent: r.agent || '', ticket: r.ticket || '',
+    action: trunc(r.action, 220), undo_cmd: trunc(r.undo_cmd, 260),
+  }));
+}
+
+// ── aggregate state (all panels except on-demand audit), ~6s cache ───────────
+let CACHE = null, CACHE_TS = 0;
+async function buildState() {
+  if (CACHE && Date.now() - CACHE_TS < 6000) return CACHE;
+  const engines = await probeEngines();
+  const ev = analyzeEvents();
+  const gate = scanGateQueue();
+  const ledger = scanLedger();
+  const enginesUp = engines.filter(e => e.up).length;
+  CACHE = {
+    ts: new Date().toISOString(),
+    engines, enginesUp, enginesTotal: engines.length,
+    lanes: ev.lanes, tickets: ev.tickets, doing: ev.doing,
+    gate, ledger,
+    hasAudit: (() => { try { fs.accessSync(AUDIT_SCRIPT); return true; } catch { return false; } })(),
+  };
+  CACHE_TS = Date.now();
+  return CACHE;
+}
+
+// ── memo reader (path-jailed to PENDING) ─────────────────────────────────────
+function readMemo(name) {
+  if (!name || typeof name !== 'string') return { error: 'no file' };
+  const resolved = path.resolve(PENDING, name);
+  const jail = path.resolve(PENDING) + path.sep;
+  if (!(resolved + path.sep).startsWith(jail) && resolved !== path.resolve(PENDING)) return { error: 'path escape rejected' };
+  if (!resolved.startsWith(jail)) return { error: 'path escape rejected' };
+  if (!resolved.endsWith('.md')) return { error: 'not a memo' };
+  let txt = '';
+  try { txt = fs.readFileSync(resolved, 'utf8'); } catch { return { error: 'not found' }; }
+  const lines = txt.split('\n').slice(0, 60).join('\n');
+  return { file: path.basename(resolved), lines };
+}
+
+// ── audit (on-demand only) ───────────────────────────────────────────────────
+function runAudit() {
+  try { fs.accessSync(AUDIT_SCRIPT); } catch { return { error: 'audit script not found', script: AUDIT_SCRIPT }; }
+  const out = sh(`node ${shq(AUDIT_SCRIPT)} 2>&1`, 30000);
+  return { script: AUDIT_SCRIPT, output: out || '(no output / timed out)' };
+}
+
+const PAGE = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
+
+const server = http.createServer(async (req, res) => {
+  if (req.headers.authorization !== AUTH) {
+    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="harness-cockpit"' });
+    return res.end('auth required');
+  }
+  const u = new URL(req.url, 'http://x');
+  try {
+    if (u.pathname === '/api/state') {
+      const s = await buildState();
+      res.writeHead(200, { 'content-type': 'application/json' });
+      return res.end(JSON.stringify(s));
+    }
+    if (u.pathname === '/api/memo') {
+      const out = readMemo(u.searchParams.get('f') || '');
+      res.writeHead(out.error ? 400 : 200, { 'content-type': 'application/json' });
+      return res.end(JSON.stringify(out));
+    }
+    if (u.pathname === '/api/audit') {
+      const out = runAudit();
+      res.writeHead(200, { 'content-type': 'application/json' });
+      return res.end(JSON.stringify(out));
+    }
+  } catch (e) {
+    res.writeHead(500, { 'content-type': 'application/json' });
+    return res.end(JSON.stringify({ error: String(e && e.message || e) }));
+  }
+  res.writeHead(200, { 'content-type': 'text/html' });
+  res.end(PAGE);
+});
+
+server.listen(PORT, () => console.log(`harness cockpit → http://127.0.0.1:${PORT}  (admin/DW2024!)`));

(oldest)  ·  back to Harness Viewer  ·  TK-12268: add creds-in-URL fetch guard (fleet pattern) to ga 45a5532 →