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/AGENTS.template.md +1 -1
- package/CHANGELOG.md +41 -0
- package/README.md +9 -2
- package/bin/crewbus.js +150 -55
- package/bin/lib/groups.js +98 -2
- package/bin/lib/identity.js +22 -26
- package/bin/lib/mail.js +26 -24
- package/bin/lib/spawn.js +102 -29
- package/bin/lib/store.js +3 -1
- package/bin/lib/web.js +182 -29
- package/docs/GROUPS.md +107 -77
- package/docs/TROUBLESHOOTING.md +26 -25
- package/docs/VERSION_SKEW.md +1 -1
- package/documentation.html +3 -3
- package/index.html +5 -5
- package/opencode/plugins/dm-watch.js +38 -18
- package/opencode/tools/dm-send.js +14 -6
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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
|
-
|
|
924
|
-
|
|
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&&
|
|
964
|
-
|
|
965
|
-
|
|
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
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
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
|
+
|