[object Object]

← back to Factory Floor

feat: Big Red tile w/ spinning brain + model picker (Ollama + claude + codex)

282ec2970ebe2a6a72876e0b8f8217dcb6d64686 · 2026-05-10 22:23:04 -0700 · Steve Abrams

Files touched

Diff

commit 282ec2970ebe2a6a72876e0b8f8217dcb6d64686
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 22:23:04 2026 -0700

    feat: Big Red tile w/ spinning brain + model picker (Ollama + claude + codex)
---
 public/index.html | 156 ++++++++++++++++++++++++++++++++++--------
 server.js         | 197 ++++++++++++++++++++++++++++++++++++------------------
 services.json     |   1 +
 3 files changed, 262 insertions(+), 92 deletions(-)

diff --git a/public/index.html b/public/index.html
index 8ddf39f..537c7fb 100644
--- a/public/index.html
+++ b/public/index.html
@@ -6,7 +6,7 @@
 <style>
   :root {
     --bg:#0a0a0c; --panel:#121217; --panel2:#181820; --rule:#26262e; --ink:#eee6d8; --mute:#8c8680; --gold:#c9a96a;
-    --ok:#7ad27a; --warn:#e0b25a; --bad:#e26d6d; --link:#9bd1ff;
+    --ok:#7ad27a; --warn:#e0b25a; --bad:#e26d6d; --link:#9bd1ff; --red:#e94545; --redglow:#ff6b6b;
     --mono:'SF Mono','Menlo',monospace; --sans:-apple-system,'SF Pro Text','Inter',sans-serif; --serif:'SF Pro Display','Cormorant Garamond',serif;
   }
   *{box-sizing:border-box}
@@ -45,15 +45,34 @@
   td.mono{font-family:var(--mono)}
   td.r{text-align:right;font-family:var(--mono);color:var(--mute)}
   td.s{color:var(--ok)}
-  td.s.stopped{color:var(--bad)}
-  td.s.errored{color:var(--bad)}
+  td.s.stopped,td.s.errored{color:var(--bad)}
   .group-head{font:600 10px var(--sans);text-transform:uppercase;letter-spacing:.14em;color:var(--mute);margin:14px 0 6px}
   .group-head:first-child{margin-top:0}
   .empty{color:var(--mute);font-style:italic;font-size:13px}
   .footer{padding:18px 28px;color:var(--mute);font-size:11px;font-family:var(--mono);text-align:center;border-top:1px solid var(--rule)}
-  .ratio{font-family:var(--mono);color:var(--mute);font-size:11px}
   .bar{height:4px;background:var(--rule);border-radius:2px;overflow:hidden;margin-top:6px}
   .bar>span{display:block;height:100%;background:var(--gold)}
