infinicode 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -358,6 +358,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
358
358
  <input id="cmdInput" placeholder="/status /nodes /health (Enter to run)" autocomplete="off" spellcheck="false">
359
359
  <button class="act" id="runBtn">Run</button>
360
360
  <button class="act ghost" id="applyBtn">Apply config to fleet</button>
361
+ <button class="act ghost" id="updateBtn" title="npm-update every node to the latest release and restart (supervised nodes come back on their own)">Update fleet</button>
361
362
  </footer>
362
363
  <div id="out"><button class="x" id="outClose">close</button><span id="outText"></span></div>
363
364
 
@@ -554,6 +555,16 @@ button.act:disabled{opacity:0.5;cursor:default;}
554
555
  .catch(function(e){ showOut('apply failed: '+e.message); })
555
556
  .then(function(){ $('applyBtn').disabled=false; });
556
557
  });
558
+ $('updateBtn').addEventListener('click', function(){
559
+ if(!confirm('Update every node to the latest npm release and restart them?\\n\\nSupervised nodes relaunch automatically; the hub itself will blip while it restarts.')) return;
560
+ $('updateBtn').disabled = true;
561
+ showOut('Updating fleet — installing latest on each node…');
562
+ fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input:'/update all' }) })
563
+ .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })
564
+ .then(function(res){ showOut('Update fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); poll(); })
565
+ .catch(function(e){ showOut('update failed: '+e.message); })
566
+ .then(function(){ $('updateBtn').disabled=false; });
567
+ });
557
568
 
558
569
  // ── tabs: Fleet + Park + RoboPark sessions/telemetry ──
559
570
  var tabs = document.querySelectorAll('.tab');
@@ -694,13 +705,28 @@ button.act:disabled{opacity:0.5;cursor:default;}
694
705
  // Robot cam targets a LiveKit track via /robopark/api/robots/:id/stream.
695
706
  // ══════════════════════════════════════════════════════════════════
696
707
  var Park = (function(){
697
- var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1};
708
+ var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1}, fitted=false;
698
709
  var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;
699
- var TW=124, TH=62;
700
710
  var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;
701
- var RING=[[2,0],[1,2],[-1,2],[-2,0],[-1,-2],[1,-2],[3,1],[3,-1],[0,3],[-3,1],[-3,-1],[0,-3],[2,3],[-2,3],[2,-3],[-2,-3],[4,0],[0,4],[-4,0],[0,-4]];
711
+ var lkRoom=null, micAnalyser=null, audioCtx=null;
712
+ var TW=44, TH=22, PARK_W=15, PARK_D=8;
713
+ // The real park (from the aerial): named robot pads at their ground
714
+ // positions, plus palms, pond, and the street. A mesh node binds to a pad
715
+ // when its display name matches the pad keywords (robobmw → VIXEN/BMW).
716
+ var SLOTS=[
717
+ {label:'VOLT', keys:['volt'], nx:0.30, ny:0.22},
718
+ {label:'TESLA', keys:['tesla'], nx:0.44, ny:0.36},
719
+ {label:'JAGUAR', keys:['jaguar','jag'], nx:0.60, ny:0.40},
720
+ {label:'VIXEN', keys:['vixen','bmw'], nx:0.55, ny:0.66, sub:'BMW'},
721
+ {label:'PANDA', keys:['panda'], nx:0.84, ny:0.58}
722
+ ];
723
+ var HUBSLOT={label:'CONTROL', keys:[], nx:0.09, ny:0.12};
724
+ var PALMS=[[0.11,0.55],[0.15,0.74],[0.19,0.42],[0.23,0.64],[0.17,0.31],[0.25,0.82],[0.37,0.24],[0.41,0.56],[0.49,0.74],[0.52,0.29],[0.59,0.62],[0.66,0.26],[0.71,0.69],[0.76,0.42],[0.82,0.74],[0.89,0.42],[0.92,0.68],[0.67,0.83],[0.34,0.71]];
725
+ var POND={cx:0.33*15, cy:0.99*8, rx:3.1, ry:1.4};
726
+ var CARS=[[4,-0.95,'#e0a23c'],[6,-0.8,'#7fb4ff'],[8,-1.0,'#ff7e6e'],[10.5,-0.78,'#d8d8e0'],[12.8,-0.95,'#f2c14e']];
702
727
 
703
728
  function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }
729
+ function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }
704
730
  function kindOf(n){
705
731
  var r=(n.role||'').toLowerCase();
706
732
  if(r==='hub'||r==='scheduler') return 'hub';
@@ -712,19 +738,41 @@ button.act:disabled{opacity:0.5;cursor:default;}
712
738
  function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }
713
739
  function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }
714
740
 
