crewbus 10.0.3 → 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/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@
4
4
 
5
5
  - Package is now published as unscoped `crewbus` (v10.0.2): `npm i -g crewbus`.
6
6
 
7
+ ## 10.0.4 (2026-10-12)
8
+
9
+ PATCH RELEASE: Briefs view + gather endpoint, task-scoped spawn rosters, and mobile brief/thread/watcher screens. No board-schema change (`board.json` stays v2); all additions backward-compatible.
10
+
11
+ - **Dashboard Briefs view + `GET /api/gather?batch=`**:
12
+ - New Briefs section: one row per fan-out (subject, workers, checkpoint/final counts, acked fraction, relative last activity); click a row for its gathered thread (briefs, checkpoint timeline, finals with ack ticks, telemetry footer).
13
+ - Crews table gains a brief/groups column (batch short-id + group names, or "solo"); Recent activity gains a text filter + checkpoints/finals toggle.
14
+ - **Task-scoped spawn rosters**:
15
+ - `buildCrewContext` takes the task's groups only; groups from unrelated concurrent briefs collapse to a count (`N other groups exist on this board`) instead of leaking names cross-task. Documented in `docs/GROUPS.md`.
16
+ - **Mobile (M5)**:
17
+ - Boards card counts live workers (`alive === true`) instead of all pid-bearing workers.
18
+ - Triage groups inbox by brief thread with checkpoint/final/note chips.
19
+ - New Briefs screen (one row per batch, tap for thread via `/api/gather`).
20
+ - Foreground watcher (`src/lib/watch.js`): polls inbox approvals + board dead-man state, alerts once per new id with in-app banner + haptic. Foreground-only by scaffold rule (no notification plugins); killed-app push remains future work (needs the plugin rule lifted + EAS credentials + a relay sender).
21
+ - `/api/inbox` items now carry `checkpoint` (additive).
22
+
7
23
  ## 10.0.3 (2026-10-10)
8
24
 
9
25
  PATCH RELEASE: Worker-record integrity, truthful presence, and multi-group collaboration in spawn briefs. No board-schema change (`board.json` stays v2); all prompt additions are backward-compatible (absent unless the spawn carries group/compute context).
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # crewbus v10.0.3 — DM bus
1
+ # crewbus v10.0.4 — DM bus
2
2
 
