[object Object]

← back to Ticket System

Fleet Office: green 'running' edge for live agents, pm2 procs, claude sessions, doing tickets

bb2080402ac69bbefc1fc676ba4a5ce5f3bf5237 · 2026-07-27 12:01:54 -0700 · Steve Abrams

- server.js: cached /api/running endpoint (pm2 jlist online + live claude session count), warmed at boot
- office.html: green running edge on doing rooms, green heartbeat ring on live agents (<8min),
  FLEET PROCESSES status board (69 green-edged pm2 nodes, hover shows cpu/mem), live/pm2/sessions pills,
  Running-edges + Process-wall toggles; running signal on its own 4s cadence

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit bb2080402ac69bbefc1fc676ba4a5ce5f3bf5237
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 12:01:54 2026 -0700

    Fleet Office: green 'running' edge for live agents, pm2 procs, claude sessions, doing tickets
    
    - server.js: cached /api/running endpoint (pm2 jlist online + live claude session count), warmed at boot
    - office.html: green running edge on doing rooms, green heartbeat ring on live agents (<8min),
      FLEET PROCESSES status board (69 green-edged pm2 nodes, hover shows cpu/mem), live/pm2/sessions pills,
      Running-edges + Process-wall toggles; running signal on its own 4s cadence
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 office.html | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 server.js   |  6 ++++-
 2 files changed, 86 insertions(+), 6 deletions(-)