741
+ // Bind mesh nodes onto the fixed park pads; unmatched pads render as
742
+ // offline placeholders so the whole park is always visible.
715
743
  function layout(src){
716
- var list=(src||[]).slice().sort(function(a,b){ return (a.nodeId<b.nodeId?-1:1); });
717
- var hub=null; for(var i=0;i<list.length;i++){ if(kindOf(list[i])==='hub'){ hub=list[i]; break; } }
718
- if(!hub && list.length) hub=list[0];
719
- var out=[], oi=0;
720
- list.forEach(function(n){
721
- var cell = (n===hub)? [0,0] : RING[(oi++)%RING.length];
722
- var prev = byId[n.nodeId];
723
- out.push({
724
- id:n.nodeId, name:n.displayName||n.nodeId, role:n.role||'?', kind:kindOf(n),
725
- connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',
726
- site:n.site||'', caps:n.capabilities||[], gx:cell[0], gy:cell[1], _hit:prev?prev._hit:null
727
- });
744
+ var list=(src||[]).slice(), used={}, out=[];
745
+ function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }
746
+ function bind(slot, wx, wy, kind){
747
+ var match=null;
748
+ for(var i=0;i<list.length;i++){ var n=list[i]; if(used[n.nodeId]) continue;
749
+ var nm=(n.displayName||n.nodeId||'').toLowerCase(), isHub=kindOf(n)==='hub';
750
+ if(kind==='hub'){ if(isHub){ match=n; break; } }
751
+ else if(!isHub){ for(var k=0;k<slot.keys.length;k++){ if(slot.keys[k] && nm.indexOf(slot.keys[k])>-1){ match=n; break; } } if(match) break; }
752
+ }
753
+ var id = match?match.nodeId:('pad:'+slot.label);
754
+ if(match) used[match.nodeId]=1;
755
+ return {
756
+ id:id, kind:kind, label:slot.label,
757
+ name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),
758
+ sub: match?(slot.sub||''):'not joined',
759
+ connected: match?!!match.connected:false,
760
+ load: (match&&match.load!=null)?match.load:0,
761
+ platform: match?(match.platform||''):'', version: match?(match.version||'?'):'—',
762
+ site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],
763
+ telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),
764
+ wx:wx, wy:wy, _hit:prevHit(id)
765
+ };
766
+ }
767
+ var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));
768
+ SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });
769
+ // Any extra live node that matched no pad → line it along the near edge.
770
+ var ex=0;
771
+ list.forEach(function(n){ if(used[n.nodeId]) return; used[n.nodeId]=1;
772
+ var w=W2(0.20+ex*0.13, 0.90); ex++;
773
+ out.push({ id:n.nodeId, kind:kindOf(n), label:(n.displayName||n.nodeId), name:(n.displayName||n.nodeId),
774
+ sub:n.role||'', connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',
775
+ site:n.site||'', caps:n.capabilities||[], telemKeys:[(n.displayName||'').toLowerCase()], wx:w[0], wy:w[1], _hit:prevHit(n.nodeId) });
728
776
  });
729
777
  return out;
730
778
  }
@@ -739,8 +787,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
739
787
 
740
788
  function hub(){ for(var i=0;i<nodes.length;i++) if(nodes[i].kind==='hub') return nodes[i]; return nodes[0]; }
741
789
  function findByFrame(f){
742
- var key=f.n; if(key==null) return null;
743
- for(var i=0;i<nodes.length;i++){ if(nodes[i].name===key||nodes[i].id===key) return nodes[i]; }
790
+ var key=(f.n==null?'':String(f.n)).toLowerCase(); if(!key) return null;
791
+ for(var i=0;i<nodes.length;i++){ var n=nodes[i];
792
+ if(n.id===f.n || (n.name||'').toLowerCase()===key) return n;
793
+ for(var k=0;k<n.telemKeys.length;k++){ if(n.telemKeys[k] && key.indexOf(n.telemKeys[k])>-1) return n; }
794
+ }
744
795
  return null;
745
796
  }
746
797
  function ingest(f, fromPoll){
@@ -786,30 +837,67 @@ button.act:disabled{opacity:0.5;cursor:default;}
786
837
  C.addEventListener('click',function(e){
787
838
  var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;
788
839
  for(var i=0;i<nodes.length;i++){ var hb=nodes[i]._hit; if(hb&&mx>=hb.x&&mx<=hb.x+hb.w&&my>=hb.y&&my<=hb.y+hb.h) hit=nodes[i]; }
789
- if(hit){ openDrawer(hit.id); selectNode(hit.id, hit.name); }
840
+ if(hit){ openDrawer(hit.id); if(String(hit.id).indexOf('pad:')!==0) selectNode(hit.id, hit.name); }
790
841
  });
791
842
  window.addEventListener('resize', function(){ if(running) resize(); });
792
843
  }
793
844
  function resize(){
794
845
  var st=$('pk-stage'); if(!st) return;
795
846
  W=st.clientWidth; H=st.clientHeight; C.width=W*DPR; C.height=H*DPR; X.setTransform(DPR,0,0,DPR,0,0);
847
+ if(!fitted){ fit(); fitted=true; }
796
848
  }