+
+  /* BIG RED tile */
+  .bigred{position:relative;background:linear-gradient(180deg,#1a0e0e 0%,#160a0a 100%);border:1px solid #3a1818}
+  .bigred h2{color:var(--redglow)}
+  .bigred-stage{display:flex;align-items:center;gap:18px;padding:12px 0}
+  .brain{width:88px;height:88px;flex-shrink:0;position:relative;display:flex;align-items:center;justify-content:center}
+  .brain svg{width:88px;height:88px;display:block;filter:drop-shadow(0 0 12px rgba(233,69,69,.4))}
+  .brain.thinking svg{animation:brainspin 1.6s linear infinite}
+  .brain.thinking::before{content:'';position:absolute;inset:-8px;border:2px solid rgba(255,107,107,.4);border-top-color:transparent;border-radius:50%;animation:brainspin 0.9s linear infinite}
+  .brain.thinking::after{content:'';position:absolute;inset:-18px;border:1px solid rgba(255,107,107,.18);border-bottom-color:transparent;border-radius:50%;animation:brainspin 2.2s linear infinite reverse}
+  @keyframes brainspin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
+  .brain-info{flex:1}
+  .brain-state{font:600 11px var(--mono);text-transform:uppercase;letter-spacing:.18em;color:var(--mute);margin-bottom:4px}
+  .brain-state.live{color:var(--redglow);animation:pulse 1.2s infinite}
+  .brain-meta{font-family:var(--mono);font-size:11px;color:var(--mute);margin-bottom:8px}
+  .model-row{display:flex;gap:8px;align-items:center;margin-top:6px}
+  .model-row label{font:600 10px var(--mono);text-transform:uppercase;letter-spacing:.1em;color:var(--mute)}
+  .model-row select{flex:1;background:#0e0708;border:1px solid #3a1818;color:var(--ink);padding:6px 8px;border-radius:3px;font-family:var(--mono);font-size:12px}
+  .model-row select:focus{outline:none;border-color:var(--redglow)}
+  .model-row .saved{font:600 10px var(--mono);color:var(--ok);opacity:0;transition:opacity .2s}
+  .model-row .saved.show{opacity:1}
 </style>
 </head>
 <body>
@@ -62,14 +81,30 @@
   <div class="meta"><span class="dot"></span><span id="meta">connecting…</span></div>
 </header>
 <main id="main">
-  <div class="card wide"><h2>Host</h2><div id="host">…</div></div>
+  <div class="card wide"><h2>booting…</h2></div>
 </main>
-<div class="footer">~/Projects/factory-floor · auto-refresh every 10s · POST /api/refresh to force · serving on :9700</div>
+<div class="footer">~/Projects/factory-floor · auto-refresh every 8s · POST /api/refresh to force · serving on :9936</div>
 
 <script>
 const $ = (id) => document.getElementById(id);
+
+const BRAIN_SVG = `
+<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <radialGradient id="rg" cx="50%" cy="45%" r="55%">
+      <stop offset="0%" stop-color="#ff7a7a"/>
+      <stop offset="65%" stop-color="#c92626"/>
+      <stop offset="100%" stop-color="#5b0a0a"/>
+    </radialGradient>
+  </defs>
+  <path fill="url(#rg)" stroke="#3a0808" stroke-width="1.2"
+    d="M50 14c-9 0-16 5-18 12-7 1-12 7-12 14 0 4 2 8 5 11-1 2-1 4-1 6 0 7 5 13 12 14 1 7 8 13 14 13s12-3 14-9c2 1 4 1 6 1 7 0 13-5 13-12 0-2 0-3-1-5 4-2 6-7 6-12 0-7-5-13-12-14-2-7-9-12-18-12z"/>
+  <g stroke="#3a0808" stroke-width="1.5" fill="none" stroke-linecap="round">
+    <path d="M30 38c4-2 8-2 12 0M58 38c4-2 8-2 12 0M28 56c5 1 10-1 14-4M58 52c4 3 9 5 14 4M40 70c4 2 12 2 16 0M40 26c4 2 12 2 16 0M50 14v72"/>
+  </g>
+</svg>`;
+
 const fmt = {
-  ms: (n) => n == null ? '—' : `${n}ms`,
   age: (s) => {
     if (s == null) return '—';
     if (s < 60) return `${s}s`;
@@ -80,33 +115,97 @@ const fmt = {
   pill: (ok) => `<span class="pill ${ok?'ok':'bad'}">${ok?'UP':'DOWN'}</span>`,
 };
 
+let LAST_MODEL = null;
+
+async function setBigRedModel(model) {
+  try {
+    const r = await fetch('/api/bigred/model', {
+      method: 'POST', headers: {'Content-Type':'application/json'},
+      body: JSON.stringify({ model })
+    });
+    if (r.ok) {
+      const el = document.querySelector('.model-row .saved');
+      if (el) { el.classList.add('show'); setTimeout(() => el.classList.remove('show'), 1500); }
+      LAST_MODEL = model;
+    }
+  } catch (e) { console.error(e); }
+}
+
+function bigRedCard(state) {
+  const br = state.bigRed || {};
+  const ollama = (state.ollama?.installed || []).map(m => m.name);
+  const allModels = [
+    ...ollama,
+    'claude:haiku', 'claude:sonnet', 'claude:opus',
+    'codex',
+  ];
+  const current = LAST_MODEL || 'claude:haiku';
+  const thinkingClass = br.thinking ? 'thinking' : '';
+  const stateLabel = br.thinking ? 'THINKING' : (br.error ? 'OFFLINE' : 'IDLE');
+  const stateClass = br.thinking ? 'live' : '';
+  return `
+  <div class="card third bigred">
+    <h2>Big Red <span class="count">:9935</span></h2>
+    <div class="bigred-stage">
+      <div class="brain ${thinkingClass}">${BRAIN_SVG}</div>
+      <div class="brain-info">
+        <div class="brain-state ${stateClass}">${stateLabel}</div>
+        <div class="brain-meta">${br.big_red_calls ?? 0} active call(s) · ${br.claude_procs ?? 0} claude proc(s)</div>
+        <div class="model-row">
+          <label>brain</label>
+          <select id="brModel" onchange="setBigRedModel(this.value)">
+            ${allModels.map(m => `<option ${m===current?'selected':''} value="${m}">${m}</option>`).join('')}
+          </select>
+          <span class="saved">saved ✓</span>
+        </div>
+      </div>
+    </div>
+  </div>`;
+}
+
+async function loadCurrentModel() {
+  try {
+    const r = await fetch('/api/bigred/model').then(r => r.json());
+    if (r.model) LAST_MODEL = r.model;
+  } catch {}
+}
+
 async function tick() {
   let s;
   try { s = await fetch('/api/state').then(r => r.json()); }
   catch (e) { $('meta').textContent = 'fetch failed: ' + e.message; return; }
 
-  $('meta').innerHTML = `polled ${new Date(s.generated_at).toLocaleTimeString()} · ${s.poll_ms}ms · pm2 ${s.pm2.items?.length||0} · ollama ${s.ollama.installed?.length||0}`;
+  if (!s.generated_at) {
+    $('meta').textContent = 'first poll in flight…';
+    return;
+  }
+
+  $('meta').innerHTML = `polled ${new Date(s.generated_at).toLocaleTimeString()} · ${s.poll_ms}ms · pm2 ${s.pm2?.items?.length||0} · ollama ${s.ollama?.installed?.length||0} · load ${s.host?.load?.[0]??'—'}`;
 
   const groups = {};
   for (const svc of (s.services || [])) (groups[svc.group] ||= []).push(svc);
 
   let html = '';
+
   // HOST
   const h = s.host || {};
   const memUsed = (h.mem_gb_total - h.mem_gb_free).toFixed(1);
-  const memPct = ((h.mem_gb_total - h.mem_gb_free) / h.mem_gb_total) * 100;
+  const memPct = Math.min(100, ((h.mem_gb_total - h.mem_gb_free) / h.mem_gb_total) * 100);
+  const loadColor = h.load?.[0] > 100 ? 'var(--bad)' : (h.load?.[0] > 20 ? 'var(--warn)' : 'var(--ok)');
   html += `<div class="card third"><h2>Host</h2>
-    <div class="row"><span class="l">${h.host}</span><span class="r">${h.cores} cores</span></div>
-    <div class="row"><span class="l">Load avg</span><span class="r">${h.load?.join(' / ')}</span></div>
+    <div class="row"><span class="l">${h.host || '—'}</span><span class="r">${h.cores} cores</span></div>
+    <div class="row"><span class="l">Load (1m)</span><span class="r" style="color:${loadColor}">${h.load?.[0] ?? '—'}</span></div>
     <div class="row"><span class="l">Memory</span><span class="r">${memUsed} / ${h.mem_gb_total} GB</span></div>
     <div class="bar"><span style="width:${memPct}%"></span></div>
     <div class="row"><span class="l">Uptime</span><span class="r">${h.uptime_h}h</span></div>
   </div>`;
 
-  // OLLAMA / INFERENCE BRAINS
+  // BIG RED — featured tile
+  html += bigRedCard(s);
+
+  // INFERENCE BRAINS
   const ol = s.ollama || {};
-  const inf = (groups['inference'] || []);
-  html += `<div class="card third"><h2>Inference brains <span class="count">${ol.installed?.length||0} installed · ${ol.loaded?.length||0} loaded</span></h2>
+  html += `<div class="card third"><h2>Inference brains <span class="count">${ol.installed?.length||0} on disk · ${ol.loaded?.length||0} hot</span></h2>
     <div class="tiles">${(ol.installed || []).map(m => {
       const loaded = (ol.loaded || []).some(L => L.name === m.name);
       return `<div class="tile">
@@ -116,14 +215,7 @@ async function tick() {
     }).join('') || '<div class="empty">no models</div>'}</div>
   </div>`;
 
-  // GIT ACTIVITY
-  html += `<div class="card third"><h2>Recent commits <span class="count">across ~/Projects</span></h2>
-    <div style="max-height:280px;overflow-y:auto">${(s.git || []).slice(0,12).map(g =>
-      `<div class="row"><span class="l" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:55%">${g.project}: <span style="color:var(--ink)">${g.msg.slice(0,50)}</span></span><span class="r">${fmt.age(g.age_s)}</span></div>`
-    ).join('') || '<div class="empty">no repos</div>'}</div>
-  </div>`;
-
-  // SERVICES — grouped tiles
+  // SERVICES
   const groupOrder = ['factory-floor', 'apps', 'factories', 'comms', 'inference'];
   html += `<div class="card two-thirds"><h2>Services <span class="count">live HTTP check</span></h2>`;
   for (const g of groupOrder) {
@@ -138,16 +230,26 @@ async function tick() {
   }
   html += `</div>`;
 
+  // GIT
+  html += `<div class="card third"><h2>Recent commits <span class="count">across ~/Projects</span></h2>
+    <div style="max-height:280px;overflow-y:auto">${(s.git || []).slice(0,12).map(g =>
+      `<div class="row"><span class="l" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%">${g.project}: <span style="color:var(--ink)">${(g.msg||'').slice(0,50)}</span></span><span class="r">${fmt.age(g.age_s)}</span></div>`
+    ).join('') || '<div class="empty">no repos</div>'}</div>
+  </div>`;
+
   // PM2
-  const pm2items = s.pm2.items || [];
-  html += `<div class="card third"><h2>PM2 agents <span class="count">${pm2items.length}</span></h2>
+  const pm2items = s.pm2?.items || [];
+  const onlinePm2 = pm2items.filter(p => p.status === 'online').length;
+  html += `<div class="card two-thirds"><h2>PM2 agents <span class="count">${onlinePm2}/${pm2items.length} online</span></h2>
     <div style="max-height:380px;overflow-y:auto"><table>
-      <thead><tr><th>name</th><th>state</th><th class="r">mem</th><th class="r">cpu</th></tr></thead>
+      <thead><tr><th>name</th><th>state</th><th>port</th><th class="r">mem</th><th class="r">cpu</th><th class="r">↻</th></tr></thead>
       <tbody>${pm2items.map(p => `<tr>
         <td class="mono">${p.name}</td>
         <td class="s ${p.status !== 'online' ? p.status : ''}">${p.status}</td>
+        <td class="mono" style="color:var(--mute)">${p.port || ''}</td>
         <td class="r">${p.mem_mb}M</td>
         <td class="r">${p.cpu}%</td>
+        <td class="r">${p.restarts}</td>
       </tr>`).join('')}</tbody>
     </table></div>
   </div>`;
@@ -155,8 +257,8 @@ async function tick() {
   $('main').innerHTML = html;
 }
 
-tick();
-setInterval(tick, 10000);
+loadCurrentModel().then(tick);
+setInterval(tick, 8000);
 </script>
 </body>
 </html>
diff --git a/server.js b/server.js
index 8992956..24e8227 100644
--- a/server.js
+++ b/server.js
@@ -1,6 +1,5 @@
-// factory-floor — single-page live coordinator for Steve's stack.
-// Polls pm2, Ollama, known service URLs, recent git commits. Caches in-memory, refreshes
-// every POLL_MS. Exposes GET /api/state and serves /public/.
+// factory-floor — single-page live coordinator. Lean variant: event-loop-safe,
+// concurrency-capped, lazy first poll, no shelling out during request handling.
 import http from 'node:http';
 import fs from 'node:fs';
 import path from 'node:path';
@@ -9,93 +8,113 @@ import { execFile } from 'node:child_process';
 import { fileURLToPath } from 'node:url';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const PORT = +(process.env.PORT || 9700);
-const POLL_MS = +(process.env.POLL_MS || 10_000);
+const PORT = +(process.env.PORT || 9936);
+const POLL_MS = +(process.env.POLL_MS || 15_000);
+const GIT_LIMIT = +(process.env.GIT_LIMIT || 40);   // cap repos polled per cycle
+const CONCURRENCY = +(process.env.CONCURRENCY || 4);
 const PROJECTS_DIR = path.join(os.homedir(), 'Projects');
 
 const SERVICES = JSON.parse(fs.readFileSync(path.join(__dirname, 'services.json'), 'utf8')).services;
 
-const run = (cmd, args, opts = {}) => new Promise((res) => {
-  execFile(cmd, args, { timeout: 30000, maxBuffer: 16 * 1024 * 1024, ...opts }, (err, stdout, stderr) =>
-    res({ err, stdout: stdout?.toString() || '', stderr: stderr?.toString() || '' })
-  );
-});
+function run(cmd, args, opts = {}) {
+  return new Promise((res) => {
+    execFile(cmd, args, { timeout: 15000, maxBuffer: 8 * 1024 * 1024, ...opts }, (err, stdout) =>
+      res({ err, stdout: stdout?.toString() || '' })
+    );
+  });
+}
+
+// concurrency-capped parallel map
+async function pmap(items, fn, limit = CONCURRENCY) {
+  const out = new Array(items.length);
+  let i = 0;
+  async function worker() {
+    while (true) {
+      const idx = i++;
+      if (idx >= items.length) return;
+      try { out[idx] = await fn(items[idx], idx); } catch (e) { out[idx] = { error: e.message }; }
+    }
+  }
+  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
+  return out;
+}
 
 async function pollPm2() {
-  const r = await run('pm2', ['jlist']);
-  if (r.err) return { error: r.err.message, items: [] };
+  const r = await run('pm2', ['jlist'], { timeout: 20000 });
+  if (r.err) return { error: r.err.code || r.err.message, items: [] };
   try {
     const j = JSON.parse(r.stdout);
-    const items = j.map(p => ({
-      name: p.name,
-      pid: p.pid,
-      status: p.pm2_env?.status,
-      cpu: p.monit?.cpu ?? 0,
-      mem_mb: Math.round((p.monit?.memory ?? 0) / 1024 / 1024),
-      uptime_s: p.pm2_env?.pm_uptime ? Math.round((Date.now() - p.pm2_env.pm_uptime) / 1000) : 0,
-      restarts: p.pm2_env?.restart_time ?? 0,
-      port: p.pm2_env?.PORT ?? p.pm2_env?.env?.PORT ?? null,
-    })).sort((a, b) => a.name.localeCompare(b.name));
-    return { items };
+    return {
+      items: j.map(p => ({
+        name: p.name,
+        status: p.pm2_env?.status,
+        cpu: p.monit?.cpu ?? 0,
+        mem_mb: Math.round((p.monit?.memory ?? 0) / 1024 / 1024),
+        uptime_s: p.pm2_env?.pm_uptime ? Math.round((Date.now() - p.pm2_env.pm_uptime) / 1000) : 0,
+        restarts: p.pm2_env?.restart_time ?? 0,
+        port: p.pm2_env?.PORT ?? p.pm2_env?.env?.PORT ?? null,
+      })).sort((a, b) => a.name.localeCompare(b.name)),
+    };
   } catch (e) { return { error: e.message, items: [] }; }
 }
 
 async function pollOllama() {
   try {
-    const [tags, ps] = await Promise.all([
-      fetch('http://127.0.0.1:11434/api/tags').then(r => r.json()).catch(() => null),
-      fetch('http://127.0.0.1:11434/api/ps').then(r => r.json()).catch(() => null),
+    const ctrl = AbortSignal.timeout(4000);
+    const [tagsR, psR] = await Promise.all([
+      fetch('http://127.0.0.1:11434/api/tags', { signal: ctrl }).then(r => r.json()).catch(() => null),
+      fetch('http://127.0.0.1:11434/api/ps', { signal: ctrl }).then(r => r.json()).catch(() => null),
     ]);
     return {
-      installed: (tags?.models || []).map(m => ({
+      installed: (tagsR?.models || []).map(m => ({
         name: m.name,
         size_gb: +(m.size / 1024 ** 3).toFixed(2),
         q: m.details?.quantization_level,
         params: m.details?.parameter_size,
       })),
-      loaded: (ps?.models || []).map(m => ({ name: m.name, size_mb: Math.round((m.size_vram || m.size) / 1024 / 1024) })),
+      loaded: (psR?.models || []).map(m => ({ name: m.name })),
     };
   } catch (e) { return { error: e.message, installed: [], loaded: [] }; }
 }
 
 async function pollServices() {
-  return Promise.all(SERVICES.map(async (s) => {
+  return pmap(SERVICES, async (s) => {
     const t0 = Date.now();
     try {
-      const ctrl = AbortSignal.timeout(3500);
-      const r = await fetch(s.url, { signal: ctrl, redirect: 'manual' });
-      const ms = Date.now() - t0;
-      let payload = null;
-      if (s.match) {
-        try {
-          const j = await r.clone().json();
-          const found = JSON.stringify(j).toLowerCase().includes(s.match.toLowerCase());
-          payload = { matched: found };
-        } catch {}
-      }
-      return { ...s, ok: r.status < 500, status: r.status, ms, payload };
+      const r = await fetch(s.url, { signal: AbortSignal.timeout(3000), redirect: 'manual' });
+      return { ...s, ok: r.status < 500, status: r.status, ms: Date.now() - t0 };
     } catch (e) {
-      return { ...s, ok: false, status: 0, ms: Date.now() - t0, error: e.code || e.message };
+      return { ...s, ok: false, status: 0, ms: Date.now() - t0, error: e.code || (e.name === 'TimeoutError' ? 'timeout' : 'err') };
     }
-  }));
+  }, 6);
 }
 
 async function pollGit() {
   const dirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })
     .filter(d => d.isDirectory() && !d.name.startsWith('.') && !d.name.startsWith('_'))
-    .slice(0, 200);
-  const rows = await Promise.all(dirs.map(async (d) => {
-    const repo = path.join(PROJECTS_DIR, d.name);
-    if (!fs.existsSync(path.join(repo, '.git'))) return null;
-    const r = await run('git', ['-C', repo, 'log', '-1', '--format=%cI%x09%h%x09%s'], { timeout: 3000 });
+    .map(d => d.name);
+  // cheap pre-filter: only dirs with a .git
+  const repos = dirs.filter(name => {
+    try { return fs.statSync(path.join(PROJECTS_DIR, name, '.git')).isDirectory(); } catch { return false; }
+  });
+  // sample most-recent-mtime first for relevance
+  const dated = repos.map(name => {
+    let mt = 0;
+    try { mt = fs.statSync(path.join(PROJECTS_DIR, name, '.git')).mtimeMs; } catch {}
+    return { name, mt };
+  }).sort((a, b) => b.mt - a.mt).slice(0, GIT_LIMIT);
+
+  const rows = await pmap(dated, async ({ name }) => {
+    const repo = path.join(PROJECTS_DIR, name);
+    const r = await run('git', ['-C', repo, 'log', '-1', '--format=%cI%x09%h%x09%s'], { timeout: 4000 });
     if (r.err || !r.stdout) return null;
     const [iso, sha, ...msg] = r.stdout.trim().split('\t');
-    return { project: d.name, iso, sha, msg: msg.join('\t'), age_s: Math.round((Date.now() - new Date(iso).getTime()) / 1000) };
-  }));
-  return rows.filter(Boolean).sort((a, b) => b.iso.localeCompare(a.iso)).slice(0, 20);
+    return { project: name, iso, sha, msg: msg.join('\t'), age_s: Math.round((Date.now() - new Date(iso).getTime()) / 1000) };
+  }, 4);
+  return rows.filter(Boolean).sort((a, b) => b.iso.localeCompare(a.iso)).slice(0, 15);
 }
 
-async function pollHost() {
+function pollHost() {
   const load = os.loadavg();
   const total = os.totalmem(), free = os.freemem();
   return {
@@ -108,34 +127,77 @@ async function pollHost() {
   };
 }
 
-let STATE = { generated_at: null };
+// "Big Red is thinking" — detect active claude CLI child processes
+async function pollBigRed() {
+  const r = await run('pgrep', ['-fl', 'claude'], { timeout: 3000 });
+  const lines = (r.stdout || '').split('\n').filter(Boolean);
+  const claudeProcs = lines.filter(l => /\sclaude\s/.test(l) || /\/claude\b/.test(l));
+  // Heuristic: big-red invokes claude with --print + sandbox cwd. Count those specifically.
+  const bigRedActive = lines.filter(l => /big-red|--print/.test(l)).length;
+  return {
+    thinking: bigRedActive > 0,
+    claude_procs: claudeProcs.length,
+    big_red_calls: bigRedActive,
+  };
+}
+
+let STATE = { generated_at: null, booting: true };
+let inFlight = false;
 
 async function refresh() {
+  if (inFlight) return;
+  inFlight = true;
   const t0 = Date.now();
-  const [pm2, ollama, services, git, host] = await Promise.all([
-    pollPm2(), pollOllama(), pollServices(), pollGit(), pollHost(),
-  ]);
-  STATE = { generated_at: new Date().toISOString(), poll_ms: Date.now() - t0, pm2, ollama, services, git, host };
+  try {
+    const [pm2, ollama, services, host, bigRed] = await Promise.all([
+      pollPm2(), pollOllama(), pollServices(), pollHost(), pollBigRed(),
+    ]);
+    // git last; it's the heaviest
+    const git = await pollGit().catch(() => []);
+    STATE = { generated_at: new Date().toISOString(), poll_ms: Date.now() - t0, pm2, ollama, services, git, host, bigRed };
+  } finally {
+    inFlight = false;
+  }
 }
 
-refresh().catch(e => console.error('initial refresh err', e.message));
-setInterval(() => { refresh().catch(e => console.error('refresh err', e.message)); }, POLL_MS);
-
-const server = http.createServer(async (req, res) => {
+// Schedule first refresh AFTER server starts listening, so the server is reachable immediately
+const server = http.createServer((req, res) => {
   try {
     if (req.url === '/api/state') {
       res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
       return res.end(JSON.stringify(STATE));
     }
     if (req.url === '/api/refresh' && req.method === 'POST') {
-      await refresh();
+      refresh().catch(() => {});
+      res.writeHead(202, { 'Content-Type': 'application/json' });
+      return res.end(JSON.stringify({ scheduled: true }));
+    }
+    if (req.url === '/api/bigred/model' && req.method === 'POST') {
+      let body = '';
+      req.on('data', c => body += c);
+      req.on('end', () => {
+        try {
+          const { model } = JSON.parse(body || '{}');
+          if (!model || typeof model !== 'string') { res.writeHead(400); return res.end('model required'); }
+          const target = path.join(os.homedir(), 'Projects', 'big-red', '.selected-model');
+          fs.writeFileSync(target, model + '\n');
+          res.writeHead(200, { 'Content-Type': 'application/json' });
+          res.end(JSON.stringify({ ok: true, model, written_to: target }));
+        } catch (e) { res.writeHead(500); res.end(e.message); }
+      });
+      return;
+    }
+    if (req.url === '/api/bigred/model' && req.method === 'GET') {
+      const target = path.join(os.homedir(), 'Projects', 'big-red', '.selected-model');
+      let model = null;
+      try { model = fs.readFileSync(target, 'utf8').trim(); } catch {}
       res.writeHead(200, { 'Content-Type': 'application/json' });
-      return res.end(JSON.stringify({ ok: true, generated_at: STATE.generated_at }));
+      return res.end(JSON.stringify({ model }));
     }
     let urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
     const filePath = path.join(__dirname, 'public', urlPath);
     if (!filePath.startsWith(path.join(__dirname, 'public'))) { res.writeHead(403); return res.end(); }
-    if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { res.writeHead(404); return res.end('not found'); }
+    if (!fs.existsSync(filePath)) { res.writeHead(404); return res.end('not found'); }
     const ext = path.extname(filePath).slice(1);
     const types = { html: 'text/html; charset=utf-8', js: 'text/javascript', css: 'text/css', svg: 'image/svg+xml', json: 'application/json' };
     res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream', 'Cache-Control': 'no-store' });
@@ -146,5 +208,10 @@ const server = http.createServer(async (req, res) => {
 });
 
 server.listen(PORT, () => {
-  console.log(`factory-floor listening on http://127.0.0.1:${PORT}  (poll every ${POLL_MS}ms)`);
+  console.log(`factory-floor listening on http://127.0.0.1:${PORT}  (poll every ${POLL_MS}ms, git_limit=${GIT_LIMIT})`);
+  // Defer first poll so the server is responsive even while the first poll is in flight
+  setTimeout(() => {
+    refresh().catch(e => console.error('initial refresh err', e.message));
+    setInterval(() => refresh().catch(() => {}), POLL_MS);
+  }, 250);
 });
diff --git a/services.json b/services.json
index 99c1bdf..8bbe438 100644
--- a/services.json
+++ b/services.json
@@ -19,6 +19,7 @@
 
     { "name": "George (Gmail)",    "url": "http://100.107.67.67:9850", "group": "comms" },
     { "name": "Norma (Instagram)", "url": "http://127.0.0.1:9810",  "group": "comms" },
+    { "name": "Big Red",           "url": "http://127.0.0.1:9935",  "group": "apps" },
 
     { "name": "Visual Factory",    "url": "http://127.0.0.1:9892",  "group": "factories" },
     { "name": "The AI Factory",    "url": "http://127.0.0.1:9890",  "group": "factories" },

← e87d5a8 fix: non-blocking initial poll + 30s timeout (box was at loa  ·  back to Factory Floor  ·  snapshot: 4 file(s) changed, +3 new, ~1 modified a09f471 →