diff --git a/office.html b/office.html
index bdb5257..299ab30 100644
--- a/office.html
+++ b/office.html
@@ -266,6 +266,11 @@ function buildRoom(t){
   const rim=new THREE.Line(new THREE.BufferGeometry().setFromPoints(rimPts),
     new THREE.LineBasicMaterial({ color:col, transparent:true, opacity:0.95, blending:THREE.AdditiveBlending, depthWrite:false }));
   grp.add(rim);
+  // green "RUNNING" edge — a bright box outline that lights up only for live (doing) tickets
+  const runEdge=new THREE.LineSegments(
+    new THREE.EdgesGeometry(new THREE.BoxGeometry(ROOM+0.7,2.4,ROOM+0.7)),
+    new THREE.LineBasicMaterial({ color:RUN, transparent:true, opacity:0, blending:THREE.AdditiveBlending, depthWrite:false }));
+  runEdge.position.y=1.2; grp.add(runEdge);
   // glass walls
   const wallMat=new THREE.MeshPhysicalMaterial({ color:0x0d1119, roughness:0.25, metalness:0.1,
     transmission:0.0, transparent:true, opacity:0.32, side:THREE.DoubleSide });
@@ -288,7 +293,7 @@ function buildRoom(t){
 
   const label=roomLabel(t);
   label.position.set(0,6.6,0); grp.add(label);
-  grp.userData={ ticket:t, floor, rim, trim, mon, label, baseEmis:floor.material.emissiveIntensity, flash:0, col };
+  grp.userData={ ticket:t, floor, rim, trim, mon, label, runEdge, baseEmis:floor.material.emissiveIntensity, flash:0, col };
   return grp;
 }
 function roomLabel(t){
@@ -317,10 +322,14 @@ function buildAvatar(name){
     new THREE.MeshBasicMaterial({ color:col, transparent:true, opacity:0.25, side:THREE.DoubleSide,
       blending:THREE.AdditiveBlending, depthWrite:false }));
   ring.rotation.x=-Math.PI/2; ring.position.y=0.12; grp.add(ring);
+  // green "running" halo — a crisp torus outline that appears only for live agents
+  const runRing=new THREE.Mesh(new THREE.TorusGeometry(1.55,0.07,10,44),
+    new THREE.MeshBasicMaterial({ color:RUN, transparent:true, opacity:0, blending:THREE.AdditiveBlending, depthWrite:false }));
+  runRing.rotation.x=-Math.PI/2; runRing.position.y=0.18; grp.add(runRing);
   const light=new THREE.PointLight(col.getHex(),0.0,10); light.position.y=2.4; grp.add(light);
   const label=makeLabel([{t:name}],{ w:Math.max(180,name.length*15+56), scale:0.5, glow:hex(col.getHex()) });
   label.position.set(0,4.8,0); grp.add(label);
-  grp.userData={ name,body,head,ring,light,label,col,target:new THREE.Vector3(),phase:Math.random()*6.28,working:0,room:null };
+  grp.userData={ name,body,head,ring,runRing,light,label,col,target:new THREE.Vector3(),phase:Math.random()*6.28,working:0,room:null,live:false };
   return grp;
 }
 
@@ -346,7 +355,7 @@ const roomsGrp=new THREE.Group(); scene.add(roomsGrp);
 const avatarsGrp=new THREE.Group(); scene.add(avatarsGrp);
 const roomByTicket=new Map(), avatarByName=new Map();
 let seenMsgMids=null, lastActionTs=0, lastFetch=0;
-let showLabels=true, showDone=true, followDMs=false;
+let showLabels=true, showDone=true, followDMs=false, showRunning=true, showProcs=true;
 let laneSigns=null;
 
 // Where each agent stands. An agent that is WORKING — owns or is acting on a
@@ -426,6 +435,40 @@ function placeAvatars(tk){ const place=agentPlacement(tk), perRoom=new Map(), as
   lobby.forEach((av,i)=>{ av.userData.atWork=false; av.userData.target.set(-48+(i%10)*7,0,26+Math.floor(i/10)*7); av.userData.room=null; });
 }
 
+// ───────────────────────── process wall (pm2 online + live sessions) ─────────
+// A grid of green-edged panels behind the lanes — one per online pm2 service.
+// This is the literal "green edge for what is running" for OS-level processes.
+const PROC_Z=188, PROC_YBASE=16, PROC_S=4.6, PROC_BOX=3.2;   // elevated status board near the camera
+const procWall=new THREE.Group(); procWall.rotation.x=-0.32; scene.add(procWall);  // tilt toward the downward camera
+let procNodes=[], procHeader=null, procCount=-1, procHeaderTxt='', procYTop=40;
+function setProcHeader(n,sessions){ const txt='pm2 · '+n+' online · '+sessions+' live sessions';
+  if(txt===procHeaderTxt && procHeader) return; procHeaderTxt=txt;
+  if(procHeader){ procWall.remove(procHeader); procHeader.material.map.dispose(); procHeader.material.dispose(); }
+  procHeader=makeLabel([{t:'FLEET PROCESSES'},{t:txt,sub:true}],{ w:520, scale:2.0, glow:hex(RUN) });
+  procHeader.position.set(30, procYTop+6, PROC_Z); procWall.add(procHeader); }
+function buildProcWall(list, sessions){ const n=list.length;
+  if(n!==procCount){ procCount=n;
+    for(const nd of procNodes){ procWall.remove(nd); nd.traverse(o=>{ o.geometry&&o.geometry.dispose&&o.geometry.dispose(); o.material&&o.material.dispose&&o.material.dispose(); }); }
+    procNodes=[];
+    const cols=Math.max(1,Math.ceil(Math.sqrt(n*2.4))), rows=Math.max(1,Math.ceil(n/cols));
+    const x0=30-(cols-1)*PROC_S/2; procYTop=PROC_YBASE+(rows-1)*PROC_S;
+    for(let i=0;i<n;i++){ const r=Math.floor(i/cols), c=i%cols; const nd=new THREE.Group();
+      const box=new THREE.BoxGeometry(PROC_BOX,PROC_BOX,0.5);
+      const panel=new THREE.Mesh(box,new THREE.MeshStandardMaterial({ color:0x0c2019, emissive:RUN, emissiveIntensity:0.25, roughness:0.5, metalness:0.3 }));
+      const edge=new THREE.LineSegments(new THREE.EdgesGeometry(box),
+        new THREE.LineBasicMaterial({ color:RUN, transparent:true, opacity:0.9, blending:THREE.AdditiveBlending, depthWrite:false }));
+      nd.add(panel,edge); nd.position.set(x0+c*PROC_S, procYTop-r*PROC_S, PROC_Z);
+      nd.userData={ panel, edge, phase:Math.random()*6.28, proc:list[i] };
+      procWall.add(nd); procNodes.push(nd); }
+  } else { for(let i=0;i<n;i++) if(procNodes[i]) procNodes[i].userData.proc=list[i]; }
+  setProcHeader(n,sessions); procWall.visible=showProcs;
+}
+function applyRunning(d){
+  const set=(id,v)=>{ const el=document.getElementById(id); if(el) el.textContent=v; };
+  set('s-running',(d.pm2||[]).length); set('s-sessions',d.sessions||0);
+  buildProcWall(d.pm2||[], d.sessions||0);
+}
+
 async function poll(){
   try{
     const api=p=>new URL(p,location.origin).href;   // no embedded creds → fetch-legal
@@ -441,6 +484,13 @@ function worldPos(av){ const v=new THREE.Vector3(); av.getWorldPosition(v); v.y=
 
 function apply(tk,msgs){
   layout(tk); placeAvatars(tk);
+  // heartbeat: an agent that acted/commented within LIVE_MS is "running" → green ring
+  const now=Date.now(), lastByAgent=new Map();
+  for(const t of tk){ for(const a of t.actions||[]){ const ts=+new Date(a.ts); if(ts>(lastByAgent.get(a.agent)||0)) lastByAgent.set(a.agent,ts); }
+    for(const c of t.comments||[]){ const ts=+new Date(c.ts); if(ts>(lastByAgent.get(c.agent)||0)) lastByAgent.set(c.agent,ts); } }
+  let liveCount=0;
+  for(const [nm,av] of avatarByName){ const live=(now-(lastByAgent.get(nm)||0))<LIVE_MS; av.userData.live=live; if(live) liveCount++; }
+  const liveEl=document.getElementById('s-live'); if(liveEl) liveEl.textContent=liveCount;
   let maxTs=lastActionTs, latest=null;
   for(const t of tk) for(const a of t.actions||[]){ const ts=+new Date(a.ts);
     if(ts>lastActionTs){ const room=roomByTicket.get(t.id); if(room) room.userData.flash=1;
@@ -479,7 +529,16 @@ addEventListener('pointermove',e=>{ mouse.x=(e.clientX/innerWidth)*2-1; mouse.y=
 function updateTip(){ const tip=document.getElementById('tip'); if(!hover){ tip.style.display='none'; return; }
   ray.setFromCamera(mouse,camera); const hits=ray.intersectObjects(roomsGrp.children,true);
   let o=hits.length?hits[0].object:null; while(o&&!(o.userData&&o.userData.ticket)) o=o.parent;
-  if(!o){ tip.style.display='none'; return; } const t=o.userData.ticket;
+  if(!o){ // no room hit — try a process-wall node
+    if(showProcs){ const ph=ray.intersectObjects(procWall.children,true); let p=ph.length?ph[0].object:null;
+      while(p&&!(p.userData&&p.userData.proc)) p=p.parent;
+      if(p){ const pr=p.userData.proc;
+        tip.style.borderLeftColor='#34d9a0';
+        tip.innerHTML=`<div class="h" style="color:#34d9a0">${esc(pr.name)}</div>
+          <div class="s">pm2 · online · ${pr.cpu}% cpu · ${pr.mem} MB${pr.restarts?(' · '+pr.restarts+' restarts'):''}</div>`;
+        tip.style.display='block'; tip.style.left=Math.min(hover.x+14,innerWidth-372)+'px'; tip.style.top=Math.min(hover.y+14,innerHeight-120)+'px'; return; } }
+    tip.style.display='none'; return; }
+  tip.style.borderLeftColor='#5aa9f0'; const t=o.userData.ticket;
   const last=(t.actions&&t.actions[t.actions.length-1])||(t.comments&&t.comments[t.comments.length-1]);
   tip.innerHTML=`<div class="h">${esc(t.id.replace(/^(TK-\d+).*/,'$1'))} · ${esc(t.status.toUpperCase())}</div>
     <div>${esc(t.title||'')}</div><div class="s">@${esc(t.assignee||'unassigned')}${t.project?(' · '+esc(t.project)):''} · ${(t.actions||[]).length} actions · ${(t.comments||[]).length} comments</div>
@@ -490,6 +549,8 @@ function updateTip(){ const tip=document.getElementById('tip'); if(!hover){ tip.
 const B=id=>document.getElementById(id);
 B('b-orbit').onclick=e=>{ controls.autoRotate=!controls.autoRotate; e.target.classList.toggle('on',controls.autoRotate); };
 B('b-labels').onclick=e=>{ showLabels=!showLabels; e.target.classList.toggle('on',showLabels); };
+B('b-run').onclick=e=>{ showRunning=!showRunning; e.target.classList.toggle('on',showRunning); };
+B('b-procs').onclick=e=>{ showProcs=!showProcs; e.target.classList.toggle('on',showProcs); procWall.visible=showProcs; };
 B('b-done').onclick=e=>{ showDone=!showDone; e.target.classList.toggle('on',showDone); poll(); };
 B('b-follow').onclick=e=>{ followDMs=!followDMs; e.target.classList.toggle('on',followDMs); };
 B('b-reset').onclick=()=>{ boot.t=1; camera.position.copy(HOME.pos); controls.target.copy(HOME.tgt); hideBoot(); };
@@ -522,6 +583,8 @@ function tick(){
     const w=u.working>0||u.atWork; u.ring.material.opacity=w?(0.5+0.4*Math.sin(el*10)):0.18;
     u.ring.scale.setScalar(1+(w?0.18*Math.sin(el*10):0)); u.light.intensity=w?2.0+Math.sin(el*12):0.0;
     u.body.material.emissiveIntensity=0.35+(w?0.28+0.22*Math.sin(el*6+u.phase):0); // working breath
+    if(u.runRing){ const on=showRunning&&u.live; u.runRing.material.opacity=on?0.45+0.4*Math.sin(el*5+u.phase):0;
+      u.runRing.scale.setScalar(on?1+0.12*Math.sin(el*5+u.phase):1); }
     u.label.visible=showLabels; }
 
   // rooms
@@ -531,9 +594,17 @@ function tick(){
     else if(u.ticket.status==='blocked') em=0.32+0.14*Math.sin(el*5);
     if(u.flash>0){ em+=u.flash*1.0; u.flash-=dt*1.1; }
     u.floor.material.emissiveIntensity=em; u.rim.material.opacity=0.7+0.3*Math.sin(el*2+room.position.x);
+    if(u.runEdge){ const on=showRunning&&u.ticket.status==='doing';
+      u.runEdge.material.opacity=on?0.5+0.45*Math.sin(el*4+room.position.z*0.3):0; }
     u.label.visible=showLabels; }
   if(laneSigns) laneSigns.children.forEach((s,i)=>{ s.visible=showLabels; s.position.y=12+Math.sin(el*1.2+i)*0.5; });
 
+  // process wall — each online pm2 node breathes green; busy (cpu>3%) nodes pulse faster
+  if(procWall.visible){ for(const nd of procNodes){ const u=nd.userData; const busy=u.proc&&u.proc.cpu>3;
+    const pulse=0.5+0.5*Math.sin(el*(busy?7:2.2)+u.phase);
+    u.edge.material.opacity=showRunning?0.35+0.6*pulse:0.22;
+    u.panel.material.emissiveIntensity=0.18+(busy?0.55:0.22)*pulse; } }
+
   // packets + trails
   for(let i=packets.length-1;i>=0;i--){ const p=packets[i]; p.t+=dt/p.dur;
     if(p.t>=1){ scene.remove(p.head,p.trail); p.head.traverse(o=>{o.material?.dispose?.();o.geometry?.dispose?.();});
@@ -558,7 +629,12 @@ function tick(){
 function resize(){ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
   renderer.setSize(innerWidth,innerHeight); composer.setSize(innerWidth,innerHeight); bloom.setSize(innerWidth,innerHeight); }
 addEventListener('resize',resize); resize();
-poll(); setInterval(poll,POLL_MS); tick();
+// running signal on its OWN cadence, independent of the ticket poll — fires immediately
+function pollRunning(){ fetch(new URL('/api/running',location.origin).href,{credentials:'same-origin'})
+  .then(r=>r.ok?r.json():null).then(d=>{ if(d) applyRunning(d); }).catch(()=>{}); }
+poll(); setInterval(poll,POLL_MS);
+pollRunning(); setInterval(pollRunning,4000);
+tick();
 </script>
 </body>
 </html>
diff --git a/server.js b/server.js
index cd77964..a79a821 100644
--- a/server.js
+++ b/server.js
@@ -110,4 +110,8 @@ http.createServer((req, res) => {
   if (req.url === '/api/tickets') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...tickets().values()])); }
   if (req.url === '/api/messages') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...messages().values()])); }
   res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page());
-}).listen(PORT, '127.0.0.1', () => console.log('ticket board on :' + PORT));
+}).listen(PORT, '127.0.0.1', () => {
+  console.log('ticket board on :' + PORT);
+  getRunning(() => {});                          // warm the pm2/sessions cache so first client fetch is instant
+  setInterval(() => getRunning(() => {}), 4500); // keep it warm ahead of the 5s TTL
+});

← 4d5b85d auto-save: 2026-07-27T11:50:21 (2 files) — office.html serve  ·  back to Ticket System  ·  chore: harden browser-side esc() to escape single-quote (ses 18f29be →