797
- function iso(gx,gy){ return { x: W/2 + cam.x + (gx-gy)*TW/2*cam.z, y: H*0.42 + cam.y + (gx+gy)*TH/2*cam.z }; }
798
-
799
- function tile(gx,gy,raised){
800
- var p=iso(gx,gy), z=cam.z, hw=TW/2*z, hh=TH/2*z, dz=raised?13*z:0;
801
- X.beginPath(); X.moveTo(p.x,p.y-hh-dz); X.lineTo(p.x+hw,p.y-dz); X.lineTo(p.x,p.y+hh-dz); X.lineTo(p.x-hw,p.y-dz); X.closePath();
802
- var g=X.createLinearGradient(p.x-hw,p.y-hh,p.x+hw,p.y+hh);
803
- g.addColorStop(0,raised?'rgba(60,52,86,0.55)':'rgba(30,26,44,0.32)'); g.addColorStop(1,raised?'rgba(20,17,32,0.6)':'rgba(16,14,26,0.28)');
804
- X.fillStyle=g; X.fill();
805
- X.strokeStyle=raised?'rgba(255,214,112,0.55)':'rgba(255,255,255,0.08)'; X.lineWidth=1; X.stroke();
806
- if(raised){
807
- X.beginPath(); X.moveTo(p.x-hw,p.y-dz); X.lineTo(p.x-hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x,p.y+hh-dz); X.closePath();
808
- X.fillStyle='rgba(10,8,18,0.55)'; X.fill(); X.strokeStyle='rgba(255,214,112,0.28)'; X.stroke();
809
- X.beginPath(); X.moveTo(p.x+hw,p.y-dz); X.lineTo(p.x+hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x,p.y+hh-dz); X.closePath();
810
- X.fillStyle='rgba(14,11,24,0.55)'; X.fill(); X.strokeStyle='rgba(255,214,112,0.2)'; X.stroke();
811
- }
812
- return p;
849
+ function fit(){
850
+ var span=PARK_W+PARK_D;
851
+ var zx=(W-330)/(span*TW*0.5+120), zy=H/(span*TH+220);
852
+ cam.z=Math.max(0.5,Math.min(1.5, Math.min(zx,zy))); cam.x=0; cam.y=0;
853
+ }
854
+ // World (park) coords → screen. Origin biased left so the right-docked feed
855
+ // doesn't cover the plaza; the park is centered on (PARK_W/2, PARK_D/2).
856
+ function iso(wx,wy){
857
+ var dx=(wx-PARK_W/2)-(wy-PARK_D/2), dy=(wx-PARK_W/2)+(wy-PARK_D/2);
858
+ return { x:(W-300)/2 + cam.x + dx*TW*cam.z, y:H*0.42 + cam.y + dy*TH*cam.z };
859
+ }
860
+ function poly(pts, fill, stroke, lw){
861
+ X.beginPath();
862
+ for(var i=0;i<pts.length;i++){ var p=iso(pts[i][0],pts[i][1]); if(i===0)X.moveTo(p.x,p.y); else X.lineTo(p.x,p.y); }
863
+ X.closePath();
864
+ if(fill){ X.fillStyle=fill; X.fill(); }
865
+ if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }
866
+ }
867
+ function drawGround(){
868
+ // grass base
869
+ var g=X.createLinearGradient(0,0,0,H); g.addColorStop(0,'rgba(38,68,44,0.55)'); g.addColorStop(1,'rgba(22,44,28,0.5)');
870
+ poly([[-2,-2.2],[PARK_W+2,-2.2],[PARK_W+2,PARK_D+2],[-2,PARK_D+2]], g, 'rgba(120,180,120,0.10)',1);
871
+ // street band along the far edge + dashed centerline + cars
872
+ poly([[0,-1.5],[PARK_W,-1.5],[PARK_W,-0.35],[0,-0.35]], 'rgba(26,26,32,0.88)', 'rgba(255,255,255,0.08)',1);
873
+ X.strokeStyle='rgba(232,210,120,0.5)'; X.setLineDash([9*cam.z,9*cam.z]);
874
+ var r1=iso(0.2,-0.92), r2=iso(PARK_W-0.2,-0.92); X.beginPath(); X.moveTo(r1.x,r1.y); X.lineTo(r2.x,r2.y); X.stroke(); X.setLineDash([]);
875
+ CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });
876
+ // paved plaza
877
+ var px0=0.4,py0=-0.15,px1=PARK_W-0.4,py1=PARK_D-0.6;
878
+ var pg=X.createLinearGradient(0,0,0,H); pg.addColorStop(0,'rgba(74,70,92,0.66)'); pg.addColorStop(1,'rgba(46,42,60,0.62)');
879
+ poly([[px0,py0],[px1,py0],[px1,py1],[px0,py1]], pg, 'rgba(255,255,255,0.07)',1);
880
+ X.strokeStyle='rgba(255,255,255,0.05)'; X.lineWidth=1;
881
+ for(var gx=Math.ceil(px0);gx<px1;gx++){ var a=iso(gx,py0), b=iso(gx,py1); X.beginPath(); X.moveTo(a.x,a.y); X.lineTo(b.x,b.y); X.stroke(); }
882
+ for(var gy=Math.ceil(py0);gy<py1;gy++){ var c=iso(px0,gy), d=iso(px1,gy); X.beginPath(); X.moveTo(c.x,c.y); X.lineTo(d.x,d.y); X.stroke(); }
883
+ // pond
884
+ var pts=[]; for(var t=0;t<Math.PI*2;t+=Math.PI/9){ pts.push([POND.cx+POND.rx*Math.cos(t), POND.cy+POND.ry*Math.sin(t)]); }
885
+ var wg=X.createLinearGradient(0,0,0,H); wg.addColorStop(0,'rgba(44,116,156,0.72)'); wg.addColorStop(1,'rgba(26,74,116,0.72)');
886
+ poly(pts, wg, 'rgba(130,205,235,0.32)',1.5);
887
+ }
888
+ function drawCar(wx,wy,col){
889
+ var p=iso(wx,wy), z=cam.z;
890
+ X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(p.x,p.y+3*z,11*z,5*z,0,0,7); X.fill();
891
+ X.fillStyle=col; X.beginPath(); X.roundRect(p.x-11*z,p.y-7*z,22*z,12*z,3*z); X.fill();
892
+ X.fillStyle='rgba(255,255,255,0.25)'; X.beginPath(); X.roundRect(p.x-6*z,p.y-6*z,12*z,5*z,2*z); X.fill();
893
+ }
894
+ function drawPalm(wx,wy){
895
+ var p=iso(wx,wy), z=cam.z;
896
+ X.fillStyle='rgba(0,0,0,0.26)'; X.beginPath(); X.ellipse(p.x,p.y,10*z,4.5*z,0,0,7); X.fill();
897
+ X.strokeStyle='#6b4a24'; X.lineWidth=3*z; X.beginPath(); X.moveTo(p.x,p.y); X.quadraticCurveTo(p.x+3*z,p.y-14*z,p.x+1*z,p.y-24*z); X.stroke();
898
+ var tx=p.x+1*z, ty=p.y-24*z; X.strokeStyle='#3f8f4a'; X.lineWidth=2.4*z;
899
+ for(var a=0;a<6;a++){ var ang=(-Math.PI/2)+(a-2.5)*0.52; X.beginPath(); X.moveTo(tx,ty); X.quadraticCurveTo(tx+Math.cos(ang)*10*z, ty+Math.sin(ang)*8*z-4*z, tx+Math.cos(ang)*20*z, ty+Math.sin(ang)*11*z+3*z); X.stroke(); }
900
+ X.fillStyle='#caa23a'; X.beginPath(); X.arc(tx,ty+1*z,2*z,0,7); X.fill();
813
901
  }
814
902
  function ring(cx,cy,r,frac,color){
815
903
  X.beginPath(); X.arc(cx,cy,r,0,Math.PI*2); X.strokeStyle='rgba(255,255,255,0.12)'; X.lineWidth=3*cam.z; X.stroke();
@@ -820,62 +908,68 @@ button.act:disabled{opacity:0.5;cursor:default;}
820
908
  X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);
