crewbus 10.0.2 → 10.0.4

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.
package/bin/lib/web.js CHANGED
@@ -17,9 +17,9 @@ import path from "node:path";
17
17
  import http from "node:http";
18
18
  import { boardDir, requireBoard, getFlag, fail, writeJson, listJson, cleanWebName, readJson, gitRevForBoard, newId, MAX_SPAWN, MAX_BODY_CHARS } from "./store.js";
19
19
  import { readAgent, agentTokenMatches, authorizeCheck, mintToken, newSalt, hashToken, touchAgent } from "./identity.js";
20
- import { readDMs, readVisible, ackedIds, findMessageById, deliverDMs, parseRecipients } from "./mail.js";
21
- import { workerStatus, pidAlive, killWorkers, bootWorker, buildSpawnPrompt, buildSpawnTarget, formatSpawnCmd } from "./spawn.js";
22
- import { groupTelemetryData } from "./groups.js";
20
+ import { readDMs, readVisible, ackedIds, ackedIdSet, findMessageById, deliverDMs, parseRecipients } from "./mail.js";
21
+ import { workerStatus, pidAlive, killWorkers, bootWorker, buildSpawnPrompt, buildSpawnTarget, formatSpawnCmd, installCliToUserPath, ensureBoardCliShim } from "./spawn.js";
22
+ import { groupTelemetryData, collectBatch, gatherTelemetry, listBatches } from "./groups.js";
23
23
  import { httpJson } from "./sync.js";
24
24
  import { readChainRecords, verifyChainRecords, appendChainRecord, readHold, readBoardQuotas } from "./export.js";
25
25
  import { launchDrivers, detectHarnessBinaries, validateLaunchPlan, advertiseEnv, HARNESS_MODELS, getDiscoveredModels, refreshDiscoveredModels } from "./launch.js";