3
3
  [![CI](https://github.com/eamonpluto/crewbus/actions/workflows/ci.yml/badge.svg)](https://github.com/eamonpluto/crewbus/actions/workflows/ci.yml)
4
4
 
@@ -55,7 +55,7 @@ env overrides below.)
55
55
  Layout: `board.json`, `agents/<name>.json`, `dm/<recipient>/<id>.json`,
56
56
  `delivered/<recipient>/<id>.json` (push markers, written by the plugin).
57
57
 
58
- Versions: `package.json` (currently 10.0.3) is the source of truth for the
58
+ Versions: `package.json` (currently 10.0.4) is the source of truth for the
59
59
  release version. `board.json`'s `version: 2` is the **board schema version**
60
60
  (a different number on purpose — do not "align" them). New to the project?
61
61
  Start with `docs/QUICKSTART.md` (5 minutes).
package/bin/crewbus.js CHANGED
@@ -2869,16 +2869,21 @@ function cmdSpawn(args) {
2869
2869
  if (alsoChannel && (!toGroupRaw || !String(toGroupRaw).trim())) fail("--also-channel needs --to-group <g,...> (it mirrors the brief into each group's channel)");
2870
2870
  const groupNames = String(toGroupRaw || "").split(",").map((s) => String(s).trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 40)).filter(Boolean);
2871
2871
  // Crew roster for the prompt block (see buildCrewContext in lib/spawn.js):
2872
- // every group on the board with its members, so each worker learns the
2873
- // map — its own group, the other groups, and the peer-DM norms — at
2874
- // boot instead of discovering (or never discovering) the board alone.
2872
+ // TASK-scoped, not board-scoped. Only the groups named in THIS spawn
2873
+ // appear in the brief — their lanes are what a worker may need to
2874
+ // contact. Groups from unrelated concurrent briefs collapse to a count
2875
+ // (discoverable via `crewbus group list`, never advertised cross-task:
2876
+ // ambient cross-task rosters are prompt noise and a misleading
2877
+ // invitation to DM crews working on unrelated tasks).
2875
2878
  const rosterGroups = [];
2876
2879
  for (const e of listJson(d.groups)) {
2877
2880
  const g = e && e.data;
2878
2881
  if (g && g.name && Array.isArray(g.members)) rosterGroups.push({ name: g.name, members: g.members.slice() });
2879
2882
  }
2880
2883
  rosterGroups.sort((a, b) => String(a.name).localeCompare(String(b.name)));
2881
- const crewFor = (to) => buildCrewContext({ name: to, from, subject, groups: rosterGroups, myGroups: rosterGroups.filter((g) => g.members.includes(to)).map((g) => g.name) });
2884
+ const taskGroups = rosterGroups.filter((g) => groupNames.includes(g.name));
2885
+ const offTask = rosterGroups.length - taskGroups.length;
2886
+ const crewFor = (to) => buildCrewContext({ name: to, from, subject, groups: taskGroups, myGroups: taskGroups.filter((g) => g.members.includes(to)).map((g) => g.name), offTask });
2882
2887
  const model = getFlag(args, "--model");
2883
2888
  const auto = args.includes("--auto");
2884
2889
  requireAutoConfirm(args);
package/bin/lib/groups.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { readJson, writeJson, listJson, fail, parseGroupList, cleanGroupName } from "./store.js";
10
- import { readDMs, findMessageById, isVerified, scanAllMessages } from "./mail.js";
10
+ import { readDMs, findMessageById, isVerified, scanAllMessages, ackedIdSet } from "./mail.js";
11
11
  import { readChannelPosts } from "./channels.js";
12
12
 
13
13
  // parseGroupList + cleanGroupName live in store.js (canonical home).
@@ -197,6 +197,99 @@ export function gatherTelemetry(items) {
197
197
  return { messages: items.length, tokensEst: Math.floor(chars / 4) };
198
198
  }
199
199
 
200
+ // Batch (brief) enumeration for the dashboard Briefs view: one row per
201
+ // fan-out/thread on the board — briefs, checkpoints, finals, ack state,
202
+ // last activity. Keys come from message batch stamps plus spawned
203
+ // workers' briefIds (single-recipient briefs carry no batch until
204
+ // replied to); a batch-less root with no replies and no worker pointing
205
+ // at it is a stray DM, not a brief, and is skipped. Stats mirror
206
+ // collectBatch + gatherTelemetry; pure read, no board IO.
207
+ export function listBatches(d) {
208
+ const all = [];
209
+ let subs = [];
210
+ try {
211
+ subs = fs.readdirSync(d.dm);
212
+ } catch {
213
+ subs = [];
214
+ }
215
+ for (const sub of subs) {
216
+ try {
217
+ if (!fs.statSync(path.join(d.dm, sub)).isDirectory()) continue;
218
+ } catch {
219
+ continue;
220
+ }
221
+ for (const m of readDMs(d, sub)) all.push(m);
222
+ }
223
+ for (const e of listJson(d.broadcast || path.join(d.root, "broadcast"))) {
224
+ const b = e.data;
225
+ if (b && b.id && b.from) {
226
+ if (!b.batch) b.batch = b.id;
227
+ all.push(b);
228
+ }
229
+ }
230
+ const childCount = new Map();
231
+ for (const m of all) {
232
+ if (!m.replyTo) continue;
233
+ childCount.set(m.replyTo, (childCount.get(m.replyTo) || 0) + 1);
234
+ }
235
+ const keys = [];
236
+ const seen = new Set();
237
+ const addKey = (k) => {
238
+ if (k && !seen.has(k)) {
239
+ seen.add(k);
240
+ keys.push(k);
241
+ }
242
+ };
243
+ for (const m of all) {
244
+ if (m.batch) addKey(m.batch);
245
+ else if (!m.replyTo && (childCount.get(m.id) || 0) > 0) addKey(m.id);
246
+ }
247
+ try {
248
+ for (const e of listJson(path.join(d.root, "agents"))) {
249
+ const a = e && e.data;
250
+ if (a && a.briefId) addKey(a.briefId);
251
+ }
252
+ } catch {}
253
+ const acked = ackedIdSet(d);
254
+ const rows = [];
255
+ for (const key of keys) {
256
+ const res = collectBatch(d, key);
257
+ if (!res) continue;
258
+ const briefs = res.items.filter((m) => !m.replyTo);
259
+ if (briefs.length === 0) continue;
260
+ briefs.sort((a, b) => String(a.at).localeCompare(String(b.at)) || String(a.id).localeCompare(String(b.id)));
261
+ const replies = res.items.filter((m) => !!m.replyTo);
262
+ const checkpoints = replies.filter((m) => m.checkpoint === true);
263
+ const finals = replies.filter((m) => m.checkpoint !== true);
264
+ const workers = [];
265
+ for (const b of briefs) {
266
+ const tos = Array.isArray(b.to) ? b.to : (b.to ? [b.to] : []);
267
+ for (const t of tos) {
268
+ if (t && !workers.includes(t)) workers.push(t);
269
+ }
270
+ }
271
+ let lastAt = briefs[0].at || "";
272
+ for (const m of res.items) {
273
+ if (String(m.at || "") > String(lastAt || "")) lastAt = m.at;
274
+ }
275
+ rows.push({
276
+ batch: key,
277
+ subject: briefs[0].subject || "",
278
+ from: briefs[0].from || "",
279
+ at: briefs[0].at || "",
280
+ workers,
281
+ briefCount: briefs.length,
282
+ replyCount: replies.length,
283
+ checkpointCount: checkpoints.length,
284
+ finalCount: finals.length,
285
+ ackedFinals: finals.filter((m) => acked.has(m.id)).length,
286
+ lastAt: lastAt || "",
287
+ });
288
+ }
289
+ rows.sort((a, b) => String(b.lastAt).localeCompare(String(a.lastAt)) || String(a.batch).localeCompare(String(b.batch)));
290
+ return rows;
291
+ }
292
+
200
293
  export function batchReplyIds(d, batch) {
201
294
  const res = collectBatch(d, batch);
202
295
  if (!res) return null;
package/bin/lib/mail.js CHANGED
@@ -388,6 +388,27 @@ export function ackedIds(d, agent) {
388
388
  return out;
389
389
  }
390
390
 
391
+ // Ack markers across every agent: message ids with at least one ack, by
392
+ // anyone. Powers batch-thread ack state (dashboard Briefs/gather) —
393
+ // orthogonal to per-agent ackedIds(), which scopes triage to one inbox.
394
+ export function ackedIdSet(d) {
395
+ const out = new Set();
396
+ let subs = [];
397
+ try {
398
+ subs = fs.readdirSync(path.join(d.root, "acked"));
399
+ } catch {
400
+ return out;
401
+ }
402
+ for (const sub of subs) {
403
+ try {
404
+ for (const f of fs.readdirSync(path.join(d.root, "acked", sub)).filter((f) => f.endsWith(".json"))) {
405
+ out.add(f.replace(/\.json$/, ""));
406
+ }
407
+ } catch {}
408
+ }
409
+ return out;
410
+ }
411
+
391
412
  export function splitCommand(cmd) {
392
413
  const out = [];
393
414
  let cur = "";
package/bin/lib/spawn.js CHANGED
@@ -185,14 +185,19 @@ export function buildSpawnPrompt({ name, from, subject, body, replyId, rev, cwd,
185
185
 
186
186
  // Crew context block for the spawn prompt: the roster a worker needs to
187
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 }) {
188
+ // the lead. `groups` is the TASK roster — the groups named in this spawn
189
+ // only — [{ name, members }]; `myGroups` the group names this recipient
190
+ // belongs to; `subject` the lane line for my groups (the spawn call's
191
+ // --subject); `offTask` how many board groups from unrelated concurrent
192
+ // briefs exist (collapsed to a count, never enumerated: cross-task
193
+ // rosters are prompt noise and a misleading invitation — "ask the group
194
+ // that owns a thing" only makes sense for sibling lanes of the same
195
+ // task). Workers may send to each other (and to-group) and tail any
196
+ // channel, but channel-post is lead|admin-only (see identity.js
197
+ // LEAD_PLUS) — so the block points reads at channels and writes at DMs.
198
+ // Returns bullet lines (no header); the caller renders them under a
199
+ // Crew: header. Pure: unit-tested, no board IO.
200
+ export function buildCrewContext({ name, from, subject, groups, myGroups, offTask }) {
196
201
  const all = (groups || []).filter((g) => g && g.name);
197
202
  const mineSet = new Set(myGroups || []);
198
203
  const mine = all.filter((g) => mineSet.has(g.name));
@@ -200,6 +205,8 @@ export function buildCrewContext({ name, from, subject, groups, myGroups }) {
200
205
  const lane = subject && String(subject).trim() ? String(subject).trim() : "the brief above";
201
206
  const me = String(name || "you");
202
207
  const lead = String(from || "lead");
208
+ const off = Math.max(0, Number(offTask) || 0);
209
+ const offNoun = (n) => `${n} other group${n === 1 ? "" : "s"}`;
203
210
  const showMembers = (members) => {
204
211
  const ms = (members || []).filter(Boolean).map(String);
205
212
  return ms.slice(0, 8).join(",") + (ms.length > 8 ? "…" : "");
@@ -212,11 +219,11 @@ export function buildCrewContext({ name, from, subject, groups, myGroups }) {
212
219
  }
213
220
  if (others.length > 0) {
214
221
  // 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(", ")}.`);
222
+ out.push(`other groups on this task: ${others.map((g) => `${g.name} (${showMembers(g.members)})`).join("; ")} — their channels: ${others.map((g) => `grp-${g.name}`.slice(0, 60)).join(", ")}.` + (off > 0 ? ` ${offNoun(off)} exist on this board: crewbus group list to discover.` : ""));
216
223
  } else if (mine.length > 0) {
217
- out.push(`no other groups on this board right now — discover live agents with: crewbus agents.`);
224
+ out.push(off > 0 ? `no other groups on this task. ${offNoun(off)} exist on this board: crewbus group list to discover.` : `no other groups on this board right now — discover live agents with: crewbus agents.`);
218
225
  } else {
219
- out.push(`discover who else is on this board with: crewbus agents (plus crewbus group list once groups exist).`);
226
+ out.push(off > 0 ? `${offNoun(off)} exist on this board (crewbus group list to discover).` : `discover who else is on this board with: crewbus agents (plus crewbus group list once groups exist).`);
220
227
  }
221
228
  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
229
  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.`);
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";
20
+ import { readDMs, readVisible, ackedIds, ackedIdSet, findMessageById, deliverDMs, parseRecipients } from "./mail.js";
21
21
  import { workerStatus, pidAlive, killWorkers, bootWorker, buildSpawnPrompt, buildSpawnTarget, formatSpawnCmd, installCliToUserPath, ensureBoardCliShim } from "./spawn.js";
22
- import { groupTelemetryData } from "./groups.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";
@@ -112,7 +112,18 @@ export function boardSnapshot(d, activeWindowSec) {
112
112
  .filter((x) => x && x.peer && typeof x.lastOk === "number")
113
113
  .sort((a, b) => String(a.peer).localeCompare(String(b.peer)))
114
114
  .map((p) => ({ peer: p.peer, lastOk: new Date(p.lastOk).toISOString() }));
115
- 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) };
116
127
  }
117
128
 
118
129
  // Fleet console snapshots (served by `web`, all open reads like /api/board;
@@ -622,9 +633,10 @@ details.tech-details summary{cursor:pointer;font-weight:500;user-select:none}
622
633
 
623
634
  <div class="sidebar-heading">VIEWS</div>
624
635
  <nav id="approot-nav" aria-label="sections">
625
- <button data-nav="sec-launch" class="active">Launch</button>
626
- <button data-nav="sec-boards">Boards</button>
627
- <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>
628
640
  <button data-nav="sec-fleet">Fleet</button>
629
641
  <button data-nav="sec-channels">Channels</button>
630
642
  <button data-nav="sec-triage">Triage</button>
@@ -770,15 +782,23 @@ details.tech-details summary{cursor:pointer;font-weight:500;user-select:none}
770
782
  <div class="card"><b id="c-peers">–</b><br>peers</div>
771
783
  </div>
772
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>
773
786
  <table><tr><th>id</th><th>route</th><th>message</th></tr><tbody id="recent"></tbody></table>
774
787
  <h2>Broadcasts</h2>
775
788
  <table><tr><th>id</th><th>from</th><th>to</th><th>subject</th><th>body</th></tr><tbody id="bcast"></tbody></table>
776
789
  </section>
777
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
+
778
798
  <!-- SECTION: CREWS (WORKERS & TEAMS) -->
779
799
  <section id="sec-crews" data-section="Crews" class="approot-hidden">
780
800
  <h2>Workers <button id="killall" class="danger">kill all</button></h2>
781
- <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>
782
802
  <h2>Agents</h2>
783
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>
784
804
  <h2>Groups</h2>
@@ -919,6 +939,10 @@ var _lt=document.getElementById('launch-token');
919
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();};}
920
940
  autoLoadOperatorCreds();
921
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()};}
922
946
  async function kill(names){
923
947
  var c=creds();
924
948
  if(!c.from||!c.token){say('set identity + token first');return;}
@@ -938,8 +962,7 @@ document.getElementById('killall').onclick=async function(){
938
962
  if(!names.length){say('no live workers (kill needs a running pid)');return;}
939
963
  kill(names);
940
964
  };
941
- document.getElementById('ackall').onclick=async function(){
942
- var c=creds();
965
+ document.getElementById('ackall').onclick=async function(){ var c=creds();
943
966
  if(!c.from||!c.token){say('set identity + token first');return;}
944
967
  if(!confirm('ack everything unacked for '+c.from+'?'))return;
945
968
  say('acking all for '+c.from+' …');
@@ -950,6 +973,37 @@ document.getElementById('ackall').onclick=async function(){
950
973
  }catch(e){say('ack failed: '+e.message);}
951
974
  refresh();
952
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
+ }
953
1007
  async function refresh(){
954
1008
  try{
955
1009
  var r=await fetch('/api/board',{cache:'no-store'});
@@ -977,8 +1031,11 @@ async function refresh(){
977
1031
  var tail=(w.tail||[]).slice(-3).map(function(l){return '<div class="log">'+esc(l)+'</div>';}).join('')||'<span class="dim">no log</span>';
978
1032
  var rep=w.reply?esc(w.reply.id)+'<div class="dim">'+esc(w.reply.head)+'</div>':'—';
979
1033
  var btn=(w.known&&w.alive===true)?'<button class="danger" data-kill="'+esc(w.name)+'">kill</button>':'';
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>';
981
- }).join('')||'<tr><td colspan="6" class="dim">no spawned workers</td></tr>';
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>';
982
1039
  Array.prototype.forEach.call(document.querySelectorAll('[data-kill]'),function(b){b.onclick=function(){kill([b.getAttribute('data-kill')]);};});
983
1040
  document.getElementById('agents').innerHTML=s.agents.map(function(a){
984
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>';
@@ -987,11 +1044,14 @@ async function refresh(){
987
1044
  var to=Array.isArray(b.to)?b.to.join(','):String(b.to||'');
988
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>';
989
1046
  }).join('')||'<tr><td colspan="5" class="dim">no broadcasts</td></tr>';
990
- document.getElementById('recent').innerHTML=s.recent.map(function(m){
991
- var to=Array.isArray(m.to)?m.to.join(','):String(m.to||'');
992
- var acks=(s.ackedBy[m.id]||[]).map(function(x){return '✓'+x;}).join(' ');
993
- 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>';
994
- }).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'));};});
995
1055
  try{
996
1056
  var fr=await fetch('/api/fleet',{cache:'no-store'});
997
1057
  var fl=await fr.json();
@@ -1870,12 +1930,40 @@ export async function handleWebDashboardRoute(req, res, url, d) {
1870
1930
  id: m.id, from: m.from, at: m.at, subject: m.subject || "",
1871
1931
  head: String(m.body || "").slice(0, 160),
1872
1932
  replyTo: m.replyTo || "", batch: m.batch || "",
1933
+ checkpoint: m.checkpoint === true,
1873
1934
  artifact: m.artifact || "", acked: known.has(m.id),
1874
1935
  }));
1875
1936
  res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1876
1937
  res.end(JSON.stringify({ board: d.root, agent, items }));
1877
1938
  return true;
1878
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
+ }
1879
1967
  if (req.method === "POST" && url.pathname === "/api/ack") {
1880
1968
  // JSON-only like /api/kill (plain browser forms can't reach it),
1881
1969
  // token-checked like the CLI, no --verify over HTTP.
package/docs/GROUPS.md CHANGED
@@ -81,8 +81,13 @@ Groups share one board, so any member can already read anything. The
81
81
  thing that decides whether groups actually collaborate is whether the
82
82
  brief tells them the map. Since v10.0.x every `spawn` brief carries a
83
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:
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:
86
91
 
87
92
  - **Peer-direct for questions.** `crewbus send --from you --to-group
88
93
  <group> --subject "..." --body "..."` reaches every member of
@@ -1,6 +1,6 @@
1
1
  # Version skew (mixed-relay operation)
2
2
 
3
- How crewbus versions interoperate. Evidence: `package.json:2` (`10.0.3`),
3
+ How crewbus versions interoperate. Evidence: `package.json:2` (`10.0.4`),
4
4
  `CHANGELOG.md` (10.0.0 2026-10-08; 7.0.0 2026-10-05; 6.4.0–6.0.0; 4.1.0/4.0.0 baseline).
5
5
  Capabilities: `RELAY_CAPS` + `launch` per `packages/contracts/board-caps.json:6-8`.
6
6
  Cell behavior is the NEW side's verified behavior (`bin/lib/sync.js` refs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewbus",
3
- "version": "10.0.3",
3
+ "version": "10.0.4",
4
4
  "description": "Zero-dependency message bus for AI coding-agent crews: message another agent, inserted into context, just a tool call.",
5
5
  "type": "module",
6
6
  "bin": {