821
909
  X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);
822
910
  }
911
+ function padMark(bx,by,z){ X.strokeStyle='rgba(255,255,255,0.10)'; X.lineWidth=1; X.beginPath(); X.ellipse(bx,by,15*z,7*z,0,0,7); X.stroke(); }
823
912
  function drawHub(n){
824
- var p=tile(n.gx,n.gy,true), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-14*z-bob;
825
- X.fillStyle='rgba(40,34,58,0.9)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;
913
+ var p=iso(n.wx,n.wy), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-6*z-bob;
914
+ X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,16*z,7*z,0,0,7); X.fill();
915
+ X.fillStyle='rgba(40,34,58,0.92)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;
826
916
  X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();
827
917
  for(var i=0;i<4;i++){ X.fillStyle=i%2?getVar('--ok'):getVar('--gold-bright'); X.globalAlpha=0.4+0.6*Math.abs(Math.sin((performance.now()-t0)/300+i)); X.fillRect(bx-11*z,by-34*z+i*9*z,22*z,4*z); X.globalAlpha=1; }
828
918
  X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();
829
919
  X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();
830
- label(bx,p.y+18*z,n.name,getVar('--gold-bright'), (n.connected?'HUB · '+Math.round(n.load)+'%':'offline'));
920
+ label(bx,p.y+16*z,n.name,getVar('--gold-bright'), (n.connected?'HUB · '+Math.round(n.load)+'%':'offline'));
831
921
  n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};
832
922
  }
833
923
  function drawRobot(n){
834
- var p=tile(n.gx,n.gy,true), z=cam.z, bob=Math.sin((performance.now()-t0)/500+n.gx)*2.4*z, bx=p.x, by=p.y-10*z-bob;
835
- ring(bx,p.y+2*z,20*z,n.load/100,loadColor(n.load));
836
- X.fillStyle='rgba(0,0,0,0.35)'; X.beginPath(); X.ellipse(bx,p.y+2*z,15*z,7*z,0,0,7); X.fill();
837
- var col=n.connected?getVar('--gold-bright'):'rgba(150,140,120,0.5)';
838
- X.fillStyle='rgba(24,20,38,0.9)'; X.strokeStyle=col; X.lineWidth=1.6;
839
- X.beginPath(); X.roundRect(bx-12*z,by-24*z,24*z,26*z,5*z); X.fill(); X.stroke();
840
- X.beginPath(); X.roundRect(bx-9*z,by-38*z,18*z,15*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();
841
- X.fillStyle=n.connected?getVar('--ok'):'#654'; X.beginPath(); X.arc(bx-4*z,by-30*z,2.1*z,0,7); X.arc(bx+4*z,by-30*z,2.1*z,0,7); X.fill();
842
- X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-38*z); X.lineTo(bx,by-45*z); X.stroke();
843
- X.fillStyle=n.connected?'#FF5B4A':'#654'; X.beginPath(); X.arc(bx,by-46*z,2.2*z,0,7); X.fill();
844
- label(bx,p.y+18*z,n.name,col,(n.connected?'load '+Math.round(n.load)+'%':'offline'));
845
- n._hit={x:bx-16*z,y:by-48*z,w:32*z,h:62*z};
924
+ var p=iso(n.wx,n.wy), z=cam.z, bob=n.connected?Math.sin((performance.now()-t0)/500+n.wx)*2.3*z:0, bx=p.x, by=p.y-4*z-bob;
925
+ if(n.connected) ring(bx,p.y+3*z,18*z,n.load/100,loadColor(n.load)); else padMark(bx,p.y+3*z,z);
926
+ X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+3*z,13*z,6*z,0,0,7); X.fill();
927
+ var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';
928
+ X.globalAlpha=n.connected?1:0.7;
929
+ X.fillStyle='rgba(24,20,38,0.92)'; X.strokeStyle=col; X.lineWidth=1.6;
930
+ X.beginPath(); X.roundRect(bx-11*z,by-24*z,22*z,25*z,5*z); X.fill(); X.stroke();
931
+ X.beginPath(); X.roundRect(bx-8*z,by-37*z,16*z,14*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();
932
+ X.fillStyle=n.connected?getVar('--ok'):'#655'; X.beginPath(); X.arc(bx-3.5*z,by-30*z,2*z,0,7); X.arc(bx+3.5*z,by-30*z,2*z,0,7); X.fill();
933
+ X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-37*z); X.lineTo(bx,by-44*z); X.stroke();
934
+ X.fillStyle=n.connected?'#FF5B4A':'#655'; X.beginPath(); X.arc(bx,by-45*z,2*z,0,7); X.fill();
935
+ X.globalAlpha=1;
936
+ var sub=n.connected?((n.sub&&n.sub!==n.name?n.sub+' · ':'')+Math.round(n.load)+'%'):'not joined';
937
+ label(bx,p.y+17*z,n.label,col,sub);
938
+ n._hit={x:bx-15*z,y:by-47*z,w:30*z,h:60*z};
846
939
  }
847
940
  function drawPeer(n){
848
- var p=tile(n.gx,n.gy,true), z=cam.z, bx=p.x, by=p.y-8*z;
849
- var col=n.connected?getVar('--info'):'rgba(150,140,120,0.5)';
941
+ var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;
942
+ X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,13*z,6*z,0,0,7); X.fill();
943
+ var col=n.connected?getVar('--info'):'rgba(150,143,120,0.5)';
850
944
  X.fillStyle='rgba(24,22,40,0.9)'; X.strokeStyle=col; X.lineWidth=1.4;
851
945
  X.beginPath(); X.roundRect(bx-13*z,by-22*z,26*z,22*z,4*z); X.fill(); X.stroke();
852
946
  X.fillStyle=col; X.globalAlpha=0.5+0.5*Math.abs(Math.sin((performance.now()-t0)/500)); X.fillRect(bx-8*z,by-14*z,16*z,3*z); X.globalAlpha=1;
