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.
@@ -13,7 +13,7 @@ appends the right subsection automatically.
13
13
  You coordinate with other AI agents by messaging them directly — like Slack,
14
14
  minimal structure, figure it out yourselves.
15
15
 
16
- The CLI is run as (after `npm i -g .`, just `crewbus`; otherwise the full
16
+ The CLI is run as (after `npm i -g crewbus`, just `crewbus`; otherwise the full
17
17
  path `node <this-checkout>/bin/crewbus.js` — `init` writes the real path
18
18
  into the project's `AGENTS.md` automatically, so prefer that copy):
19
19
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Package is now published as unscoped `crewbus` (v10.0.2): `npm i -g crewbus`.
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
+
23
+ ## 10.0.3 (2026-10-10)
24
+
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).
26
+
27
+ - **Spawn briefs carry a Crew: roster block**:
28
+ - Every `spawn` brief now names the worker's group(s) + members + lane, the other groups + members, and their group channels (`grp-<group>`).
29
+ - Ships the peer-DM norm with an explicit trigger (ask the group that owns a thing before asking the lead; lead stays for approvals/scope only), the read-channels/write-DMs distinction (channel-post is lead|admin-only), and a `crewbus agents` + `crewbus group list` boot habit.
30
+ - Documented as cross-group collaboration norms in `docs/GROUPS.md`.
31
+ - **Agent-doc merge canonicalization**:
32
+ - New `mergeAgentDoc(prev, patch)` in `bin/lib/identity.js`; `touchAgent`, CLI `heartbeat`, and `register`'s mint path route through it.
33
+ - The `dm-send` tool and `dm-watch` plugin upserts merge over prev instead of enumerating — fixes spawned workers losing `spawnedPid`/`spawnedBy`/`briefId`/`role` (and with it: vanishing from `spawn-status`, unkillable workers, and silent role promotion) the moment they report.
34
+ - `syncWorkerSession` preserves binding fields (`promptPath`, model, cwd, cmd, respawn counts), which `respawn` needs.
35
+ - **Truthful presence + session hygiene**:
36
+ - `boardSnapshot` gates pid-bearing agents on verified process liveness; pid-less agents keep lastSeen freshness.
37
+ - The watcher TTL-expires session mappings (30m without a confirmed delivery or re-registration) instead of heartbeating dead sessions forever.
38
+ - **Observability**:
39
+ - Boot-metadata first line in every worker log; `workerStatus` surfaces `logSize`/`logMtimeMs`/`logIdleMs`.
40
+ - `doctor` gains a read-only dead-man scan (exited without reply, past deadline, alive-but-quiet) — informational, never FAIL.
41
+ - Dashboard shows data age + STALE banner, relative timestamps, alive-gated kill buttons; `spawn-status --all` lists pid-wiped workers as "no pid recorded" instead of dropping them.
42
+ - **Windows ergonomics**: group lists split on whitespace too (unquoted `--add a,b,c` arriving space-rejoined now parses as several members); PowerShell quoting note in `docs/TROUBLESHOOTING.md`.
43
+
3
44
  ## 10.0.2 (2026-10-09)
4
45
 
5
46
  PATCH RELEASE: Target workspace CWD selector & persistence in Launch Studio, popover clarity, eliminated upload prompt, and Desktop v0.1.16 companion release.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # crewbus v10.0.2 — 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
 
@@ -23,6 +23,13 @@ node $REPO/bin/crewbus.js <command>
23
23
 
24
24
  Or install globally:
25
25
 
26
+ ```sh
27
+ npm i -g crewbus
28
+ crewbus --help
29
+ ```
30
+
31
+ Or from a local checkout (dev alternative):
32
+
26
33
  ```sh
27
34
  cd $REPO
28
35
  npm i -g .
@@ -48,7 +55,7 @@ env overrides below.)
48
55
  Layout: `board.json`, `agents/<name>.json`, `dm/<recipient>/<id>.json`,
49
56
  `delivered/<recipient>/<id>.json` (push markers, written by the plugin).
50
57
 
51
- Versions: `package.json` (currently 10.0.2) is the source of truth for the
58
+ Versions: `package.json` (currently 10.0.4) is the source of truth for the
52
59
  release version. `board.json`'s `version: 2` is the **board schema version**
53
60
  (a different number on purpose — do not "align" them). New to the project?
54
61
  Start with `docs/QUICKSTART.md` (5 minutes).
package/bin/crewbus.js CHANGED
@@ -42,11 +42,11 @@ import { execFileSync, spawn } from "node:child_process";
42
42
  // at top level so the import graph (store <- everyone; identity <- sync/spawn/
43
43
  // mail/groups/channels/web/relay/export) has no evaluation-order hazards.
44
44
  import { BOARD_VERSION, BROADCAST_AFTER, MAX_BODY_CHARS, MAX_RECIPIENTS, MAX_SPAWN, VALUE_FLAGS, boardDir, chmodAgentFile, cleanArtifact, cleanBranchPrefix, cleanChannelName, cleanGroupName, cleanPriority, cleanReply, cleanSenderType, cleanSubject, cleanWebName, dirs, ensureBoard, fail, findBoardUpward, getFlag, gitRevForBoard, listJson, newId, optionalAgent, parseDuration, readBoardMeta, readJson, refuseDriveRootBoard, requireBoard, resolveAgent, restArgs, sanitizeName, writeBoardMeta, writeExclusiveJson, writeJson, nextHlc, stampSyncDoc, hlcCompare, SEND_RATE_CAP, SEND_RATE_WINDOW_MS, MAX_FWD_DEPTH, DEDUPE_WINDOW_MS, webErr } from "./lib/store.js";
45
- import { VALID_ROLES, agentTokenMatches, assertMintWon, authorize, authorizeCheck, checkToken, cleanRole, countAgentRecords, defaultRoleForNew, ensureSender, hashToken, isHashRevoked, mergeSyncedAgent, mintToken, newSalt, readAgent, readBoardAcl, resolveToken, revokedPathForHash, roleOfRecord, sanitizeAgentForSync, stripAgentSecrets, timingSafeEqualStr, touchAgent, writeAgentFile, writeBoardAcl, isBoardFrozen } from "./lib/identity.js";
45
+ import { VALID_ROLES, agentTokenMatches, assertMintWon, authorize, authorizeCheck, checkToken, cleanRole, countAgentRecords, defaultRoleForNew, ensureSender, hashToken, isHashRevoked, mergeAgentDoc, mergeSyncedAgent, mintToken, newSalt, readAgent, readBoardAcl, resolveToken, revokedPathForHash, roleOfRecord, sanitizeAgentForSync, stripAgentSecrets, timingSafeEqualStr, touchAgent, writeAgentFile, writeBoardAcl, isBoardFrozen } from "./lib/identity.js";
46
46
  import { saveTokenFile } from "./lib/tokenfile.js";
47
47
  import { CLIENT_TLS, SYNC_LWW, cleanSyncRel, clientInsecureFromArgs, crewSurvey, httpJson, readPemFlag, readSyncState, readTombstones, relayAuthEntries, relayCredFor, relayCredHeaders, setupClientTls, splitByWeight, syncRound, syncWalk, warnInsecureOnce, writeSyncState, writeTombstone, SYNC_SUBS, SYNC_UNION, RELAY_CAPS, SUB_CAP, SUB_CAP_NOTE, tombstoneIdForRel, readSyncDoc } from "./lib/sync.js";
48
48
  import { devicePath, isLoopbackHost, newDeviceCred, newPairToken, pairingPath, parseDeviceCred, readDevice, readRelayState, relaySecretFromArgs, remoteSpawn, requireRelayClientCert, requireRelaySecret, requireScope, tryAcquireFence, verifyOidcJwt, writeRelayState, relayStatePath, bearerFromReq, getOidcConfig, getOidcJwks, b64urlDecode, b64urlJson, jwsRawToDer, oidcGetJson, OIDC_SKEW_SEC, OIDC_JWKS_TTL_MS, OIDC_ALG_HASH, OIDC_EC_SIZE, deviceFromReq } from "./lib/relay.js";
49
- import { assertGitCheckout, bootRespawnedWorker, bootWorker, buildRespawnBrief, buildRespawnTarget, buildSpawnPrompt, buildSpawnTarget, formatSpawnCmd, isPidStale, killWorkers, maybeIsolate, parseAllowEnv, pidAlive, provisionBranch, provisionWorktree, readWorkerSession, requireAutoConfirm, sandboxPresent, scrubChildEnv, syncWorkerSession, workerStatus, worktreeStamp, defaultMaxTurnsFor } from "./lib/spawn.js";
49
+ import { assertGitCheckout, bootRespawnedWorker, bootWorker, buildCrewContext, buildRespawnBrief, buildRespawnTarget, buildSpawnPrompt, buildSpawnTarget, formatSpawnCmd, isPidStale, killWorkers, maybeIsolate, parseAllowEnv, pidAlive, provisionBranch, provisionWorktree, readWorkerSession, requireAutoConfirm, sandboxPresent, scrubChildEnv, syncWorkerSession, workerStatus, worktreeStamp, defaultMaxTurnsFor, ensureBoardCliShim, installCliToUserPath } from "./lib/spawn.js";
50
50
  import { appendChainRecord, auditHmacKey, boardTotalBytes, chainFilePath, countChannels, dirSize, doExportToFile, enforceAgentQuota, enforceBytesQuota, enforceChannelQuota, holdActive, holdDocPath, holdRefusal, parseQuotaBytes, parseQuotaCount, readBackupInner, readBoardQuotas, readChainRecords, readHold, readSnapshotSchedule, resolveBackupKeyMaterial, setAuditForward, snapshotStamp, startAuditForwarder, verifyChainRecords, collectBoardFiles, encryptBackupPayload, decryptBackupPayload, rawKeyFromMaterial, deriveBackupKey, toAuditExport, spoolAuditEvent, postAuditEvent, auditSpoolDir, drainAuditSpool, enqueueAuditForward, signAuditRecord, chainRecordHash, AUDIT_FORWARD_URL, AUDIT_FORWARD_KEY } from "./lib/export.js";
51
51
  import { ackedIds, checkSendRateLimit, deliverDMs, enforceMaxChars, filterDigest, findDuplicateSend, findMessageById, heartbeat, isVerified, loadManifest, manifestPath, msgTimeMs, parseRecipients, printDigest, printMsg, readDMs, readVisible, recordBroadcastManifest, requireFanoutConfirm, resolveFwdDepth, runVerifier, verifyMessageSig, isHigh, signMessage, untrustedEnvelope, relTime, rateFilePath, broadcastTargets, readBroadcastsFor, formatTo, msgHeader, readAckMarker, splitCommand, readRecipientsFile } from "./lib/mail.js";
52
52
  import { batchReplyIds, collectBatch, contributingGroups, expandGroups, expandGroupsOrFail, gatherTelemetry, groupTelemetryData, heuristicSenderType, readGroup, ensureGroupCreatedAt, readResultRecord, writeResultRecord, findFirstVerifiedReply } from "./lib/groups.js";
@@ -591,22 +591,30 @@ export default tool({
591
591
  const tokenHint = minted.created ? \` identity '\${from}' claimed, token \${minted.token} (set CREWBUS_TOKEN=\${minted.token})\` : "";
592
592
  const rev = gitRev(root);
593
593
  const at = new Date().toISOString();
594
- // upsert sender with live session routing for the watcher plugin
594
+ // upsert sender with live session routing for the watcher plugin.
595
+ // Merge over prev, never enumerate: a hand-built record here once
596
+ // wiped spawn bookkeeping (spawnedPid/By, briefId) and roles off
597
+ // every worker that reported through this tool.
595
598
  const ap = path.join(root, "agents", from + ".json");
596
599
  let prev = null;
597
600
  try {
598
601
  prev = JSON.parse(fs.readFileSync(ap, "utf8"));
599
602
  } catch {}
600
- writeJsonAtomic(ap, {
603
+ const merged = { ...(prev && typeof prev === "object" ? prev : {}) };
604
+ for (const [k, v] of Object.entries({
601
605
  name: from,
602
606
  firstSeen: (prev && prev.firstSeen) || new Date().toISOString(),
603
607
  lastSeen: new Date().toISOString(),
604
608
  sessionId: (context && context.sessionID) || (prev && prev.sessionId) || undefined,
605
609
  lastDir: context.worktree || context.directory || undefined,
606
- token: (prev && prev.token) || undefined,
607
- tokenHash: (prev && prev.tokenHash) || undefined,
608
- salt: (prev && prev.salt) || undefined,
609
- });
610
+ })) {
611
+ if (v === undefined) delete merged[k];
612
+ else merged[k] = v;
613
+ }
614
+ for (const k of Object.keys(merged)) {
615
+ if (merged[k] === undefined) delete merged[k];
616
+ }
617
+ writeJsonAtomic(ap, merged);
610
618
  const isAll = recipients.length === 1 && recipients[0] === "@all";
611
619
  const mirrorChannels = (batch) => {
612
620
  if (args.also_channel !== true) return "";
@@ -731,6 +739,13 @@ export const DmWatchPlugin = async ({ client, directory }) => {
731
739
  const deliveredDir = path.join(root, "delivered");
732
740
 
733
741
  const agentToSession = new Map(); // agent -> sessionID
742
+ const sessionMeta = new Map(); // agent -> { sessionID, confirmedAt }
743
+ // A mapped session is proof of life only while fresh: entries age out
744
+ // after TTL without a confirmed delivery or re-registration, so dead
745
+ // workers stop being heartbeated (and stop reading "active") instead
746
+ // of lingering forever. Their mail then waits for pull until the live
747
+ // session re-registers — the same fallback as a refused push.
748
+ const SESSION_TTL_MS = 30 * 60 * 1000;
734
749
  const processed = new Set(); // "<agent>/<msgId>"
735
750
  const lastBeat = new Map(); // agent -> epoch ms of last presence write
736
751
 
@@ -763,7 +778,14 @@ export const DmWatchPlugin = async ({ client, directory }) => {
763
778
  for (const f of files) {
764
779
  if (!f.endsWith(".json")) continue;
765
780
  const doc = readJsonSafe(path.join(agentsDir, f));
766
- if (doc && doc.name && doc.sessionId) agentToSession.set(doc.name, doc.sessionId);
781
+ if (doc && doc.name && doc.sessionId) {
782
+ agentToSession.set(doc.name, doc.sessionId);
783
+ const meta = sessionMeta.get(doc.name);
784
+ if (!meta || meta.sessionID !== doc.sessionId) {
785
+ // (Re)registration: a new session id is fresh proof of life.
786
+ sessionMeta.set(doc.name, { sessionID: doc.sessionId, confirmedAt: Date.now() });
787
+ }
788
+ }
767
789
  }
768
790
  }
769
791
 
@@ -783,23 +805,15 @@ export const DmWatchPlugin = async ({ client, directory }) => {
783
805
  if (prev && prev.lastSeen && now - new Date(prev.lastSeen).getTime() < 60000) return;
784
806
  const at = new Date().toISOString();
785
807
  try {
786
- fs.writeFileSync(p, JSON.stringify({
787
- name: agent,
788
- firstSeen: (prev && prev.firstSeen) || at,
789
- lastSeen: at,
790
- sessionId: (prev && prev.sessionId) || undefined,
791
- lastDir: (prev && prev.lastDir) || undefined,
792
- spawnedPid: (prev && prev.spawnedPid) || undefined,
793
- spawnedAt: (prev && prev.spawnedAt) || undefined,
794
- spawnedBy: (prev && prev.spawnedBy) || undefined,
795
- briefId: (prev && prev.briefId) || undefined,
796
- spawnedWorktree: (prev && prev.spawnedWorktree) || undefined,
797
- spawnedBranch: (prev && prev.spawnedBranch) || undefined,
798
- spawnedLifetime: (prev && prev.spawnedLifetime) || undefined,
799
- token: (prev && prev.token) || undefined,
800
- tokenHash: (prev && prev.tokenHash) || undefined,
801
- salt: (prev && prev.salt) || undefined,
802
- }, null, 2) + "\\n");
808
+ // Merge over prev, never enumerate (see mergeAgentDoc in
809
+ // bin/lib/identity.js — this file is standalone so the merge is
810
+ // inlined): a heartbeat must not drop fields it doesn't own.
811
+ const merged = { ...(prev && typeof prev === "object" ? prev : {}) };
812
+ for (const [k, v] of Object.entries({ name: agent, firstSeen: (prev && prev.firstSeen) || at, lastSeen: at })) {
813
+ if (v === undefined) delete merged[k];
814
+ else merged[k] = v;
815
+ }
816
+ fs.writeFileSync(p, JSON.stringify(merged, null, 2) + "\\n");
803
817
  } catch {}
804
818
  }
805
819
 
@@ -994,6 +1008,10 @@ export const DmWatchPlugin = async ({ client, directory }) => {
994
1008
  await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
995
1009
  }
996
1010
  advanceCursor(agent, msg.id);
1011
+ // A completed push is the only proof of life this watcher can
1012
+ // observe directly — refresh the mapping TTL so live sessions
1013
+ // stay pushable across idle stretches.
1014
+ sessionMeta.set(agent, { sessionID, confirmedAt: Date.now() });
997
1015
  } catch (e) {
998
1016
  const detail = e && e.message ? e.message : String(e);
999
1017
  releaseClaim(agent, msg.id);
@@ -1026,6 +1044,16 @@ export const DmWatchPlugin = async ({ client, directory }) => {
1026
1044
  for (const agent of agents) {
1027
1045
  const sessionID = agentToSession.get(agent);
1028
1046
  if (!sessionID) continue; // nobody live for this name — mail waits for pull
1047
+ const meta = sessionMeta.get(agent);
1048
+ if (!meta || meta.sessionID !== sessionID || Date.now() - meta.confirmedAt > SESSION_TTL_MS) {
1049
+ // Stale mapping: no confirmed delivery and no re-registration
1050
+ // within TTL. Drop it (clears the doc's sessionId too) so the
1051
+ // agent stops reading "active" and its mail waits for pull until
1052
+ // the live session re-registers.
1053
+ invalidateSession(agent, sessionID);
1054
+ sessionMeta.delete(agent);
1055
+ continue;
1056
+ }
1029
1057
  heartbeat(agent);
1030
1058
  let files = [];
1031
1059
  try {
@@ -2023,24 +2051,20 @@ function cmdRegister(args) {
2023
2051
  // here (else anyone could self-mint admin). Warn, keep the default.
2024
2052
  process.stderr.write(`crewbus: warning: --role ignored (only an admin can grant roles; ask an admin to run register --from <admin> --for ${agent} --role ${String(roleWant).trim().toLowerCase()})\n`);
2025
2053
  }
2026
- const record = {
2054
+ const record = mergeAgentDoc(prev, {
2027
2055
  name: agent,
2028
2056
  firstSeen: (prev && prev.firstSeen) || now,
2029
2057
  lastSeen: now,
2030
2058
  sessionId: session || (prev && prev.sessionId) || undefined,
2031
2059
  lastDir: process.cwd(),
2032
- spawnedPid: (prev && prev.spawnedPid) || undefined,
2033
- spawnedAt: (prev && prev.spawnedAt) || undefined,
2034
- spawnedBy: (prev && prev.spawnedBy) || undefined,
2035
- briefId: (prev && prev.briefId) || undefined,
2036
2060
  tokenHash: hashToken(fresh, salt),
2037
2061
  salt,
2038
- expiresAt: expiresAt !== undefined ? expiresAt : (serviceFlag ? null : ((prev && prev.expiresAt !== undefined) ? prev.expiresAt : undefined)),
2062
+ expiresAt: expiresRaw !== undefined ? expiresAt : (serviceFlag ? null : ((prev && prev.expiresAt !== undefined) ? prev.expiresAt : undefined)),
2039
2063
  service: serviceFlag ? true : ((prev && prev.service !== undefined) ? prev.service : undefined),
2040
2064
  revokedAt: undefined,
2041
2065
  role: newRole,
2042
2066
  v, hlc,
2043
- };
2067
+ });
2044
2068
  if (!prev) {
2045
2069
  // Fresh first-claim races (parallel registers): exactly one may win,
2046
2070
  // via atomic exclusive create. Losers fail loudly like a claimed name.
@@ -2472,7 +2496,11 @@ function cmdGroup(args) {
2472
2496
  const adds = [];
2473
2497
  const addRaw = getFlag(rest, "--add");
2474
2498
  if (addRaw) {
2475
- for (const part of String(addRaw).split(",")) {
2499
+ // Split on commas AND whitespace: PowerShell array-splats an unquoted
2500
+ // --add a,b,c into separate argv items that can rejoin with spaces
2501
+ // (npm shims), so "cam-1 cam-2 cam-3" must parse as three names, not
2502
+ // one joined member.
2503
+ for (const part of String(addRaw).split(/[,\s]+/)) {
2476
2504
  if (part.trim() === "") continue;
2477
2505
  const clean = sanitizeName(part, "recipient");
2478
2506
  if (!adds.includes(clean)) adds.push(clean);
@@ -2840,6 +2868,22 @@ function cmdSpawn(args) {
2840
2868
  const toGroupRaw = getFlag(args, "--to-group");
2841
2869
  if (alsoChannel && (!toGroupRaw || !String(toGroupRaw).trim())) fail("--also-channel needs --to-group <g,...> (it mirrors the brief into each group's channel)");
2842
2870
  const groupNames = String(toGroupRaw || "").split(",").map((s) => String(s).trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 40)).filter(Boolean);
2871
+ // Crew roster for the prompt block (see buildCrewContext in lib/spawn.js):
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).
2878
+ const rosterGroups = [];
2879
+ for (const e of listJson(d.groups)) {
2880
+ const g = e && e.data;
2881
+ if (g && g.name && Array.isArray(g.members)) rosterGroups.push({ name: g.name, members: g.members.slice() });
2882
+ }
2883
+ rosterGroups.sort((a, b) => String(a.name).localeCompare(String(b.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 });
2843
2887
  const model = getFlag(args, "--model");
2844
2888
  const auto = args.includes("--auto");
2845
2889
  requireAutoConfirm(args);
@@ -2895,12 +2939,12 @@ function cmdSpawn(args) {
2895
2939
  const driver = harnesses[i % harnesses.length];
2896
2940
  const itemMaxTurns = maxTurnsNum !== undefined ? maxTurnsNum : ((driver === "claude" || driver === "grok") ? 50 : undefined);
2897
2941
  const itemSpawnOpts = { ...spawnOpts, harness: driver, maxTurns: itemMaxTurns, allowTools: driver === "claude" ? allowTools : undefined };
2898
- const previewPrompt = buildSpawnPrompt({ name: to, from, subject, body: body.trim(), replyId: previewBatch || "msg-<id>", rev, cwd, root });
2942
+ const previewPrompt = buildSpawnPrompt({ name: to, from, subject, body: body.trim(), replyId: previewBatch || "msg-<id>", rev, cwd, root, crew: crewFor(to) });
2899
2943
  const t = buildSpawnTarget({ ...itemSpawnOpts, name: to, promptPath: `<logs>/${to}-<stamp>.prompt.md`, prompt: previewPrompt });
2900
2944
  const wtNote = worktreePrefix !== undefined ? ` worktree ../${to}-<stamp> (branch ${worktreePrefix}/${to}-<stamp>)` : branchPrefix !== undefined ? ` branch ${branchPrefix}/${to}-<stamp>` : "";
2901
2945
  console.log(`would spawn ${to} [${driver}] (${lifetime}) cwd ${cwd}${wtNote} cmd: ${formatSpawnCmd(t)} [board ${d.root}]`);
2902
2946
  });
2903
- console.log(`--- prompt (first worker) ---\n${buildSpawnPrompt({ name: recipients[0], from, subject, body: body.trim(), replyId: previewBatch || "msg-<id>", rev, cwd, root })}`);
2947
+ console.log(`--- prompt (first worker) ---\n${buildSpawnPrompt({ name: recipients[0], from, subject, body: body.trim(), replyId: previewBatch || "msg-<id>", rev, cwd, root, crew: crewFor(recipients[0]) })}`);
2904
2948
  return;
2905
2949
  }
2906
2950
  // Direct (N-copy) path is forced here (every worker needs its own message
@@ -2934,7 +2978,7 @@ function cmdSpawn(args) {
2934
2978
  } else if (branchPrefix !== undefined) {
2935
2979
  workerBranch = provisionBranch(cwd, branchPrefix, to).branch;
2936
2980
  }
2937
- const r = bootWorker(d, { ...itemSpawnOpts, cwd: workerCwd }, { to, id, from, subject, body: body.trim(), rev, logDir, budgetTokens, budgetMinutes, deadlineAt, spawnedWorktree: workerWorktree, spawnedBranch: workerBranch, spawnedLifetime: lifetime });
2981
+ const r = bootWorker(d, { ...itemSpawnOpts, cwd: workerCwd }, { to, id, from, subject, body: body.trim(), rev, logDir, budgetTokens, budgetMinutes, deadlineAt, spawnedWorktree: workerWorktree, spawnedBranch: workerBranch, spawnedLifetime: lifetime, crew: crewFor(to) });
2938
2982
  const where = workerWorktree ? ` worktree ${workerWorktree}` : workerBranch ? ` branch ${workerBranch}` : "";
2939
2983
  console.log(`spawned ${to} pid ${r.pid} [${driver}] (${lifetime}) log ${r.logPath} reply ${id}${where} [board ${d.root}]`);
2940
2984
  } catch (e) {
@@ -3366,9 +3410,12 @@ function cmdSpawnStatus(args) {
3366
3410
  const json = args.includes("--json");
3367
3411
  let names;
3368
3412
  if (args.includes("--all")) {
3413
+ // Pid-bearing workers plus bookkept ones whose pid record was lost
3414
+ // (e.g. an older dm-send wiped it): the latter still report via
3415
+ // workerStatus as "no pid recorded" instead of vanishing silently.
3369
3416
  names = listJson(d.agents)
3370
3417
  .map((e) => e.data)
3371
- .filter((x) => x && x.name && typeof x.spawnedPid === "number")
3418
+ .filter((x) => x && x.name && (typeof x.spawnedPid === "number" || x.spawnedBy || x.briefId))
3372
3419
  .map((x) => x.name)
3373
3420
  .sort();
3374
3421
  } else {
@@ -4303,14 +4350,43 @@ function cmdDoctor(args) {
4303
4350
  else info(`not a git checkout (sends omit rev)`);
4304
4351
 
4305
4352
  let ids = parseHarnessFlag(args);
4306
- if (ids.length === 0) {
4307
- if (meta && Array.isArray(meta.harnesses) && meta.harnesses.length > 0) ids = meta.harnesses.filter((h) => HARNESSES.includes(h));
4308
- else {
4309
- ids = detectHarnesses(cwd);
4310
- if (ids.length === 0) ids = ["generic"];
4353
+ if (ids.length === 0) {
4354
+ if (meta && Array.isArray(meta.harnesses) && meta.harnesses.length > 0) ids = meta.harnesses.filter((h) => HARNESSES.includes(h));
4355
+ else {
4356
+ ids = detectHarnesses(cwd);
4357
+ if (ids.length === 0) ids = ["generic"];
4358
+ }
4311
4359
  }
4312
- }
4313
- if (!local) {
4360
+ // Dead-man scan (read-only, board-local only — no cwd needed): the
4361
+ // events a lead cannot see without polling — workers that exited
4362
+ // without replying, passed deadlines, or went quiet while their pid
4363
+ // is still alive. Informational only — never FAIL. Placed before the
4364
+ // non-local early return so it runs for --board boards too.
4365
+ try {
4366
+ const dd = ensureBoard(root);
4367
+ const found = [];
4368
+ for (const e of listJson(path.join(root, "agents"))) {
4369
+ const a = e && e.data;
4370
+ if (!a || !a.name || typeof a.spawnedPid !== "number") continue;
4371
+ let st = null;
4372
+ try {
4373
+ st = workerStatus(dd, a.name, 1);
4374
+ } catch {
4375
+ continue;
4376
+ }
4377
+ if (!st || !st.known) continue;
4378
+ if (st.alive === false && a.briefId && !st.reply) {
4379
+ found.push(`${a.name} exited with no reply to its brief — check its log (${st.logPath || "no log yet"})`);
4380
+ } else if (st.budget && st.budget.exceeded) {
4381
+ found.push(`${a.name} is past its deadline/budget — see spawn-status --to ${a.name}`);
4382
+ } else if (st.alive === true && st.logIdleMs !== null && st.logIdleMs > 15 * 60 * 1000) {
4383
+ found.push(`${a.name} alive but no log output for ${Math.round(st.logIdleMs / 60000)}m — possibly spinning; see spawn-status --to ${a.name}`);
4384
+ }
4385
+ }
4386
+ for (const f of found.slice(0, 5)) info(`dead-man: ${f}`);
4387
+ if (found.length > 5) info(`dead-man: …and ${found.length - 5} more — see spawn-status --all`);
4388
+ } catch {}
4389
+ if (!local) {
4314
4390
  info(`non-local board: skipping project-file checks for ${root}`);
4315
4391
  console.log(bad === 0 ? "doctor: healthy" : `doctor: ${bad} problem(s)`);
4316
4392
  if (bad > 0) process.exitCode = 1;
@@ -4426,16 +4502,16 @@ function cmdDoctor(args) {
4426
4502
  break;
4427
4503
  }
4428
4504
  }
4429
- // Reconcile hint (read-only): workers whose pids look recycled since a
4430
- // reboot read dead instead of running. Informational only — never FAIL.
4431
- try {
4432
- const stale = listJson(path.join(root, "agents"))
4433
- .map((e) => e.data)
4434
- .filter((x) => x && x.name && isPidStale(x.spawnedPid, x.spawnedAt))
4435
- .map((x) => x.name);
4436
- if (stale.length > 0) info(`${stale.length} worker(s) with stale pids (presumed dead after reboot): ${stale.slice(0, 5).join(",")}${stale.length > 5 ? "…" : ""} — see spawn-status --all; respawn to reboot`);
4437
- } catch {}
4438
- if (!process.env.CREWBUS_AGENT) info("CREWBUS_AGENT is unset — hooks need it to know who you are");
4505
+ // Reconcile hint (read-only): workers whose pids look recycled since a
4506
+ // reboot read dead instead of running. Informational only — never FAIL.
4507
+ try {
4508
+ const stale = listJson(path.join(root, "agents"))
4509
+ .map((e) => e.data)
4510
+ .filter((x) => x && x.name && isPidStale(x.spawnedPid, x.spawnedAt))
4511
+ .map((x) => x.name);
4512
+ if (stale.length > 0) info(`${stale.length} worker(s) with stale pids (presumed dead after reboot): ${stale.slice(0, 5).join(",")}${stale.length > 5 ? "…" : ""} — see spawn-status --all; respawn to reboot`);
4513
+ } catch {}
4514
+ if (!process.env.CREWBUS_AGENT) info("CREWBUS_AGENT is unset — hooks need it to know who you are");
4439
4515
  if (!process.env.CREWBUS_TOKEN) info("CREWBUS_TOKEN is unset — sends/reads as a claimed name need it");
4440
4516
  try {
4441
4517
  const legacy = fs.readdirSync(path.join(root, "agents")).filter((f) => f.endsWith(".json")).map((f) => {
@@ -4801,6 +4877,7 @@ async function cmdCrew(args) {
4801
4877
  async function cmdServe(args) {
4802
4878
  const root = boardDir(args);
4803
4879
  const d = requireBoard(root);
4880
+ ensureBoardCliShim(d);
4804
4881
  const host = getFlag(args, "--host") || "127.0.0.1";
4805
4882
  const port = Number(getFlag(args, "--port") || 0);
4806
4883
  if (!(port >= 0 && port < 65536)) fail("--port must be 0-65535 (0 = random)");
@@ -6240,6 +6317,8 @@ Setup:
6240
6317
  crewbus init [--global] [--board <path>] [--force] [--no-opencode] [--portable]
6241
6318
  [--harness opencode,claude,codex,antigravity,grok,cursor,generic]
6242
6319
  create board in ./.crewbus, write AGENTS.md block, install harness
6320
+ crewbus install-cli
6321
+ install standalone CLI wrapper to user PATH (~/.crewbus/bin)
6243
6322
  wiring (hooks + MCP config + notes). Without --harness, init applies the
6244
6323
  union of detected markers (.opencode/.claude/.codex/.agents/.grok/.cursor).
6245
6324
  init also pre-approves board-only bus I/O (crewbus MCP server +
@@ -7243,6 +7322,21 @@ async function cmdPoolResume(args) {
7243
7322
  }
7244
7323
  }
7245
7324
 
7325
+ function cmdInstallCli() {
7326
+ const res = installCliToUserPath();
7327
+ if (res.ok) {
7328
+ console.log(`crewbus: standalone CLI wrappers written to ${res.binDir}`);
7329
+ if (res.addedToPath) {
7330
+ console.log(`crewbus: ${res.detail}`);
7331
+ console.log(`crewbus: restart your terminal window, then type "crewbus" to verify`);
7332
+ } else {
7333
+ console.log(`crewbus: ${res.detail}`);
7334
+ }
7335
+ } else {
7336
+ fail(`install-cli failed: ${res.detail || "unknown error"}`);
7337
+ }
7338
+ }
7339
+
7246
7340
  async function main() {
7247
7341
  const [, , cmd, ...rest] = process.argv;
7248
7342
  if (REMOVED.has(cmd)) {
@@ -7250,6 +7344,7 @@ async function main() {
7250
7344
  }
7251
7345
  switch (cmd) {
7252
7346
  case "init": return cmdInit(rest);
7347
+ case "install-cli": return cmdInstallCli();
7253
7348
  case "register": return cmdRegister(rest);
7254
7349
  case "acl": return cmdAcl(rest);
7255
7350
  case "agents": return cmdAgents(rest);
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).
@@ -104,7 +104,10 @@ export function readGroup(d, name) {
104
104
  export function expandGroups(d, raw) {
105
105
  const out = [];
106
106
  if (raw === undefined || raw === null || String(raw).trim() === "") return out;
107
- for (const part of String(raw).split(",")) {
107
+ for (const part of String(raw).split(/[,\s]+/)) {
108
+ // Group names carry no spaces (see cleanGroupName), so splitting on
109
+ // whitespace too is safe — and it saves PowerShell users whose
110
+ // unquoted --to-group a,b arrives space-rejoined (see cmdGroup --add).
108
111
  if (part.trim() === "") continue;
109
112
  const g = String(part).trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 40);
110
113
  if (!g || g === "@all") throw new Error(`invalid group name "${part}"`);
@@ -194,6 +197,99 @@ export function gatherTelemetry(items) {
194
197
  return { messages: items.length, tokensEst: Math.floor(chars / 4) };
195
198
  }
196
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
+
197
293
  export function batchReplyIds(d, batch) {
198
294
  const res = collectBatch(d, batch);
199
295
  if (!res) return null;
@@ -385,6 +385,25 @@ export function authorizeThrow(d, agent, action, scope) { // line 591
385
385
  return r;
386
386
  }
387
387
 
388
+ // Canonical agent-doc merge: every writer starts from prev and applies
389
+ // only its patch, so parallel writers (register, heartbeat, the dm-send
390
+ // tool, the dm-watch plugin) can never drop fields they don't own (spawn
391
+ // bookkeeping, tokens, roles). A patch key wins on any defined value —
392
+ // including false/0/"" — so callers MUST pass undefined for no-change.
393
+ // New agent-doc writers must use this or spread prev; never enumerate.
394
+ export function mergeAgentDoc(prev, patch) {
395
+ const base = (prev && typeof prev === "object") ? { ...prev } : {};
396
+ const p = (patch && typeof patch === "object") ? patch : {};
397
+ for (const k of Object.keys(p)) {
398
+ if (p[k] === undefined) delete base[k];
399
+ else base[k] = p[k];
400
+ }
401
+ for (const k of Object.keys(base)) {
402
+ if (base[k] === undefined) delete base[k];
403
+ }
404
+ return base;
405
+ }
406
+
388
407
  export function touchAgent(d, name, extra) { // line 2647
389
408
  const p = path.join(d.agents, `${name}.json`);
390
409
  const now = new Date().toISOString();
@@ -393,36 +412,13 @@ export function touchAgent(d, name, extra) { // line 2647
393
412
  prev = readJson(p);
394
413
  } catch {}
395
414
  const { v, hlc } = stampSyncDoc(prev);
396
- const doc = {
415
+ const doc = mergeAgentDoc(prev, {
397
416
  name,
398
417
  firstSeen: (prev && prev.firstSeen) || now,
399
418
  lastSeen: now,
400
- sessionId: (extra && extra.sessionId) || (prev && prev.sessionId) || undefined,
401
- lastDir: (extra && extra.lastDir) || (prev && prev.lastDir) || undefined,
402
- spawnedPid: (extra && extra.spawnedPid) || (prev && prev.spawnedPid) || undefined,
403
- spawnedAt: (extra && extra.spawnedAt) || (prev && prev.spawnedAt) || undefined,
404
- spawnedBy: (extra && extra.spawnedBy) || (prev && prev.spawnedBy) || undefined,
405
- briefId: (extra && extra.briefId) || (prev && prev.briefId) || undefined,
406
- budgetTokens: (extra && extra.budgetTokens) || (prev && prev.budgetTokens) || undefined,
407
- budgetMinutes: (extra && extra.budgetMinutes) || (prev && prev.budgetMinutes) || undefined,
408
- budgetSince: (extra && extra.budgetSince) || (prev && prev.budgetSince) || undefined,
409
- deadlineAt: (extra && extra.deadlineAt) || (prev && prev.deadlineAt) || undefined,
410
- spawnedWorktree: (extra && extra.spawnedWorktree) || (prev && prev.spawnedWorktree) || undefined,
411
- spawnedBranch: (extra && extra.spawnedBranch) || (prev && prev.spawnedBranch) || undefined,
412
- spawnedLifetime: (extra && extra.spawnedLifetime) || (prev && prev.spawnedLifetime) || undefined,
413
- spawnedHarness: (extra && extra.spawnedHarness) || (prev && prev.spawnedHarness) || undefined,
414
- spawnedEnvScrubbed: (extra && extra.spawnedEnvScrubbed) || (prev && prev.spawnedEnvScrubbed) || undefined,
415
- token: (prev && prev.token) || undefined,
416
- tokenHash: (prev && prev.tokenHash) || undefined,
417
- salt: (prev && prev.salt) || undefined,
418
- expiresAt: (extra && extra.expiresAt !== undefined ? extra.expiresAt : undefined) ?? (prev && prev.expiresAt !== undefined ? prev.expiresAt : undefined),
419
- rotatedAt: (extra && extra.rotatedAt !== undefined ? extra.rotatedAt : undefined) ?? (prev && prev.rotatedAt !== undefined ? prev.rotatedAt : undefined),
420
- service: (extra && extra.service !== undefined ? extra.service : undefined) ?? (prev && prev.service !== undefined ? prev.service : undefined),
421
- offboarded: (extra && extra.offboarded !== undefined ? extra.offboarded : undefined) ?? (prev && prev.offboarded !== undefined ? prev.offboarded : undefined),
422
- revokedAt: (extra && extra.revokedAt !== undefined ? extra.revokedAt : undefined) ?? (prev && prev.revokedAt !== undefined ? prev.revokedAt : undefined),
423
- role: (extra && extra.role !== undefined ? extra.role : undefined) ?? (prev && prev.role !== undefined ? prev.role : undefined),
419
+ ...(extra || {}),
424
420
  v, hlc,
425
- };
421
+ });
426
422
  writeAgentFile(d, name, doc);
427
423
  return doc;
428
424
  }