@@ -32,6 +32,17 @@ export function escapeHtml(s) {
32
32
 
33
33
  export function boardSnapshot(d, activeWindowSec) {
34
34
  const cutoff = Date.now() - activeWindowSec * 1000;
35
+ // Liveness first: pid-bearing agents are active only while their process
36
+ // is verifiably alive (kill-0 + start-time guard inside workerStatus) —
37
+ // a fresh lastSeen alone proves nothing, because watcher heartbeats keep
38
+ // refreshing docs for long-dead sessions. Pid-less agents (humans, CLI
39
+ // peers) fall back to lastSeen freshness. Reuses the workers array below
40
+ // (no extra status syscalls).
41
+ const workers = listJson(d.agents)
42
+ .map((e) => e.data)
43
+ .filter((a) => a && a.name && typeof a.spawnedPid === "number")
44
+ .map((a) => workerStatus(d, a.name, 5));
45
+ const liveByName = new Map(workers.map((w) => [w.name, w.alive === true]));
35
46
  const agents = listJson(d.agents)
36
47
  .map((e) => e.data)
37
48
  .filter((x) => x && x.name)
@@ -42,12 +53,13 @@ export function boardSnapshot(d, activeWindowSec) {
42
53
  const { token, tokenHash, salt, ...safe } = a; // tokens never leave the server
43
54
  return {
44
55
  ...safe,
45
- active: Date.parse(a.lastSeen) >= cutoff,
56
+ active: typeof a.spawnedPid === "number"
57
+ ? liveByName.get(a.name) === true
58
+ : Date.parse(a.lastSeen) >= cutoff,
46
59
  dmCount: visible.length,
47
60
  unacked: visible.filter((m) => !acked.has(m.id)).length,
48
61
  };
49
62
  });
50
- const workers = agents.filter((a) => typeof a.spawnedPid === "number").map((a) => workerStatus(d, a.name, 5));
51
63
  const broadcasts = listJson(d.broadcast || path.join(d.root, "broadcast"))
52
64
  .map((e) => e.data)
53
65
  .filter((b) => b && b.id && b.from)
@@ -100,7 +112,18 @@ export function boardSnapshot(d, activeWindowSec) {
100
112
  .filter((x) => x && x.peer && typeof x.lastOk === "number")
101
113
  .sort((a, b) => String(a.peer).localeCompare(String(b.peer)))
102
114
  .map((p) => ({ peer: p.peer, lastOk: new Date(p.lastOk).toISOString() }));
103
- return { board: d.root, at: new Date().toISOString(), agents, workers, broadcasts, recent: recent.slice(0, 30), ackedBy, groups, peers };
115
+ // Brief batch per worker (for the Crews brief/group column): the batch
116
+ // stamp on the worker's brief DM, else the brief id itself. Built from
117
+ // the full pre-slice message scan so capped feeds never lose the join.
118
+ const batchByBriefId = new Map();
119
+ for (const m of recent) {
120
+ if (m && m.id && !batchByBriefId.has(m.id)) batchByBriefId.set(m.id, m.batch || m.id);
121
+ }
122
+ const workersWithBatch = workers.map((w) => ({
123
+ ...w,
124
+ batch: w.briefId ? (batchByBriefId.get(w.briefId) || w.briefId) : undefined,
125
+ }));
126
+ return { board: d.root, at: new Date().toISOString(), agents, workers: workersWithBatch, broadcasts, recent: recent.slice(0, 30), ackedBy, groups, peers, briefs: listBatches(d) };
104
127
  }
105
128
 
106
129
  // Fleet console snapshots (served by `web`, all open reads like /api/board;
@@ -589,6 +612,7 @@ details.tech-details summary{cursor:pointer;font-weight:500;user-select:none}
589
612
  <div class="pop-path" id="pop-board-path">${e(boardPath)}</div>
590
613
  <button type="button" id="copy-board-btn" class="pop-btn"><svg class="btn-ic" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy Board Path</button>
591
614
  <button type="button" class="pop-btn" id="pop-pair-btn" style="margin-top:8px"><svg class="btn-ic" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg> Pair Mobile Client (QR)…</button>
615
+ <button type="button" class="pop-btn" id="pop-install-cli-btn" style="margin-top:8px"><svg class="btn-ic" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> Install 'crewbus' CLI in PATH</button>
592
616
  </div>
593
617
  </div>
594
618
 
@@ -609,9 +633,10 @@ details.tech-details summary{cursor:pointer;font-weight:500;user-select:none}
609
633
 
610
634
  <div class="sidebar-heading">VIEWS</div>
611
635
  <nav id="approot-nav" aria-label="sections">
612
- <button data-nav="sec-launch" class="active">Launch</button>
613
- <button data-nav="sec-boards">Boards</button>
614
- <button data-nav="sec-crews">Crews</button>
636
+ <button data-nav="sec-launch" class="active">Launch</button>
637
+ <button data-nav="sec-boards">Boards</button>
638
+ <button data-nav="sec-briefs">Briefs</button>
639
+ <button data-nav="sec-crews">Crews</button>
615
640
  <button data-nav="sec-fleet">Fleet</button>
616
641
  <button data-nav="sec-channels">Channels</button>
617
642
  <button data-nav="sec-triage">Triage</button>
@@ -646,8 +671,9 @@ details.tech-details summary{cursor:pointer;font-weight:500;user-select:none}
646
671
  <span>Search commands, workers, sections…</span>
647
672
  <kbd>Ctrl+K</kbd>
648
673
  </button>
649
- <div class="topbar-right">
650
- <label style="display:none">harness <select id="topbar-harness" aria-label="harness quick-pick"></select></label>
674
+ <div class="topbar-right">
675
+ <span id="poll-age" class="dim" title="age of the data on screen (Chrome throttles background tabs — a stale view is otherwise indistinguishable from a live one)"></span>
676
+ <label style="display:none">harness <select id="topbar-harness" aria-label="harness quick-pick"></select></label>
651
677
  <button type="button" id="inspector-toggle-btn" title="Toggle Inspector sidebar">Inspector ◨</button>
652
678
  </div>
653
679
  </header>
@@ -756,15 +782,23 @@ details.tech-details summary{cursor:pointer;font-weight:500;user-select:none}
756
782
  <div class="card"><b id="c-peers">–</b><br>peers</div>
757
783
  </div>
758
784
  <h2>Recent activity</h2>
785
+ <div style="display:flex;gap:8px;margin-bottom:8px"><input id="feed-q" placeholder="filter route / message…" style="flex:1"><select id="feed-type"><option value="">all</option><option value="checkpoint">checkpoints</option><option value="final">final reports</option></select></div>
759
786
  <table><tr><th>id</th><th>route</th><th>message</th></tr><tbody id="recent"></tbody></table>
760
787
  <h2>Broadcasts</h2>
761
788
  <table><tr><th>id</th><th>from</th><th>to</th><th>subject</th><th>body</th></tr><tbody id="bcast"></tbody></table>
762
789
  </section>
763
790
 
791
+ <!-- SECTION: BRIEFS (ONE ROW PER FAN-OUT) -->
792
+ <section id="sec-briefs" data-section="Briefs" class="approot-hidden">
793
+ <h2>Briefs <span class="dim">one row per fan-out — click a row for its gathered thread</span></h2>
794
+ <table><tr><th>brief</th><th>workers</th><th>checkpoints</th><th>finals</th><th>acked</th><th>last activity</th></tr><tbody id="briefs"></tbody></table>
795
+ <div id="brief-thread"></div>
796
+ </section>
797
+
764
798
  <!-- SECTION: CREWS (WORKERS & TEAMS) -->
765
799
  <section id="sec-crews" data-section="Crews" class="approot-hidden">
766
800
  <h2>Workers <button id="killall" class="danger">kill all</button></h2>
767
- <table><tr><th>worker</th><th>state</th><th>pid</th><th>reply</th><th>log tail</th><th></th></tr><tbody id="workers"></tbody></table>
801
+ <table><tr><th>worker</th><th>state</th><th>pid</th><th>brief / groups</th><th>reply</th><th>log tail</th><th></th></tr><tbody id="workers"></tbody></table>
768
802
  <h2>Agents</h2>
769
803
  <table><tr><th>name</th><th>presence</th><th>last seen</th><th>session</th><th>DMs</th><th>unacked</th></tr><tbody id="agents"></tbody></table>
770
804
  <h2>Groups</h2>
@@ -905,6 +939,10 @@ var _lt=document.getElementById('launch-token');
905
939
  if(_lt){_lt.oninput=function(){try{localStorage.setItem('ab-tok',this.value.trim());}catch(_){}var t=document.getElementById('tok');if(t)t.value=this.value.trim();markIdent();};}
906
940
  autoLoadOperatorCreds();
907
941
  function stateOf(w){if(!w.known)return 'unknown';if(w.reply)return w.acked?'done · acked':'done · reply waiting';if(w.alive===true)return 'running';if(w.alive===false)return 'exited · no reply';return 'no pid';}
942
+ var _fq=document.getElementById('feed-q');
943
+ if(_fq){_fq.oninput=function(){renderRecent();};}
944
+ var _ft=document.getElementById('feed-type');
945
+ if(_ft){_ft.onchange=function(){renderRecent()};}
908
946
  async function kill(names){
909
947
  var c=creds();
910
948
  if(!c.from||!c.token){say('set identity + token first');return;}
@@ -920,12 +958,11 @@ async function kill(names){
920
958
  }
921
959
  document.getElementById('killall').onclick=async function(){
922
960
  var rows=window.__workers||[];
923
- var names=rows.filter(function(w){return w.known&&typeof w.pid==='number';}).map(function(w){return w.name;});
924
- if(!names.length){say('no spawned workers');return;}
961
+ var names=rows.filter(function(w){return w.known&&w.alive===true;}).map(function(w){return w.name;});
962
+ if(!names.length){say('no live workers (kill needs a running pid)');return;}
925
963
  kill(names);
926
964
  };
927
- document.getElementById('ackall').onclick=async function(){
928
- var c=creds();
965
+ document.getElementById('ackall').onclick=async function(){ var c=creds();
929
966
  if(!c.from||!c.token){say('set identity + token first');return;}
930
967
  if(!confirm('ack everything unacked for '+c.from+'?'))return;
931
968
  say('acking all for '+c.from+' …');
@@ -936,10 +973,43 @@ document.getElementById('ackall').onclick=async function(){
936
973
  }catch(e){say('ack failed: '+e.message);}
937
974
  refresh();
938
975
  };
976
+ function renderRecent(){
977
+ var q=((document.getElementById('feed-q')||{}).value||'').toLowerCase();
978
+ var t=((document.getElementById('feed-type')||{}).value||'');
979
+ var known=window.__ackedBy||{};
980
+ var rows=(window.__recent||[]).filter(function(m){
981
+ if(t==='checkpoint'&&m.checkpoint!==true)return false;
982
+ if(t==='final'&&!(m.replyTo&&m.checkpoint!==true))return false;
983
+ if(!q)return true;
984
+ var to=Array.isArray(m.to)?m.to.join(','):String(m.to||'');
985
+ return (m.id+' '+(m.from||'')+' '+to+' '+(m.subject||'')+' '+(m.body||'')).toLowerCase().indexOf(q)!==-1;
986
+ });
987
+ document.getElementById('recent').innerHTML=rows.map(function(m){
988
+ var to=Array.isArray(m.to)?m.to.join(','):String(m.to||'');
989
+ var acks=(known[m.id]||[]).map(function(x){return '✓'+x;}).join(' ');
990
+ return '<tr><td>'+esc(m.id)+'</td><td>'+esc(m.from)+' → '+esc(short(to,40))+'</td><td>'+(m.subject?'<b>'+esc(m.subject)+'</b><br>':'')+esc(short(m.body,200))+'<div class="dim">'+(m.replyTo?('re: '+esc(m.replyTo)+' '):'')+(m.batch?('batch '+esc(m.batch)+' '):'')+esc(acks)+'</div></td></tr>';
991
+ }).join('')||'<tr><td colspan="3" class="dim">no messages yet</td></tr>';
992
+ }
993
+ async function showBrief(batch){
994
+ var box=document.getElementById('brief-thread');
995
+ box.innerHTML='<div class="dim">loading thread '+esc(batch)+'…</div>';
996
+ try{
997
+ var r=await fetch('/api/gather?batch='+encodeURIComponent(batch),{cache:'no-store'});
998
+ var g=await r.json();
999
+ if(!r.ok){box.innerHTML='<div class="dim">gather failed: '+esc(g.error||r.status)+'</div>';return;}
1000
+ box.innerHTML='<h3>'+esc(short(batch,32))+' <span class="dim">'+g.briefs+' briefs · '+g.replies+' replies · ~'+g.telemetry.tokensEst+' tok</span></h3>'+g.items.map(function(m){
1001
+ var kind=!m.replyTo?'<b>brief</b>':(m.checkpoint?'<span class="dim">checkpoint</span>':'<b>final'+(m.acked?' ✓':'')+'</b>');
1002
+ var to=Array.isArray(m.to)?m.to.join(','):String(m.to||'');
1003
+ return '<div class="card" style="margin:.5em 0"><div>'+kind+' <b>'+esc(m.from)+'</b> → '+esc(short(to,60))+' <span class="dim">'+esc(ago(m.at))+'</span></div>'+(m.subject?'<div><b>'+esc(m.subject)+'</b></div>':'')+'<div class="log" style="white-space:pre-wrap">'+esc(m.body)+'</div></div>';
1004
+ }).join('');
1005
+ }catch(e){box.innerHTML='<div class="dim">gather failed: '+esc(e.message)+'</div>';}
1006
+ }
939
1007
  async function refresh(){
940
1008
  try{
941
1009
  var r=await fetch('/api/board',{cache:'no-store'});
942
1010
  var s=await r.json();
1011
+ window.__lastBoardPoll=Date.now();
1012
+ updatePollAge();
943
1013
  window.__workers=s.workers;
944
1014
  var active=s.agents.filter(function(a){return a.active;}).length;
945
1015
  var unacked=s.agents.reduce(function(n,a){return n+a.unacked;},0);
@@ -960,22 +1030,28 @@ async function refresh(){
960
1030
  document.getElementById('workers').innerHTML=s.workers.map(function(w){
961
1031
  var tail=(w.tail||[]).slice(-3).map(function(l){return '<div class="log">'+esc(l)+'</div>';}).join('')||'<span class="dim">no log</span>';
962
1032
  var rep=w.reply?esc(w.reply.id)+'<div class="dim">'+esc(w.reply.head)+'</div>':'—';
963
- var btn=(w.known&&typeof w.pid==='number')?'<button class="danger" data-kill="'+esc(w.name)+'">kill</button>':'';
964
- return '<tr data-worker="'+esc(w.name)+'" class="worker-row"><td><b>'+esc(w.name)+'</b><div class="dim">by '+esc(w.spawnedBy||'?')+'</div></td><td>'+esc(stateOf(w))+'</td><td>'+(w.pid===null||w.pid===undefined?'—':esc(String(w.pid)))+'</td><td>'+rep+'</td><td>'+tail+'</td><td>'+btn+'</td></tr>';
965
- }).join('')||'<tr><td colspan="6" class="dim">no spawned workers</td></tr>';
1033
+ var btn=(w.known&&w.alive===true)?'<button class="danger" data-kill="'+esc(w.name)+'">kill</button>':'';
1034
+ var wgroups=(s.groups||[]).filter(function(g){return (g.members||[]).indexOf(w.name)!==-1;}).map(function(g){return g.name;}).join(',');
1035
+ var batch=w.batch||w.briefId||'—';
1036
+ var bcell='<div class="dim">'+esc(short(batch,20))+'</div>'+(wgroups?esc(wgroups):'<span class="dim">solo</span>');
1037
+ return '<tr data-worker="'+esc(w.name)+'" class="worker-row"><td><b>'+esc(w.name)+'</b><div class="dim">by '+esc(w.spawnedBy||'?')+'</div></td><td>'+esc(stateOf(w))+'</td><td>'+(w.pid===null||w.pid===undefined?'—':esc(String(w.pid)))+'</td><td>'+bcell+'</td><td>'+rep+'</td><td>'+tail+'</td><td>'+btn+'</td></tr>';
1038
+ }).join('')||'<tr><td colspan="7" class="dim">no spawned workers</td></tr>';
966
1039
  Array.prototype.forEach.call(document.querySelectorAll('[data-kill]'),function(b){b.onclick=function(){kill([b.getAttribute('data-kill')]);};});
967
1040
  document.getElementById('agents').innerHTML=s.agents.map(function(a){
968
- return '<tr><td><b>'+esc(a.name)+'</b></td><td>'+(a.active?'● active':'○ stale')+'</td><td>'+esc(a.lastSeen||'?')+'</td><td>'+esc(a.sessionId||'—')+'</td><td>'+a.dmCount+'</td><td>'+a.unacked+'</td></tr>';
1041
+ return '<tr><td><b>'+esc(a.name)+'</b></td><td>'+(a.active?'● active':'○ stale')+'</td><td title="'+esc(a.lastSeen||'?')+'">'+esc(ago(a.lastSeen))+'</td><td>'+esc(a.sessionId||'—')+'</td><td>'+a.dmCount+'</td><td>'+a.unacked+'</td></tr>';
969
1042
  }).join('')||'<tr><td colspan="6" class="dim">no agents yet</td></tr>';
970
1043
  document.getElementById('bcast').innerHTML=s.broadcasts.map(function(b){
971
1044
  var to=Array.isArray(b.to)?b.to.join(','):String(b.to||'');
972
1045
  return '<tr><td>'+esc(b.id)+'</td><td>'+esc(b.from)+'</td><td>'+esc(short(to,80))+'</td><td>'+esc(b.subject||'')+'</td><td>'+esc(short(b.body,140))+'</td></tr>';
973
1046
  }).join('')||'<tr><td colspan="5" class="dim">no broadcasts</td></tr>';
974
- document.getElementById('recent').innerHTML=s.recent.map(function(m){
975
- var to=Array.isArray(m.to)?m.to.join(','):String(m.to||'');
976
- var acks=(s.ackedBy[m.id]||[]).map(function(x){return '✓'+x;}).join(' ');
977
- return '<tr><td>'+esc(m.id)+'</td><td>'+esc(m.from)+' → '+esc(short(to,40))+'</td><td>'+(m.subject?'<b>'+esc(m.subject)+'</b><br>':'')+esc(short(m.body,200))+'<div class="dim">'+(m.replyTo?('re: '+esc(m.replyTo)+' '):'')+(m.batch?('batch '+esc(m.batch)+' '):'')+esc(acks)+'</div></td></tr>';
978
- }).join('')||'<tr><td colspan="3" class="dim">no messages yet</td></tr>';
1047
+ window.__recent=s.recent||[];
1048
+ window.__ackedBy=s.ackedBy||{};
1049
+ renderRecent();
1050
+ document.getElementById('briefs').innerHTML=(s.briefs||[]).map(function(b){
1051
+ var ws=(b.workers||[]).join(',');
1052
+ return '<tr data-batch="'+esc(b.batch)+'" class="brief-row"><td>'+(b.subject?'<b>'+esc(b.subject)+'</b><br>':'')+'<span class="dim">'+esc(short(b.batch,24))+' · from '+esc(b.from)+'</span></td><td>'+esc(ws.length>60?ws.slice(0,60)+'…':ws)+'<div class="dim">'+b.briefCount+' brief'+(b.briefCount===1?'':'s')+'</div></td><td>'+b.checkpointCount+'</td><td>'+b.finalCount+'</td><td>'+b.ackedFinals+'/'+b.finalCount+'</td><td>'+esc(ago(b.lastAt))+'</td></tr>';
1053
+ }).join('')||'<tr><td colspan="6" class="dim">no briefs yet</td></tr>';
1054
+ Array.prototype.forEach.call(document.querySelectorAll('[data-batch]'),function(r){r.onclick=function(){showBrief(r.getAttribute('data-batch'));};});
979
1055
  try{
980
1056
  var fr=await fetch('/api/fleet',{cache:'no-store'});
981
1057
  var fl=await fr.json();
@@ -1337,6 +1413,7 @@ function getActionIcon(act){
1337
1413
  case 'openfolder':return '<svg class="pal-ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
1338
1414
  case 'pairmobile':return '<svg class="pal-ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>';
1339
1415
  case 'refresh':return '<svg class="pal-ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>';
1416
+ case 'installcli':return '<svg class="pal-ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
1340
1417
  default:return '';
1341
1418
  }
1342
1419
  }
@@ -1348,6 +1425,7 @@ function paletteSections(){
1348
1425
  {kind:'action',label:'Copy board path',action:'copyboard'},
1349
1426
  {kind:'action',label:'Open project folder…',action:'openfolder'},
1350
1427
  {kind:'action',label:'Pair mobile client (QR)…',action:'pairmobile'},
1428
+ {kind:'action',label:'Install \'crewbus\' CLI in PATH',action:'installcli'},
1351
1429
  {kind:'action',label:'Refresh board state',action:'refresh'},
1352
1430
  {kind:'section',label:'Launch',id:'sec-launch'},
1353
1431
  {kind:'section',label:'Boards',id:'sec-boards'},
@@ -1417,6 +1495,7 @@ function paletteActivate(m){
1417
1495
  try{window.open('./pair.html','_blank','width=420,height=560');}catch(_){}
1418
1496
  }
1419
1497
  else if(m.action==='refresh'){refresh();}
1498
+ else if(m.action==='installcli'){document.getElementById('pop-install-cli-btn').click();}
1420
1499
  }else if(m.kind==='section'){
1421
1500
  approotNavTo(m.id);
1422
1501
  }else{
@@ -1536,7 +1615,7 @@ function showInspector(name){
1536
1615
  +'<div class="dim">reply: '+rep+' · acked: '+esc(w.acked?'yes':'no')+' · session: '+esc(w.harnessSessionId||'—')+'</div>'
1537
1616
  +'<div class="log" style="margin-top:6px">'+tail+'</div>'
1538
1617
  +'<div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap">'
1539
- +((w.known&&typeof w.pid==='number')?'<button class="danger" id="insp-kill">kill</button>':'')
1618
+ +((w.known&&w.alive===true)?'<button class="danger" id="insp-kill">kill</button>':'')
1540
1619
  +(w.reply?'<button id="insp-ack">ack reply</button>':'')
1541
1620
  +'<button id="insp-respawn">respawn (CLI-only)</button></div>'
1542
1621
  +'<div class="dim" id="insp-respawn-cmd" style="margin-top:4px"></div>';
@@ -1647,6 +1726,27 @@ if(ppb){ppb.onclick=function(){
1647
1726
  try{window.parent.postMessage({type:'crewbus:open-pair'},'*');}catch(_){}
1648
1727
  try{window.open('./pair.html','_blank','width=420,height=560');}catch(_){}
1649
1728
  };}
1729
+ var picb=document.getElementById('pop-install-cli-btn');
1730
+ if(picb){picb.onclick=async function(){
1731
+ picb.disabled=true;
1732
+ picb.textContent='Installing CLI…';
1733
+ try{
1734
+ var r=await fetch('/api/install-cli',{method:'POST'});
1735
+ var j=await r.json();
1736
+ if(j&&j.ok){
1737
+ say('CLI installed! '+(j.detail||''));
1738
+ picb.textContent='CLI Installed (in PATH)';
1739
+ }else{
1740
+ say('Failed to install CLI: '+(j&&j.error||'unknown error'));
1741
+ picb.textContent='Install Failed — Retry';
1742
+ picb.disabled=false;
1743
+ }
1744
+ }catch(e){
1745
+ say('Install error: '+e.message);
1746
+ picb.textContent='Install Failed — Retry';
1747
+ picb.disabled=false;
1748
+ }
1749
+ };}
1650
1750
  document.getElementById('project-trigger').onclick=function(e){
1651
1751
  e.stopPropagation();
1652
1752
  var pop=document.getElementById('project-popover');
@@ -1726,9 +1826,15 @@ document.getElementById('launch-brief').addEventListener('keydown',function(e){
1726
1826
  document.getElementById('launch-go').click();
1727
1827
  }
1728
1828
  });
1729
- loadLaunchMeta();
1730
- refresh();
1731
- setInterval(refresh,5000);
1829
+ loadLaunchMeta();
1830
+ refresh();
1831
+ setInterval(refresh,5000);
1832
+ // Data-age indicator: background tabs get their 5s poll throttled, so a
1833
+ // stale view must say so instead of passing for live (see poll-age).
1834
+ function agoMs(ms){var s=Math.max(0,Math.floor(ms/1000));if(s<60)return s+'s';var m=Math.floor(s/60);if(m<60)return m+'m';return Math.floor(m/60)+'h';}
1835
+ function ago(iso){try{var t=Date.parse(iso);if(isNaN(t))return '?';return agoMs(Date.now()-t)+' ago';}catch(e){return '?';}}
1836
+ function updatePollAge(){var el=document.getElementById('poll-age');if(!el)return;if(!window.__lastBoardPoll){el.textContent='not updated yet';return;}var age=Date.now()-window.__lastBoardPoll;var stale=age>15000;el.textContent=(stale?'STALE · ':'')+'updated '+agoMs(age)+' ago';el.style.color=stale?'var(--danger)':'';}
1837
+ setInterval(updatePollAge,1000);
1732
1838
  </script>
1733
1839
  </body></html>`;
1734
1840
  }
@@ -1824,12 +1930,40 @@ export async function handleWebDashboardRoute(req, res, url, d) {
1824
1930
  id: m.id, from: m.from, at: m.at, subject: m.subject || "",
1825
1931
  head: String(m.body || "").slice(0, 160),
1826
1932
  replyTo: m.replyTo || "", batch: m.batch || "",
1933
+ checkpoint: m.checkpoint === true,
1827
1934
  artifact: m.artifact || "", acked: known.has(m.id),
1828
1935
  }));
1829
1936
  res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1830
1937
  res.end(JSON.stringify({ board: d.root, agent, items }));
1831
1938
  return true;
1832
1939
  }
1940
+ if (req.method === "GET" && url.pathname === "/api/gather") {
1941
+ // Open read like /api/board (the transcript is already visible piece
1942
+ // by piece there); the dashboard twin of `gather --batch`: one brief
1943
+ // thread — briefs, checkpoint timeline, final reports — oldest first.
1944
+ const batch = String(url.searchParams.get("batch") || "").trim().slice(0, 200);
1945
+ if (!batch) {
1946
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
1947
+ res.end(JSON.stringify({ error: "pass ?batch=<batch-id>" }));
1948
+ return true;
1949
+ }
1950
+ const got = collectBatch(d, batch);
1951
+ if (!got) {
1952
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
1953
+ res.end(JSON.stringify({ error: `unknown batch "${batch}"` }));
1954
+ return true;
1955
+ }
1956
+ const known = ackedIdSet(d);
1957
+ const tele = gatherTelemetry(got.items);
1958
+ const items = got.items.map((m) => ({
1959
+ id: m.id, from: m.from, to: m.to, at: m.at, subject: m.subject || "",
1960
+ body: String(m.body || ""), replyTo: m.replyTo || "", batch: m.batch || "",
1961
+ checkpoint: m.checkpoint === true, acked: known.has(m.id),
1962
+ }));
1963
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1964
+ res.end(JSON.stringify({ board: d.root, batch, briefs: got.briefs, replies: got.replies, telemetry: tele, items }));
1965
+ return true;
1966
+ }
1833
1967
  if (req.method === "POST" && url.pathname === "/api/ack") {
1834
1968
  // JSON-only like /api/kill (plain browser forms can't reach it),
1835
1969
  // token-checked like the CLI, no --verify over HTTP.
@@ -1889,12 +2023,31 @@ export async function handleWebDashboardRoute(req, res, url, d) {
1889
2023
  res.end(JSON.stringify({ ok: false, error: "no lead.token found" }));
1890
2024
  return true;
1891
2025
  }
2026
+ if (req.method === "POST" && url.pathname === "/api/install-cli") {
2027
+ const ip = req.socket.remoteAddress || "";
2028
+ const isLoop = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
2029
+ if (!isLoop) {
2030
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
2031
+ res.end(JSON.stringify({ error: "loopback only" }));
2032
+ return true;
2033
+ }
2034
+ try {
2035
+ const resInstall = installCliToUserPath();
2036
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
2037
+ res.end(JSON.stringify({ ok: true, ...resInstall }));
2038
+ } catch (err) {
2039
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
2040
+ res.end(JSON.stringify({ ok: false, error: String(err && err.message ? err.message : err) }));
2041
+ }
2042
+ return true;
2043
+ }
1892
2044
  return false;
1893
2045
  }
1894
2046
 
1895
2047
  export async function cmdWeb(args) {
1896
2048
  const root = boardDir(args);
1897
2049
  const d = requireBoard(root);
2050
+ ensureBoardCliShim(d);
1898
2051
  const host = getFlag(args, "--host") || "127.0.0.1";
1899
2052
  const port = Number(getFlag(args, "--port") || 0);
1900
2053
  if (!(port >= 0 && port < 65536)) fail("--port must be 0-65535 (0 = random)");
package/docs/GROUPS.md CHANGED
@@ -1,77 +1,107 @@
1
- # Groups, outcomes, races (Review §4.3)
2
-
3
- Groups are named recipient sets for variant briefs. Outcomes are native
4
- records — the interim workaround (one group per approach + `RESULT:`
5
- subjects + a verifier agent + a `gather --json` script + `spawn-kill`)
6
- is replaced by the commands below.
7
-
8
- ## Curate + brief + reduce
9
-
10
- ```sh
11
- crewbus group create team-a --add a1,a2,a3
12
- crewbus send --from lead --to-group team-a --subject "variant: regularity" --body "..."
13
- crewbus gather --batch <batch> # briefs + replies + telemetry footer + contributing groups
14
- ```
15
-
16
- `gather --batch` prints a telemetry footer
17
- (`msgs, ~tokens (chars/4), contributing groups`) and the same fields in
18
- `--json` (`contributingGroups`, `telemetry`).
19
-
20
- ## Replies carry artifacts
21
-
22
- ```sh
23
- crewbus send --from e1 --to lead --reply <brief-id> --artifact out/a1.json --body "done"
24
- ```
25
-
26
- `--artifact <path-or-url>` (max 500 chars) is stored on the message and
27
- shown by `inbox` / `gather` / `thread`.
28
-
29
- ## Verify, then record the result
30
-
31
- ```sh
32
- crewbus ack --from lead --id <reply-id> --verify "node test/check.mjs --input out/a1.json"
33
- crewbus result record --group team-a --msg <reply-id> --artifact out/a1.json --from lead
34
- crewbus result show --group team-a
35
- crewbus result list [--group team-a] [--json]
36
- ```
37
-
38
- `results/<group>.json` holds ONE record
39
- `{group, artifact, by, msgId, verifierOutput, at}` — first verified
40
- result wins. Only verified messages are recordable: the ack marker
41
- `acked/*/<msgId>.json` must carry `verified:true`, else recording fails
42
- unless `--force` (which prints a warning to stderr).
43
-
44
- ## Race mode
45
-
46
- ```sh
47
- crewbus race start --group team-a --batch <batch> [--timeout 60000] [--json]
48
- crewbus race close --group team-a --from lead [--kill]
49
- ```
50
-
51
- `race start` reports the winner (recorded result whose `msgId` is in the
52
- batch, else the first verified ack on a batch reply) or "no verified
53
- result yet". `--timeout` polls instead of checking once; there is no
54
- daemon. `race close` DMs "race closed by X" to every other member and,
55
- with `--kill`, terminates their spawned pids.
56
-
57
- ## Status + telemetry
58
-
59
- ```sh
60
- crewbus group status team-a [--json] # running (pid alive), replies, verified, spend
61
- crewbus group telemetry team-a [--json] # {messages, tokensEst, wallClockMs, members, verifiedCount}
62
- ```
63
-
64
- Spend = message count + wall-clock since the group `createdAt` +
65
- token estimate (`chars/4` over member bodies+subjects). `createdAt` is
66
- backfilled from the group file mtime for pre-existing groups.
67
-
68
- ## Attribution (agents in several groups)
69
-
70
- - A reply attributes via its chain: `--reply <msg-id>` → root brief →
71
- batch → group(s) whose members appear in the transcript.
72
- - `gather --batch` shows `contributing groups` (groups with ≥1 message
73
- from a member in the transcript).
74
- - `result record` **requires `--group` explicitly** — there is no
75
- auto-assign. When an agent is in several groups, the recorder decides
76
- which group the result counts for.
77
-
1
+ # Groups, outcomes, races (Review §4.3)
2
+
3
+ Groups are named recipient sets for variant briefs. Outcomes are native
4
+ records — the interim workaround (one group per approach + `RESULT:`
5
+ subjects + a verifier agent + a `gather --json` script + `spawn-kill`)
6
+ is replaced by the commands below.
7
+
8
+ ## Curate + brief + reduce
9
+
10
+ ```sh
11
+ crewbus group create team-a --add a1,a2,a3
12
+ crewbus send --from lead --to-group team-a --subject "variant: regularity" --body "..."
13
+ crewbus gather --batch <batch> # briefs + replies + telemetry footer + contributing groups
14
+ ```
15
+
16
+ `gather --batch` prints a telemetry footer
17
+ (`msgs, ~tokens (chars/4), contributing groups`) and the same fields in
18
+ `--json` (`contributingGroups`, `telemetry`).
19
+
20
+ ## Replies carry artifacts
21
+
22
+ ```sh
23
+ crewbus send --from e1 --to lead --reply <brief-id> --artifact out/a1.json --body "done"
24
+ ```
25
+
26
+ `--artifact <path-or-url>` (max 500 chars) is stored on the message and
27
+ shown by `inbox` / `gather` / `thread`.
28
+
29
+ ## Verify, then record the result
30
+
31
+ ```sh
32
+ crewbus ack --from lead --id <reply-id> --verify "node test/check.mjs --input out/a1.json"
33
+ crewbus result record --group team-a --msg <reply-id> --artifact out/a1.json --from lead
34
+ crewbus result show --group team-a
35
+ crewbus result list [--group team-a] [--json]
36
+ ```
37
+
38
+ `results/<group>.json` holds ONE record
39
+ `{group, artifact, by, msgId, verifierOutput, at}` — first verified
40
+ result wins. Only verified messages are recordable: the ack marker
41
+ `acked/*/<msgId>.json` must carry `verified:true`, else recording fails
42
+ unless `--force` (which prints a warning to stderr).
43
+
44
+ ## Race mode
45
+
46
+ ```sh
47
+ crewbus race start --group team-a --batch <batch> [--timeout 60000] [--json]
48
+ crewbus race close --group team-a --from lead [--kill]
49
+ ```
50
+
51
+ `race start` reports the winner (recorded result whose `msgId` is in the
52
+ batch, else the first verified ack on a batch reply) or "no verified
53
+ result yet". `--timeout` polls instead of checking once; there is no
54
+ daemon. `race close` DMs "race closed by X" to every other member and,
55
+ with `--kill`, terminates their spawned pids.
56
+
57
+ ## Status + telemetry
58
+
59
+ ```sh
60
+ crewbus group status team-a [--json] # running (pid alive), replies, verified, spend
61
+ crewbus group telemetry team-a [--json] # {messages, tokensEst, wallClockMs, members, verifiedCount}
62
+ ```
63
+
64
+ Spend = message count + wall-clock since the group `createdAt` +
65
+ token estimate (`chars/4` over member bodies+subjects). `createdAt` is
66
+ backfilled from the group file mtime for pre-existing groups.
67
+
68
+ ## Attribution (agents in several groups)
69
+
70
+ - A reply attributes via its chain: `--reply <msg-id>` → root brief →
71
+ batch → group(s) whose members appear in the transcript.
72
+ - `gather --batch` shows `contributing groups` (groups with ≥1 message
73
+ from a member in the transcript).
74
+ - `result record` **requires `--group` explicitly** — there is no
75
+ auto-assign. When an agent is in several groups, the recorder decides
76
+ which group the result counts for.
77
+
78
+ ## Cross-group collaboration
79
+
80
+ Groups share one board, so any member can already read anything. The
81
+ thing that decides whether groups actually collaborate is whether the
82
+ brief tells them the map. Since v10.0.x every `spawn` brief carries a
83
+ **Crew:** block (see `buildCrewContext` in `bin/lib/spawn.js`): your
84
+ group(s) + members + lane, the sibling groups of the same spawn +
85
+ members, their group channels, and the peer-DM norm. Groups from
86
+ unrelated concurrent briefs are deliberately NOT enumerated — they
87
+ collapse to a count (`N other groups exist on this board`), so
88
+ cross-task rosters stay discoverable (`crewbus group list`) without
89
+ becoming prompt noise or a misleading invitation. Conventions that
90
+ block assumes:
91
+
92
+ - **Peer-direct for questions.** `crewbus send --from you --to-group
93
+ <group> --subject "..." --body "..."` reaches every member of
94
+ another group. Ask the group that owns a thing first; the lead is
95
+ for approvals (`--priority high`) and scope disputes only.
96
+ - **Channels are the shared read surface.** `crewbus channel tail
97
+ grp-<group>` shows a group's briefs + mirrored progress; reads need
98
+ no token. Channel *posting* is lead|admin-only, so workers write
99
+ via DMs, never via `channel post`.
100
+ - **Boot habit.** `crewbus agents` + `crewbus group list` once at
101
+ start, so a worker knows who else is here. A worker that never runs
102
+ a discovery command will work star-topology through the lead — that
103
+ is a failure of the brief, not the worker.
104
+ - **Scope questions stay lanes-first.** "Stay in your lane" prevents
105
+ collisions; "DM the group that owns a thing" prevents silos. A
106
+ brief should name both the boundary *and* the bridge.
107
+