853
- label(bx,p.y+16*z,n.name,col,(n.connected?n.role:'offline'));
947
+ label(bx,p.y+15*z,n.label,col,(n.connected?n.sub:'offline'));
854
948
  n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};
855
949
  }
856
950
 
857
951
  function frame(){
858
952
  if(!running) return;
859
953
  X.clearRect(0,0,W,H);
860
- for(var gx=-4;gx<=4;gx++) for(var gy=-4;gy<=4;gy++){ if(!nodes.some(function(n){return n.gx===gx&&n.gy===gy;})) faint(gx,gy); }
861
- var sorted=nodes.slice().sort(function(a,b){ return (a.gx+a.gy)-(b.gx+b.gy); });
862
- sorted.forEach(function(n){ if(n.kind==='hub') drawHub(n); else if(n.kind==='robot') drawRobot(n); else drawPeer(n); });
863
- for(var i=orbs.length-1;i>=0;i--){ var o=orbs[i]; o.t+=0.024; if(o.t>=1){orbs.splice(i,1);continue;}
864
- var a=iso(o.a.gx,o.a.gy), b=iso(o.b.gx,o.b.gy), e=o.t<.5?2*o.t*o.t:-1+(4-2*o.t)*o.t;
865
- var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o.t*Math.PI)*40*cam.z;
866
- X.beginPath(); X.arc(x,y,4*cam.z,0,7); X.fillStyle=o.color; X.shadowColor=o.color; X.shadowBlur=12; X.fill(); X.shadowBlur=0;
954
+ drawGround();
955
+ // depth-sort palms + nodes so nearer objects overlap farther ones
956
+ var objs=[];
957
+ for(var pi=0;pi<PALMS.length;pi++){ var w=W2(PALMS[pi][0],PALMS[pi][1]); objs.push({wx:w[0],wy:w[1],palm:true}); }
958
+ for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }
959
+ objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });
960
+ objs.forEach(function(o){ if(o.palm) drawPalm(o.wx,o.wy); else { var n=o.n; if(n.kind==='hub')drawHub(n); else if(n.kind==='robot')drawRobot(n); else drawPeer(n); } });
961
+ for(var i=orbs.length-1;i>=0;i--){ var o2=orbs[i]; o2.t+=0.024; if(o2.t>=1){orbs.splice(i,1);continue;}
962
+ var a=iso(o2.a.wx,o2.a.wy), b=iso(o2.b.wx,o2.b.wy), e=o2.t<.5?2*o2.t*o2.t:-1+(4-2*o2.t)*o2.t;
963
+ var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o2.t*Math.PI)*40*cam.z;
964
+ X.beginPath(); X.arc(x,y,4*cam.z,0,7); X.fillStyle=o2.color; X.shadowColor=o2.color; X.shadowBlur=12; X.fill(); X.shadowBlur=0;
867
965
  }
868
966
  for(var j=pulses.length-1;j>=0;j--){ var pu=pulses[j]; pu.t+=0.02; if(pu.t>=1){pulses.splice(j,1);continue;}
869
- var pp=iso(pu.n.gx,pu.n.gy), yy=pp.y-58*cam.z-pu.t*24*cam.z;
967
+ var pp=iso(pu.n.wx,pu.n.wy), yy=pp.y-56*cam.z-pu.t*24*cam.z;
870
968
  X.globalAlpha=1-pu.t; X.font='700 '+(15*cam.z)+'px '+mono(); X.textAlign='center'; X.fillStyle=getVar('--gold-bright'); X.fillText(pu.g,pp.x,yy);
871
- X.beginPath(); X.arc(pp.x,pp.y-20*cam.z,(10+pu.t*22)*cam.z,0,7); X.strokeStyle='rgba(255,214,112,'+(1-pu.t)+')'; X.lineWidth=2; X.stroke(); X.globalAlpha=1;
969
+ X.beginPath(); X.arc(pp.x,pp.y-18*cam.z,(10+pu.t*22)*cam.z,0,7); X.strokeStyle='rgba(255,214,112,'+(1-pu.t)+')'; X.lineWidth=2; X.stroke(); X.globalAlpha=1;
872
970
  }
873
971
  raf=requestAnimationFrame(frame);
874
972
  }
875
- function faint(gx,gy){ var p=iso(gx,gy),z=cam.z,hw=TW/2*z,hh=TH/2*z;
876
- X.beginPath(); X.moveTo(p.x,p.y-hh); X.lineTo(p.x+hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x-hw,p.y); X.closePath();
877
- X.strokeStyle='rgba(255,255,255,0.045)'; X.lineWidth=1; X.stroke();
878
- }
879
973
 
880
974
  function connectStream(){
881
975
  if(es) return;
@@ -887,8 +981,15 @@ button.act:disabled{opacity:0.5;cursor:default;}
887
981
  }catch(e){ es=null; }
888
982
  }
889
983
 
984
+ var sdkLoading=false;
985
+ function loadSDK(){
986
+ if(window.LivekitClient || sdkLoading) return; sdkLoading=true;
987
+ var s=document.createElement('script'); s.src='/vendor/livekit-client.umd.js'+QS; s.async=true;
988
+ s.onerror=function(){ sdkLoading=false; }; document.head.appendChild(s);
989
+ }
890
990
  function activate(){
891
991
  ensure();
992
+ loadSDK();
892
993
  if(lastStatus) onStatus(lastStatus);
893
994
  resize();
894
995
  connectStream();
@@ -902,9 +1003,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
902
1003
  var n=byId[id]; if(!n) return; drawerId=id;
903
1004
  $('pk-drawer').classList.add('open');
904
1005
  $('pk-av').textContent = n.kind==='hub'?'🖥':(n.kind==='robot'?'🤖':'🧩');
905
- $('pk-name').textContent=n.name;
906
- $('pk-sub').textContent=n.role+' · '+(n.platform||'?')+' · v'+n.version+(n.site?(' · '+n.site):'');
907
- var st=$('pk-state'); st.textContent=n.connected?'live':'idle'; st.className='pk-pill '+(n.connected?'live':'idle');
1006
+ $('pk-name').textContent=n.label||n.name;
1007
+ $('pk-sub').textContent = n.connected ? (n.name+' · '+(n.platform||'?')+' · v'+n.version+(n.site?(' · '+n.site):'')) : 'pad reserved · not joined';
1008
+ var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; st.className='pk-pill '+(n.connected?'live':'idle');
908
1009
  buildDrawer(n);
909
1010
  pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick
910
1011
  if(drawerTimer) clearInterval(drawerTimer);
@@ -916,6 +1017,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
916
1017
  if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;
917
1018
  if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }
