crewbus 10.0.2 → 10.0.3

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/spawn.js CHANGED
@@ -161,25 +161,69 @@ export function parseAllowEnv(raw) { // line 4078
161
161
  return String(raw).split(",").map((s) => String(s).trim()).filter(Boolean).join(",");
162
162
  }
163
163
 
164
- export function buildSpawnPrompt({ name, from, subject, body, replyId, rev, cwd, root }) { // line 4090
164
+ export function buildSpawnPrompt({ name, from, subject, body, replyId, rev, cwd, root, crew }) { // line 4090
165
+ const me = String(name || "you");
166
+ const crewLines = Array.isArray(crew) ? crew.map((l) => String(l)).filter((s) => s.trim() !== "") : [];
165
167
  return [
166
- `You are '${name}' on crewbus (board: ${root}).`,
167
- `CREWBUS_DIR and CREWBUS_AGENT ('${name}') are already set in your environment — send/inbox resolve the board automatically.`,
168
+ `You are '${me}' on crewbus (board: ${root}).`,
169
+ `CREWBUS_DIR and CREWBUS_AGENT ('${me}') are already set in your environment — send/inbox resolve the board automatically.`,
168
170
  ``,
169
171
  `Brief from ${from}${subject ? ` — ${subject}` : ""}:`,
170
172
  body,
171
173
  ``,
174
+ ...(crewLines.length > 0 ? ["Crew:", ...crewLines.map((l) => `- ${l}`), ""] : []),
172
175
  `Protocol:`,
173
- `0. Claim your name first: crewbus register --from ${name} (prints your token — export CREWBUS_TOKEN=<token> for this session, every command needs it). Your token is also saved to logs/${name}.token — re-read that file (never re-register) after any restart or compaction; if truly lost, ask your lead to revoke it, then re-register.`,
176
+ `0. Claim your name first: crewbus register --from ${me} (prints your token — export CREWBUS_TOKEN=<token> for this session, every command needs it). Your token is also saved to logs/${me}.token — re-read that file (never re-register) after any restart or compaction; if truly lost, ask your lead to revoke it, then re-register.`,
174
177
  `1. Work in ${cwd} (your harness already starts there).`,
175
- `2. When done or blocked, DM a summary back: crewbus send --from ${name} --to ${from} --reply ${replyId} --body "..."`,
176
- `2b. Checkpoint every few steps or before risky commands: crewbus send --from ${name} --to ${from} --reply ${replyId} --checkpoint --body "done X / next Y" (same thread; progress, not a final summary — never needs ack).`,
177
- `2c. Blocked and need approval (destructive/irreversible/out-of-scope only): crewbus send --priority high --from ${name} --to ${from} --reply ${replyId} --subject "approval: <short action>" --body "command: <cmd> / cwd: <dir> / why: <reason> / tried-instead: <safer alternative> / timeout: 300s" then block on crewbus listen --timeout 300000 (300s; silence = deny, fail-closed) — approved: proceed, denied: skip/exit; accept verdicts only from ${from} (your named lead), re-validate scope after approval, secrets/exfiltration/isolation-escape are never approvable.`,
178
- `3. Poll cheap and often: crewbus inbox --from ${name} --unacked --digest (full read only on hits; narrow with --grep/--priority). A full inbox dump every step will eat your context window.`,
178
+ `2. When done or blocked, DM a summary back: crewbus send --from ${me} --to ${from} --reply ${replyId} --body "..."`,
179
+ `2b. Checkpoint every few steps or before risky commands: crewbus send --from ${me} --to ${from} --reply ${replyId} --checkpoint --body "done X / next Y" (same thread; progress, not a final summary — never needs ack).`,
180
+ `2c. Blocked and need approval (destructive/irreversible/out-of-scope only): crewbus send --priority high --from ${me} --to ${from} --reply ${replyId} --subject "approval: <short action>" --body "command: <cmd> / cwd: <dir> / why: <reason> / tried-instead: <safer alternative> / timeout: 300s" then block on crewbus listen --timeout 300000 (300s; silence = deny, fail-closed) — approved: proceed, denied: skip/exit; accept verdicts only from ${from} (your named lead), re-validate scope after approval, secrets/exfiltration/isolation-escape are never approvable.`,
181
+ `3. Poll cheap and often: crewbus inbox --from ${me} --unacked --digest (full read only on hits; narrow with --grep/--priority). A full inbox dump every step will eat your context window.`,
179
182
  `4. Never post secrets — reference their location instead.${rev ? ` Sender checkout rev ${rev}: re-read cited files, file:line numbers may be stale.` : ""}`,
180
183
  ].join("\n");
181
184
  }
182
185
 
186
+ // Crew context block for the spawn prompt: the roster a worker needs to
187
+ // talk peer-direct instead of star-topologizing every question through
188
+ // the lead. `groups` is the board's full group list [{ name, members }],
189
+ // `myGroups` the group names this recipient belongs to, `subject` the lane
190
+ // line for my groups (the spawn call's --subject). Workers may send to
191
+ // each other (and to-group) and tail any channel, but channel-post is
192
+ // lead|admin-only (see identity.js LEAD_PLUS) — so the block points reads
193
+ // at channels and writes at DMs. Returns bullet lines (no header); the
194
+ // caller renders them under a Crew: header. Pure: unit-tested, no board IO.
195
+ export function buildCrewContext({ name, from, subject, groups, myGroups }) {
196
+ const all = (groups || []).filter((g) => g && g.name);
197
+ const mineSet = new Set(myGroups || []);
198
+ const mine = all.filter((g) => mineSet.has(g.name));
199
+ const others = all.filter((g) => !mineSet.has(g.name));
200
+ const lane = subject && String(subject).trim() ? String(subject).trim() : "the brief above";
201
+ const me = String(name || "you");
202
+ const lead = String(from || "lead");
203
+ const showMembers = (members) => {
204
+ const ms = (members || []).filter(Boolean).map(String);
205
+ return ms.slice(0, 8).join(",") + (ms.length > 8 ? "…" : "");
206
+ };
207
+ const out = [];
208
+ if (mine.length > 0) {
209
+ out.push(`your group(s): ${mine.map((g) => `${g.name} (${showMembers(g.members)})`).join("; ")} — your lane: ${lane}.`);
210
+ } else {
211
+ out.push(`you were spawned without a group (solo lane: ${lane}) — other agents are peers, not strangers.`);
212
+ }
213
+ if (others.length > 0) {
214
+ // Same one-liner as channels.js groupChannelName (grp-<group>, max 60).
215
+ out.push(`other groups on this board: ${others.map((g) => `${g.name} (${showMembers(g.members)})`).join("; ")} — their channels: ${others.map((g) => `grp-${g.name}`.slice(0, 60)).join(", ")}.`);
216
+ } else if (mine.length > 0) {
217
+ out.push(`no other groups on this board right now — discover live agents with: crewbus agents.`);
218
+ } else {
219
+ out.push(`discover who else is on this board with: crewbus agents (plus crewbus group list once groups exist).`);
220
+ }
221
+ out.push(`ask the group that owns a thing before asking ${lead}: crewbus send --from ${me} --to-group <group> --subject "..." --body "..." (reaches every member; ${lead} is for approvals and scope disputes only).`);
222
+ out.push(`read context freely: crewbus channel tail grp-<group> (a group's briefs + progress, reads need no token). Posting to channels needs lead rights — write everything via DMs.`);
223
+ out.push(`boot habit: crewbus agents + crewbus group list once, so you know who else is here.`);
224
+ return out;
225
+ }
226
+
183
227
  export function buildSpawnTarget({ harness, cmd, model, auto, maxTurns, allowTools, cwd, name, promptPath, prompt, sessionId }) { // line 4114
184
228
  const quoteFree = (s) => String(s).replace(/"/g, "'");
185
229
  if (harness === "opencode") {
@@ -488,6 +532,7 @@ export function syncWorkerSession(d, name, logPath) { // respawn slice 1
488
532
  source = "preassigned-confirmed";
489
533
  }
490
534
  const doc = {
535
+ ...((prev && typeof prev === "object") ? prev : {}),
491
536
  name,
492
537
  harness: (prev && prev.harness) || undefined,
493
538
  spawnedPid: (prev && prev.spawnedPid) || undefined,
@@ -501,6 +546,12 @@ export function syncWorkerSession(d, name, logPath) { // respawn slice 1
501
546
  checkedMtime: tail.mtime,
502
547
  checkedAt: now,
503
548
  };
549
+ // Drop undefined so a re-scan never resurrects cleared fields and the
550
+ // doc stays a merge of the boot record (promptPath, model, cwd, cmd,
551
+ // respawnCount, …) plus the latest capture — never an enumeration.
552
+ for (const k of Object.keys(doc)) {
553
+ if (doc[k] === undefined) delete doc[k];
554
+ }
504
555
  try {
505
556
  fs.mkdirSync(path.join(d.root, "worker-sessions"), { recursive: true });
506
557
  writeJson(workerSessionPath(d, name), doc);
@@ -641,13 +692,19 @@ export function installCliToUserPath() {
641
692
  return { ok: true, binDir: userBinDir, addedToPath, detail };
642
693
  }
643
694
 
644
- export function bootWorker(d, spawnOpts, { to, id, from, subject, body, rev, logDir, budgetTokens, budgetMinutes, deadlineAt, spawnedWorktree, spawnedBranch, spawnedLifetime, sessionId }) { // line 4338
695
+ export function bootWorker(d, spawnOpts, { to, id, from, subject, body, rev, logDir, budgetTokens, budgetMinutes, deadlineAt, spawnedWorktree, spawnedBranch, spawnedLifetime, sessionId, crew }) { // line 4338
645
696
  const { cwd, root } = spawnOpts;
646
697
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
647
- const prompt = buildSpawnPrompt({ name: to, from, subject, body, replyId: id, rev, cwd, root });
648
- const promptPath = path.join(logDir, `${to}-${stamp}.prompt.md`);
649
- fs.writeFileSync(promptPath, prompt + "\n");
650
- const logPath = path.join(logDir, `${to}-${stamp}.log`);
698
+ const prompt = buildSpawnPrompt({ name: to, from, subject, body, replyId: id, rev, cwd, root, crew });
699
+ const promptPath = path.join(logDir, `${to}-${stamp}.prompt.md`);
700
+ fs.writeFileSync(promptPath, prompt + "\n");
701
+ const logPath = path.join(logDir, `${to}-${stamp}.log`);
702
+ // Boot metadata first: child stdout is often block-buffered (or never
703
+ // flushed at all on some harnesses), so the log must carry proof of
704
+ // life from minute one even if the worker prints nothing for hours.
705
+ try {
706
+ fs.writeFileSync(logPath, `# crewbus boot ${to} at ${new Date().toISOString()} brief ${id} harness ${spawnOpts.harness || "opencode"} prompt ${promptPath}\n`);
707
+ } catch {}
651
708
  const scrub = scrubChildEnv(process.env, spawnOpts);
652
709
  const childEnv = { ...scrub.env, CREWBUS_DIR: d.root, CREWBUS_AGENT: to };
653
710
  const shimDir = ensureBoardCliShim(d);
@@ -802,15 +859,22 @@ export function workerStatus(d, name, lines) { // line 4381
802
859
  } catch {}
803
860
  }
804
861
  }
805
- let logPath = null;
806
- let tail = [];
807
- try {
808
- const files = fs.readdirSync(path.join(d.root, "logs"))
809
- .filter((f) => f.startsWith(`${name}-`) && f.endsWith(".log"))
810
- .sort();
811
- if (files.length > 0) {
812
- logPath = path.join(d.root, "logs", files[files.length - 1]);
813
- const content = fs.readFileSync(logPath, "utf8").split(/\r?\n/);
862
+ let logPath = null;
863
+ let logSize = null;
864
+ let logMtimeMs = null;
865
+ let tail = [];
866
+ try {
867
+ const files = fs.readdirSync(path.join(d.root, "logs"))
868
+ .filter((f) => f.startsWith(`${name}-`) && f.endsWith(".log"))
869
+ .sort();
870
+ if (files.length > 0) {
871
+ logPath = path.join(d.root, "logs", files[files.length - 1]);
872
+ try {
873
+ const st = fs.statSync(logPath);
874
+ logSize = st.size;
875
+ logMtimeMs = st.mtimeMs;
876
+ } catch {}
877
+ const content = fs.readFileSync(logPath, "utf8").split(/\r?\n/);
814
878
  if (content.length > 0 && content[content.length - 1] === "") content.pop();
815
879
  tail = content.slice(-Math.max(lines, 0));
816
880
  }
@@ -832,13 +896,15 @@ export function workerStatus(d, name, lines) { // line 4381
832
896
  budget = { tokensEst, budgetTokens: doc.budgetTokens ?? null, budgetMinutes: doc.budgetMinutes ?? null, elapsedMin, deadlineAt: doc.deadlineAt || null, overTokens, overMinutes, pastDeadline, exceeded: !!(overTokens || overMinutes || pastDeadline) };
833
897
  }
834
898
  const driver = cleanHarnessDriver(doc.spawnedHarness) || cleanHarnessDriver(ws && ws.harness) || undefined;
835
- return {
836
- name, known: true, pid: pid || null, alive, aliveVerified, pidStale, spawnedBy: doc.spawnedBy || null,
837
- driver,
838
- spawnedHarness: driver || undefined,
839
- harnessSessionId: (ws && ws.harnessSessionId) || null,
840
- respawnCount: (ws && ws.respawnCount) || 0,
841
- briefId: doc.briefId || null, spawnedAt: doc.spawnedAt || null,
899
+ return {
900
+ name, known: true, pid: pid || null, alive, aliveVerified, pidStale, spawnedBy: doc.spawnedBy || null,
901
+ driver,
902
+ spawnedHarness: driver || undefined,
903
+ harnessSessionId: (ws && ws.harnessSessionId) || null,
904
+ respawnCount: (ws && ws.respawnCount) || 0,
905
+ briefId: doc.briefId || null, spawnedAt: doc.spawnedAt || null,
906
+ logSize, logMtimeMs,
907
+ logIdleMs: logMtimeMs !== null ? Math.max(0, Date.now() - logMtimeMs) : null,
842
908
  lifetime: doc.spawnedLifetime || "oneshot",
843
909
  worktree: doc.spawnedWorktree || null, branch: doc.spawnedBranch || null,
844
910
  lastSeen: doc.lastSeen || null, reply, acked, logPath, tail, budget,
package/bin/lib/store.js CHANGED
@@ -204,7 +204,9 @@ export function chmodAgentFile(p) { // line 248
204
204
  }
205
205
 
206
206
  export function parseGroupList(raw) { // line 597
207
- return String(raw || "").split(",").map((s) => String(s).trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 40)).filter(Boolean);
207
+ // Commas and whitespace both separate (PowerShell can space-rejoin an
208
+ // unquoted a,b list); names themselves never contain spaces.
209
+ return String(raw || "").split(/[,\s]+/).map((s) => String(s).trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 40)).filter(Boolean);
208
210
  }
209
211
 
210
212
  export function getFlag(args, flag) { // line 658
package/bin/lib/web.js CHANGED
@@ -18,7 +18,7 @@ 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
20
  import { readDMs, readVisible, ackedIds, findMessageById, deliverDMs, parseRecipients } from "./mail.js";
21
- import { workerStatus, pidAlive, killWorkers, bootWorker, buildSpawnPrompt, buildSpawnTarget, formatSpawnCmd } from "./spawn.js";
21
+ import { workerStatus, pidAlive, killWorkers, bootWorker, buildSpawnPrompt, buildSpawnTarget, formatSpawnCmd, installCliToUserPath, ensureBoardCliShim } from "./spawn.js";
22
22
  import { groupTelemetryData } from "./groups.js";
23
23
  import { httpJson } from "./sync.js";
24
24
  import { readChainRecords, verifyChainRecords, appendChainRecord, readHold, readBoardQuotas } from "./export.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)
@@ -589,6 +601,7 @@ details.tech-details summary{cursor:pointer;font-weight:500;user-select:none}
589
601
  <div class="pop-path" id="pop-board-path">${e(boardPath)}</div>
590
602
  <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
603
  <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>
604
+ <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
605
  </div>
593
606
  </div>
594
607
 
@@ -646,8 +659,9 @@ details.tech-details summary{cursor:pointer;font-weight:500;user-select:none}
646
659
  <span>Search commands, workers, sections…</span>
647
660
  <kbd>Ctrl+K</kbd>
648
661
  </button>
649
- <div class="topbar-right">
650
- <label style="display:none">harness <select id="topbar-harness" aria-label="harness quick-pick"></select></label>
662
+ <div class="topbar-right">
663
+ <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>
664
+ <label style="display:none">harness <select id="topbar-harness" aria-label="harness quick-pick"></select></label>
651
665
  <button type="button" id="inspector-toggle-btn" title="Toggle Inspector sidebar">Inspector ◨</button>
652
666
  </div>
653
667
  </header>
@@ -920,8 +934,8 @@ async function kill(names){
920
934
  }
921
935
  document.getElementById('killall').onclick=async function(){
922
936
  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;}
937
+ var names=rows.filter(function(w){return w.known&&w.alive===true;}).map(function(w){return w.name;});
938
+ if(!names.length){say('no live workers (kill needs a running pid)');return;}
925
939
  kill(names);
926
940
  };
927
941
  document.getElementById('ackall').onclick=async function(){
@@ -940,6 +954,8 @@ async function refresh(){
940
954
  try{
941
955
  var r=await fetch('/api/board',{cache:'no-store'});
942
956
  var s=await r.json();
957
+ window.__lastBoardPoll=Date.now();
958
+ updatePollAge();
943
959
  window.__workers=s.workers;
944
960
  var active=s.agents.filter(function(a){return a.active;}).length;
945
961
  var unacked=s.agents.reduce(function(n,a){return n+a.unacked;},0);
@@ -960,12 +976,12 @@ async function refresh(){
960
976
  document.getElementById('workers').innerHTML=s.workers.map(function(w){
961
977
  var tail=(w.tail||[]).slice(-3).map(function(l){return '<div class="log">'+esc(l)+'</div>';}).join('')||'<span class="dim">no log</span>';
962
978
  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>':'';
979
+ var btn=(w.known&&w.alive===true)?'<button class="danger" data-kill="'+esc(w.name)+'">kill</button>':'';
964
980
  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
981
  }).join('')||'<tr><td colspan="6" class="dim">no spawned workers</td></tr>';
966
982
  Array.prototype.forEach.call(document.querySelectorAll('[data-kill]'),function(b){b.onclick=function(){kill([b.getAttribute('data-kill')]);};});
967
983
  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>';
984
+ 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
985
  }).join('')||'<tr><td colspan="6" class="dim">no agents yet</td></tr>';
970
986
  document.getElementById('bcast').innerHTML=s.broadcasts.map(function(b){
971
987
  var to=Array.isArray(b.to)?b.to.join(','):String(b.to||'');
@@ -1337,6 +1353,7 @@ function getActionIcon(act){
1337
1353
  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
1354
  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
1355
  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>';
1356
+ 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
1357
  default:return '';
1341
1358
  }
1342
1359
  }
@@ -1348,6 +1365,7 @@ function paletteSections(){
1348
1365
  {kind:'action',label:'Copy board path',action:'copyboard'},
1349
1366
  {kind:'action',label:'Open project folder…',action:'openfolder'},
1350
1367
  {kind:'action',label:'Pair mobile client (QR)…',action:'pairmobile'},
1368
+ {kind:'action',label:'Install \'crewbus\' CLI in PATH',action:'installcli'},
1351
1369
  {kind:'action',label:'Refresh board state',action:'refresh'},
1352
1370
  {kind:'section',label:'Launch',id:'sec-launch'},
1353
1371
  {kind:'section',label:'Boards',id:'sec-boards'},
@@ -1417,6 +1435,7 @@ function paletteActivate(m){
1417
1435
  try{window.open('./pair.html','_blank','width=420,height=560');}catch(_){}
1418
1436
  }
1419
1437
  else if(m.action==='refresh'){refresh();}
1438
+ else if(m.action==='installcli'){document.getElementById('pop-install-cli-btn').click();}
1420
1439
  }else if(m.kind==='section'){
1421
1440
  approotNavTo(m.id);
1422
1441
  }else{
@@ -1536,7 +1555,7 @@ function showInspector(name){
1536
1555
  +'<div class="dim">reply: '+rep+' · acked: '+esc(w.acked?'yes':'no')+' · session: '+esc(w.harnessSessionId||'—')+'</div>'
1537
1556
  +'<div class="log" style="margin-top:6px">'+tail+'</div>'
1538
1557
  +'<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>':'')
1558
+ +((w.known&&w.alive===true)?'<button class="danger" id="insp-kill">kill</button>':'')
1540
1559
  +(w.reply?'<button id="insp-ack">ack reply</button>':'')
1541
1560
  +'<button id="insp-respawn">respawn (CLI-only)</button></div>'
1542
1561
  +'<div class="dim" id="insp-respawn-cmd" style="margin-top:4px"></div>';
@@ -1647,6 +1666,27 @@ if(ppb){ppb.onclick=function(){
1647
1666
  try{window.parent.postMessage({type:'crewbus:open-pair'},'*');}catch(_){}
1648
1667
  try{window.open('./pair.html','_blank','width=420,height=560');}catch(_){}
1649
1668
  };}
1669
+ var picb=document.getElementById('pop-install-cli-btn');
1670
+ if(picb){picb.onclick=async function(){
1671
+ picb.disabled=true;
1672
+ picb.textContent='Installing CLI…';
1673
+ try{
1674
+ var r=await fetch('/api/install-cli',{method:'POST'});
1675
+ var j=await r.json();
1676
+ if(j&&j.ok){
1677
+ say('CLI installed! '+(j.detail||''));
1678
+ picb.textContent='CLI Installed (in PATH)';
1679
+ }else{
1680
+ say('Failed to install CLI: '+(j&&j.error||'unknown error'));
1681
+ picb.textContent='Install Failed — Retry';
1682
+ picb.disabled=false;
1683
+ }
1684
+ }catch(e){
1685
+ say('Install error: '+e.message);
1686
+ picb.textContent='Install Failed — Retry';
1687
+ picb.disabled=false;
1688
+ }
1689
+ };}
1650
1690
  document.getElementById('project-trigger').onclick=function(e){
1651
1691
  e.stopPropagation();
1652
1692
  var pop=document.getElementById('project-popover');
@@ -1726,9 +1766,15 @@ document.getElementById('launch-brief').addEventListener('keydown',function(e){
1726
1766
  document.getElementById('launch-go').click();
1727
1767
  }
1728
1768
  });
1729
- loadLaunchMeta();
1730
- refresh();
1731
- setInterval(refresh,5000);
1769
+ loadLaunchMeta();
1770
+ refresh();
1771
+ setInterval(refresh,5000);
1772
+ // Data-age indicator: background tabs get their 5s poll throttled, so a
1773
+ // stale view must say so instead of passing for live (see poll-age).
1774
+ 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';}
1775
+ function ago(iso){try{var t=Date.parse(iso);if(isNaN(t))return '?';return agoMs(Date.now()-t)+' ago';}catch(e){return '?';}}
1776
+ 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)':'';}
1777
+ setInterval(updatePollAge,1000);
1732
1778
  </script>
1733
1779
  </body></html>`;
1734
1780
  }
@@ -1889,12 +1935,31 @@ export async function handleWebDashboardRoute(req, res, url, d) {
1889
1935
  res.end(JSON.stringify({ ok: false, error: "no lead.token found" }));
1890
1936
  return true;
1891
1937
  }
1938
+ if (req.method === "POST" && url.pathname === "/api/install-cli") {
1939
+ const ip = req.socket.remoteAddress || "";
1940
+ const isLoop = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
1941
+ if (!isLoop) {
1942
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
1943
+ res.end(JSON.stringify({ error: "loopback only" }));
1944
+ return true;
1945
+ }
1946
+ try {
1947
+ const resInstall = installCliToUserPath();
1948
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1949
+ res.end(JSON.stringify({ ok: true, ...resInstall }));
1950
+ } catch (err) {
1951
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1952
+ res.end(JSON.stringify({ ok: false, error: String(err && err.message ? err.message : err) }));
1953
+ }
1954
+ return true;
1955
+ }
1892
1956
  return false;
1893
1957
  }
1894
1958
 
1895
1959
  export async function cmdWeb(args) {
1896
1960
  const root = boardDir(args);
1897
1961
  const d = requireBoard(root);
1962
+ ensureBoardCliShim(d);
1898
1963
  const host = getFlag(args, "--host") || "127.0.0.1";
1899
1964
  const port = Number(getFlag(args, "--port") || 0);
1900
1965
  if (!(port >= 0 && port < 65536)) fail("--port must be 0-65535 (0 = random)");
package/docs/GROUPS.md CHANGED
@@ -1,77 +1,102 @@
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 other groups + members, their group
85
+ channels, and the peer-DM norm. Conventions that block assumes:
86
+
87
+ - **Peer-direct for questions.** `crewbus send --from you --to-group
88
+ <group> --subject "..." --body "..."` reaches every member of
89
+ another group. Ask the group that owns a thing first; the lead is
90
+ for approvals (`--priority high`) and scope disputes only.
91
+ - **Channels are the shared read surface.** `crewbus channel tail
92
+ grp-<group>` shows a group's briefs + mirrored progress; reads need
93
+ no token. Channel *posting* is lead|admin-only, so workers write
94
+ via DMs, never via `channel post`.
95
+ - **Boot habit.** `crewbus agents` + `crewbus group list` once at
96
+ start, so a worker knows who else is here. A worker that never runs
97
+ a discovery command will work star-topology through the lead — that
98
+ is a failure of the brief, not the worker.
99
+ - **Scope questions stay lanes-first.** "Stay in your lane" prevents
100
+ collisions; "DM the group that owns a thing" prevents silos. A
101
+ brief should name both the boundary *and* the bridge.
102
+