918
1019
  if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }
1020
+ if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
1021
+ micAnalyser=null;
919
1022
  }
920
1023
  function pollRoboparkQuiet(){
921
1024
  fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
@@ -926,6 +1029,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
926
1029
  }
927
1030
  function buildDrawer(n){
928
1031
  var b=$('pk-body');
1032
+ if(n.kind==='robot' && !n.connected){
1033
+ b.innerHTML =
1034
+ sect('🅿 park pad · '+esc(n.label), '<div class="pk-note" style="opacity:1;color:var(--muted)">No robot has joined this pad yet. When a node named <b>'+esc((n.telemKeys[1]||n.label).toString())+'</b> connects to the mesh, its live camera, mic, speaker, and telemetry appear here automatically.</div>')
1035
+ + sect('📍 location', '<div class="pk-grid2"><div class="pk-kv"><div class="v">'+esc(n.label)+'</div><div class="l">pad</div></div><div class="pk-kv"><div class="v" style="color:var(--muted)">idle</div><div class="l">status</div></div></div>');
1036
+ return;
1037
+ }
929
1038
  if(n.kind==='robot'){
930
1039
  b.innerHTML =
931
1040
  sect('👁 camera feed',
@@ -955,6 +1064,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
955
1064
  var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';
956
1065
  var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';
957
1066
  var row=roboparkByName[n.name];
1067
+ if(!row){ var keys=n.telemKeys||[]; for(var rk in roboparkByName){ var lk=rk.toLowerCase(); for(var ki=0;ki<keys.length;ki++){ if(keys[ki] && (lk===keys[ki]||lk.indexOf(keys[ki])>-1)){ row=roboparkByName[rk]; break; } } if(row) break; } }
958
1068
  if(row){
959
1069
  set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);
960
1070
  set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);
@@ -969,30 +1079,54 @@ button.act:disabled{opacity:0.5;cursor:default;}
969
1079
 
970
1080
  // ── cam: target a LiveKit track; fall back to a styled preview ──
971
1081
  function startCam(n){
972
- var note=$('pk-camnote'), res=$('pk-camres');
1082
+ var note=$('pk-camnote');
973
1083
  camAbort = ('AbortController' in window)?new AbortController():null;
974
- // The robot-side RoboPark plugin exposes this; returns {url, token, room, trackSid}.
1084
+ idleCam('connecting');
1085
+ // scheduler mints a subscribe-only viewer token for the robot's current
1086
+ // LiveKit room; the Pi already publishes 'cam' (video) + 'mic' (audio).
975
1087
  fetch('/robopark/api/robots/'+encodeURIComponent(n.name)+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})
976
- .then(function(r){ if(!r.ok) throw new Error('no stream'); return r.json(); })
1088
+ .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })
977
1089
  .then(function(info){
978
- if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); }
979
- else if(info && info.url){ if(note) note.textContent='LiveKit track ready — load livekit-client to view'; previewCam(n); }
980
- else throw new Error('no track');
1090
+ if(info && info.active===false){ idleCam(info.reason||'idle'); return; }
1091
+ if(info && info.url && info.token){
1092
+ if(window.LivekitClient){ attachLiveKit(info, n); }
1093
+ else { idleCam('no-sdk'); }
1094
+ } else { idleCam('idle'); }
981
1095
  })
982
- .catch(function(){ if(note) note.textContent='no published cam track — showing preview (robot plugin pending)'; if(res) res.textContent='preview'; previewCam(n); });
1096
+ .catch(function(){ idleCam('unreachable'); });
1097
+ }
1098
+ function idleCam(reason){
1099
+ var cc=$('pk-camcv'), msg={connecting:'connecting to live session…', idle:'robot idle · no live session', production_mode_off:'production mode off', 'no-sdk':'live session up · loading video…', unreachable:'scheduler unreachable'}[reason]||'no live feed';
1100
+ if(cc&&cc.getContext){ var cx=cc.getContext('2d'); cx.fillStyle='#0b0d12'; cx.fillRect(0,0,cc.width,cc.height); cx.strokeStyle='rgba(255,255,255,0.05)'; for(var y=0;y<cc.height;y+=4){cx.beginPath();cx.moveTo(0,y);cx.lineTo(cc.width,y);cx.stroke();} cx.fillStyle='#5a6a80'; cx.font='11px monospace'; cx.textAlign='center'; cx.fillText(msg, cc.width/2, cc.height/2); }
1101
+ var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'—';
1102
+ var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;
983
1103
  }
984
1104
  function attachLiveKit(info, n){
985
- var mount=$('pk-cam'), note=$('pk-camnote'), res=$('pk-camres'); if(!mount) return;
1105
+ var note=$('pk-camnote'), res=$('pk-camres');
1106
+ try{
1107
+ var LK=window.LivekitClient; lkRoom=new LK.Room();
1108
+ lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){
1109
+ if(track.kind==='video'){
1110
+ var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;
1111
+ var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);
1112
+ if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit · '+esc(info.room);
1113
+ } else if(track.kind==='audio'){ hookMicAnalyser(track); track.attach(); }
1114
+ });
1115
+ lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='—'; });
1116
+ lkRoom.connect(info.url, info.token).then(function(){ if(note) note.textContent='connected · waiting for camera track…'; })
1117
+ .catch(function(){ idleCam('unreachable'); });
1118
+ }catch(e){ idleCam('no-sdk'); }
1119
+ }
1120
+ // Real mic level: tap the robot's subscribed 'mic' audio track with a Web
1121
+ // Audio analyser and read RMS in startMeters (no invented levels).
1122
+ function hookMicAnalyser(track){
986
1123
  try{
987
- var LK=window.LivekitClient, room=new LK.Room();
988
- room.connect(info.url, info.token).then(function(){
989
- if(note) note.textContent='LiveKit · connected';
990
- room.on(LK.RoomEvent.TrackSubscribed, function(track){
991
- if(track.kind==='video'){ var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true;
992
- var cv=$('pk-camcv'); if(cv&&cv.parentNode){ cv.parentNode.replaceChild(el, cv); } if(res) res.textContent='LiveKit · live'; }
993
- });
994
- }).catch(function(){ if(note) note.textContent='LiveKit connect failed — preview'; previewCam(n); });
995
- }catch(e){ if(note) note.textContent='LiveKit error — preview'; previewCam(n); }
1124
+ var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;
1125
+ audioCtx=audioCtx||new AC();
1126
+ var ms=track.mediaStreamTrack; if(!ms) return;
1127
+ var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));
1128
+ var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;
1129
+ }catch(e){}
996
1130
  }
997
1131
  function previewCam(n){
998
1132
  var cc=$('pk-camcv'); if(!cc||!cc.getContext) return; var cx=cc.getContext('2d'), w=cc.width, h=cc.height, s0=performance.now();
@@ -1015,18 +1149,19 @@ button.act:disabled{opacity:0.5;cursor:default;}
1015
1149
  function bars(el,n){ el.innerHTML=''; for(var i=0;i<n;i++){ var b=document.createElement('i'); el.appendChild(b); } return el.querySelectorAll('i'); }
1016
1150
  function startMeters(n){
1017
1151
  var mb=$('pk-micbars'), sb=$('pk-spkbars'); if(!mb) return;
1018
- var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw.getContext('2d'), s0=performance.now();
1019
- // Preview envelopes until the robot audio plugin streams real levels.
1152
+ var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw?sw.getContext('2d'):null, buf=new Uint8Array(128);
1020
1153
  (function loop(){ if(!$('pk-micbars')||drawerId==null) return;
1021
- var tt=(performance.now()-s0)/1000;
1022
- var env=Math.max(0,Math.sin(tt*2.3))*Math.max(0,Math.sin(tt*0.7));
1023
- for(var i=0;i<mi.length;i++){ var v=env*(0.4+0.6*Math.random())*(i<env*mi.length?1:0.25); mi[i].style.height=(3+v*13)+'px'; mi[i].style.background=i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'; }
1024
- set('pk-micval', Math.round(env*100)+'%'); set('pk-micstate', env>0.3?'preview · speech':'preview · quiet');
1025
- var spk=Math.max(0,Math.sin(tt*1.1-1)), speaking=spk>0.35;
1026
- for(var j=0;j<si.length;j++){ var vv=speaking?(0.3+0.7*Math.random()):0.05; si[j].style.height=(3+vv*13)+'px'; si[j].style.background=speaking?'var(--gold-bright)':'var(--glass-border)'; }
1027
- set('pk-spkval', speaking?Math.round(spk*100)+'%':'idle'); set('pk-spkstate', speaking?'preview · TTS':'preview · idle');
1028
- swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle=speaking?getVar('--gold-bright'):'rgba(255,255,255,0.12)'; swx.lineWidth=1.5; swx.beginPath();
1029
- for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.15+tt*8)*(speaking?spk*11:1.4)*Math.sin(x*0.03); if(x===0)swx.moveTo(x,yy); else swx.lineTo(x,yy); } swx.stroke();
1154
+ // MIC — real RMS from the subscribed LiveKit mic track (else no signal).
1155
+ var lvl=0;
1156
+ if(micAnalyser){ micAnalyser.getByteTimeDomainData(buf); var sum=0; for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; } lvl=Math.min(1, Math.sqrt(sum/buf.length)*3.4); }
1157
+ for(var i=0;i<mi.length;i++){ var on=i<lvl*mi.length; mi[i].style.height=(3+(on?lvl*13:0))+'px'; mi[i].style.background=on?(i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'):'var(--glass-border)'; }
1158
+ set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'—');
1159
+ set('pk-micstate', micAnalyser?(lvl>0.12?'live · hearing audio':'live · quiet'):'no live mic track');
1160
+ // SPEAKER the Pi plays TTS out its own speaker; it is not published as a
1161
+ // track, so report status honestly instead of inventing a level.
1162
+ for(var j=0;j<si.length;j++){ si[j].style.height='3px'; si[j].style.background='var(--glass-border)'; }
1163
+ set('pk-spkval','—'); set('pk-spkstate','TTS plays on the robot speaker (not streamed)');
1164
+ if(swx){ swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle='rgba(255,255,255,0.12)'; swx.lineWidth=1.4; swx.beginPath(); for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.1)*1.1; if(x===0)swx.moveTo(x,yy); else swx.lineTo(x,yy); } swx.stroke(); }
1030
1165
  meterRAF=requestAnimationFrame(loop);
1031
1166
  })();
1032
1167
  }
@@ -4,6 +4,7 @@ import { PeerMesh } from './peer-mesh.js';
4
4
  import { EventBridge } from './event-bridge.js';
5
5
  import { ConfigSync } from './config-sync.js';
6
6
  import { AutoUpdate } from './auto-update.js';
7
+ import { performSelfUpdate } from './self-update.js';
7
8
  import { ComputeRouter } from './compute-router.js';
8
9
  import { TelemetryCollector, loadFromSnapshot, selfNodeStatus, nodeStatusFromPeer } from './telemetry.js';
9
10
  import { buildManifest } from './node-identity.js';
@@ -58,6 +59,17 @@ export class Federation {
58
59
  version: deps.options.version,
59
60
  logger: deps.logger,
60
61
  enabled: deps.options.autoUpdate,
62
+ // Apply the announced update (npm install + supervised restart). Only ever
63
+ // reached for announces from a hub/relay with auto-update enabled — see
64
+ // AutoUpdate.onAnnounce. Supervision is read from INFINICODE_SUPERVISED.
65
+ applyUpdate: async (announce) => {
66
+ await performSelfUpdate({
67
+ version: announce.version,
68
+ channel: announce.channel === 'git' ? 'git' : 'npm',
69
+ currentVersion: deps.options.version,
70
+ logger: deps.logger,
71
+ });
72
+ },
61
73
  });
62
74
  }
63
75
  get identity() {
@@ -0,0 +1,26 @@
1
+ import type { Logger } from '../types.js';
2
+ export interface SelfUpdateOptions {
3
+ /** Target version — 'latest' or a semver like '2.6.1'. Default 'latest'. */
4
+ version?: string;
5
+ /** npm package to install. Default 'infinicode' (override via INFINICODE_UPDATE_PKG). */
6
+ pkg?: string;
7
+ channel?: 'npm' | 'git' | 'manual';
8
+ /** The version currently running, for the from→to report. */
9
+ currentVersion?: string;
10
+ /** Node runs under a supervisor → safe to exit-for-restart. Falls back to
11
+ * INFINICODE_SUPERVISED=1. */
12
+ supervised?: boolean;
13
+ /** Schedule a restart after a successful install. Defaults to `supervised`. */
14
+ restart?: boolean;
15
+ /** Delay before exiting so the command reply flushes back over the mesh. */
16
+ restartDelayMs?: number;
17
+ logger?: Logger;
18
+ }
19
+ export interface SelfUpdateResult {
20
+ ok: boolean;
21
+ from?: string;
22
+ to: string;
23
+ restarting: boolean;
24
+ message: string;
25
+ }
26
+ export declare function performSelfUpdate(opts?: SelfUpdateOptions): Promise<SelfUpdateResult>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * OpenKernel — Self Update
3
+ *
4
+ * The apply side of mesh updates: install a newer npm release of this package
5
+ * globally, then (if the node runs under a process supervisor) exit so the
6
+ * supervisor relaunches it on the new version. Isolated here so the shell-out
7
+ * lives in exactly one place — the `/update` command and the AutoUpdate gossip
8
+ * path both call this, nothing else shells out for updates.
9
+ *
10
+ * Restart policy is deliberately conservative: a node only exits-for-restart
11
+ * when it is explicitly marked supervised (`serve --supervised`, which sets
12
+ * INFINICODE_SUPERVISED=1). An unsupervised node still installs the new version
13
+ * on disk but stays up on the old one, and says so — killing a hand-started
14
+ * process would just take the node offline with nothing to bring it back.
15
+ */
16
+ import { execa } from 'execa';
17
+ const SAFE_VERSION = /^[A-Za-z0-9._-]+$/; // 'latest', '2.6.1', 'next' — never a shell metachar
18
+ const SAFE_PKG = /^[@A-Za-z0-9._/-]+$/;
19
+ export async function performSelfUpdate(opts = {}) {
20
+ const version = (opts.version ?? 'latest').trim();
21
+ const pkg = (opts.pkg ?? process.env.INFINICODE_UPDATE_PKG ?? 'infinicode').trim();
22
+ const channel = opts.channel ?? 'npm';
23
+ const supervised = opts.supervised ?? process.env.INFINICODE_SUPERVISED === '1';
24
+ const restart = opts.restart ?? supervised;
25
+ const info = (m) => (opts.logger ? opts.logger.info(m) : console.log(' ' + m));
26
+ const warn = (m) => (opts.logger ? opts.logger.warn(m) : console.log(' ' + m));
27
+ if (channel !== 'npm') {
28
+ return { ok: false, to: version, restarting: false, message: `channel "${channel}" is not supported for self-update yet (npm only).` };
29
+ }
30
+ if (!SAFE_VERSION.test(version) || !SAFE_PKG.test(pkg)) {
31
+ return { ok: false, to: version, restarting: false, message: `refused: unsafe update spec "${pkg}@${version}".` };
32
+ }
33
+ const spec = `${pkg}@${version}`;
34
+ info(`[update] installing ${spec} (global)…`);
35
+ try {
36
+ // Use npm.cmd on Windows so we never need a shell (no injection surface).
37
+ const bin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
38
+ const res = await execa(bin, ['install', '-g', spec], { all: true, timeout: 300_000 });
39
+ const tail = String(res.all ?? res.stdout ?? '').split('\n').filter(Boolean).slice(-2).join(' ').trim();
40
+ info(`[update] installed ${spec}${tail ? ' — ' + tail : ''}`);
41
+ }
42
+ catch (err) {
43
+ const detail = err;
44
+ const message = (detail.all || detail.shortMessage || detail.message || String(err)).replace(/\s+/g, ' ').slice(0, 300);
45
+ warn(`[update] npm install failed: ${message}`);
46
+ return { ok: false, from: opts.currentVersion, to: version, restarting: false, message: `npm install -g ${spec} failed: ${message}` };
47
+ }
48
+ let restarting = false;
49
+ if (restart && supervised) {
50
+ restarting = true;
51
+ const delay = opts.restartDelayMs ?? 2000;
52
+ info(`[update] restarting in ${delay}ms for the supervisor to relaunch on ${version}…`);
53
+ const timer = setTimeout(() => process.exit(0), delay);
54
+ timer.unref?.();
55
+ }
56
+ else if (restart && !supervised) {
57
+ warn('[update] installed but NOT restarting — node is not marked supervised (start serve with --supervised). Restart it to activate.');
58
+ }
59
+ return {
60
+ ok: true,
61
+ from: opts.currentVersion,
62
+ to: version,
63
+ restarting,
64
+ message: restarting
65
+ ? `updated to ${spec} — restarting now; the supervisor will relaunch on the new version.`
66
+ : `updated to ${spec} on disk — restart the node to activate${supervised ? '' : ' (not supervised, so no auto-restart)'}.`,
67
+ };
68
+ }