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.
@@ -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,30 @@
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.3 (2026-10-10)
8
+
9
+ 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).
10
+
11
+ - **Spawn briefs carry a Crew: roster block**:
12
+ - Every `spawn` brief now names the worker's group(s) + members + lane, the other groups + members, and their group channels (`grp-<group>`).
13
+ - 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.
14
+ - Documented as cross-group collaboration norms in `docs/GROUPS.md`.
15
+ - **Agent-doc merge canonicalization**:
16
+ - New `mergeAgentDoc(prev, patch)` in `bin/lib/identity.js`; `touchAgent`, CLI `heartbeat`, and `register`'s mint path route through it.
17
+ - 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.
18
+ - `syncWorkerSession` preserves binding fields (`promptPath`, model, cwd, cmd, respawn counts), which `respawn` needs.
19
+ - **Truthful presence + session hygiene**:
20
+ - `boardSnapshot` gates pid-bearing agents on verified process liveness; pid-less agents keep lastSeen freshness.
21
+ - The watcher TTL-expires session mappings (30m without a confirmed delivery or re-registration) instead of heartbeating dead sessions forever.
22
+ - **Observability**:
23
+ - Boot-metadata first line in every worker log; `workerStatus` surfaces `logSize`/`logMtimeMs`/`logIdleMs`.
24
+ - `doctor` gains a read-only dead-man scan (exited without reply, past deadline, alive-but-quiet) — informational, never FAIL.
25
+ - 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.
26
+ - **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`.
27
+
3
28
  ## 10.0.2 (2026-10-09)
4
29
 
5
30
  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.3 — 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.3) 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,17 @@ 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
+ // 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.
2875
+ const rosterGroups = [];
2876
+ for (const e of listJson(d.groups)) {
2877
+ const g = e && e.data;
2878
+ if (g && g.name && Array.isArray(g.members)) rosterGroups.push({ name: g.name, members: g.members.slice() });
2879
+ }
2880
+ 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) });
2843
2882
  const model = getFlag(args, "--model");
2844
2883
  const auto = args.includes("--auto");
2845
2884
  requireAutoConfirm(args);
@@ -2895,12 +2934,12 @@ function cmdSpawn(args) {
2895
2934
  const driver = harnesses[i % harnesses.length];
2896
2935
  const itemMaxTurns = maxTurnsNum !== undefined ? maxTurnsNum : ((driver === "claude" || driver === "grok") ? 50 : undefined);
2897
2936
  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 });
2937
+ const previewPrompt = buildSpawnPrompt({ name: to, from, subject, body: body.trim(), replyId: previewBatch || "msg-<id>", rev, cwd, root, crew: crewFor(to) });
2899
2938
  const t = buildSpawnTarget({ ...itemSpawnOpts, name: to, promptPath: `<logs>/${to}-<stamp>.prompt.md`, prompt: previewPrompt });
2900
2939
  const wtNote = worktreePrefix !== undefined ? ` worktree ../${to}-<stamp> (branch ${worktreePrefix}/${to}-<stamp>)` : branchPrefix !== undefined ? ` branch ${branchPrefix}/${to}-<stamp>` : "";
2901
2940
  console.log(`would spawn ${to} [${driver}] (${lifetime}) cwd ${cwd}${wtNote} cmd: ${formatSpawnCmd(t)} [board ${d.root}]`);
2902
2941
  });
2903
- console.log(`--- prompt (first worker) ---\n${buildSpawnPrompt({ name: recipients[0], from, subject, body: body.trim(), replyId: previewBatch || "msg-<id>", rev, cwd, root })}`);
2942
+ 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
2943
  return;
2905
2944
  }
2906
2945
  // Direct (N-copy) path is forced here (every worker needs its own message
@@ -2934,7 +2973,7 @@ function cmdSpawn(args) {
2934
2973
  } else if (branchPrefix !== undefined) {
2935
2974
  workerBranch = provisionBranch(cwd, branchPrefix, to).branch;
2936
2975
  }
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 });
2976
+ 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
2977
  const where = workerWorktree ? ` worktree ${workerWorktree}` : workerBranch ? ` branch ${workerBranch}` : "";
2939
2978
  console.log(`spawned ${to} pid ${r.pid} [${driver}] (${lifetime}) log ${r.logPath} reply ${id}${where} [board ${d.root}]`);
2940
2979
  } catch (e) {
@@ -3366,9 +3405,12 @@ function cmdSpawnStatus(args) {
3366
3405
  const json = args.includes("--json");
3367
3406
  let names;
3368
3407
  if (args.includes("--all")) {
3408
+ // Pid-bearing workers plus bookkept ones whose pid record was lost
3409
+ // (e.g. an older dm-send wiped it): the latter still report via
3410
+ // workerStatus as "no pid recorded" instead of vanishing silently.
3369
3411
  names = listJson(d.agents)
3370
3412
  .map((e) => e.data)
3371
- .filter((x) => x && x.name && typeof x.spawnedPid === "number")
3413
+ .filter((x) => x && x.name && (typeof x.spawnedPid === "number" || x.spawnedBy || x.briefId))
3372
3414
  .map((x) => x.name)
3373
3415
  .sort();
3374
3416
  } else {
@@ -4303,14 +4345,43 @@ function cmdDoctor(args) {
4303
4345
  else info(`not a git checkout (sends omit rev)`);
4304
4346
 
4305
4347
  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"];
4348
+ if (ids.length === 0) {
4349
+ if (meta && Array.isArray(meta.harnesses) && meta.harnesses.length > 0) ids = meta.harnesses.filter((h) => HARNESSES.includes(h));
4350
+ else {
4351
+ ids = detectHarnesses(cwd);
4352
+ if (ids.length === 0) ids = ["generic"];
4353
+ }
4311
4354
  }
4312
- }
4313
- if (!local) {
4355
+ // Dead-man scan (read-only, board-local only — no cwd needed): the
4356
+ // events a lead cannot see without polling — workers that exited
4357
+ // without replying, passed deadlines, or went quiet while their pid
4358
+ // is still alive. Informational only — never FAIL. Placed before the
4359
+ // non-local early return so it runs for --board boards too.
4360
+ try {
4361
+ const dd = ensureBoard(root);
4362
+ const found = [];
4363
+ for (const e of listJson(path.join(root, "agents"))) {
4364
+ const a = e && e.data;
4365
+ if (!a || !a.name || typeof a.spawnedPid !== "number") continue;
4366
+ let st = null;
4367
+ try {
4368
+ st = workerStatus(dd, a.name, 1);
4369
+ } catch {
4370
+ continue;
4371
+ }
4372
+ if (!st || !st.known) continue;
4373
+ if (st.alive === false && a.briefId && !st.reply) {
4374
+ found.push(`${a.name} exited with no reply to its brief — check its log (${st.logPath || "no log yet"})`);
4375
+ } else if (st.budget && st.budget.exceeded) {
4376
+ found.push(`${a.name} is past its deadline/budget — see spawn-status --to ${a.name}`);
4377
+ } else if (st.alive === true && st.logIdleMs !== null && st.logIdleMs > 15 * 60 * 1000) {
4378
+ found.push(`${a.name} alive but no log output for ${Math.round(st.logIdleMs / 60000)}m — possibly spinning; see spawn-status --to ${a.name}`);
4379
+ }
4380
+ }
4381
+ for (const f of found.slice(0, 5)) info(`dead-man: ${f}`);
4382
+ if (found.length > 5) info(`dead-man: …and ${found.length - 5} more — see spawn-status --all`);
4383
+ } catch {}
4384
+ if (!local) {
4314
4385
  info(`non-local board: skipping project-file checks for ${root}`);
4315
4386
  console.log(bad === 0 ? "doctor: healthy" : `doctor: ${bad} problem(s)`);
4316
4387
  if (bad > 0) process.exitCode = 1;
@@ -4426,16 +4497,16 @@ function cmdDoctor(args) {
4426
4497
  break;
4427
4498
  }
4428
4499
  }
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");
4500
+ // Reconcile hint (read-only): workers whose pids look recycled since a
4501
+ // reboot read dead instead of running. Informational only — never FAIL.
4502
+ try {
4503
+ const stale = listJson(path.join(root, "agents"))
4504
+ .map((e) => e.data)
4505
+ .filter((x) => x && x.name && isPidStale(x.spawnedPid, x.spawnedAt))
4506
+ .map((x) => x.name);
4507
+ 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`);
4508
+ } catch {}
4509
+ if (!process.env.CREWBUS_AGENT) info("CREWBUS_AGENT is unset — hooks need it to know who you are");
4439
4510
  if (!process.env.CREWBUS_TOKEN) info("CREWBUS_TOKEN is unset — sends/reads as a claimed name need it");
4440
4511
  try {
4441
4512
  const legacy = fs.readdirSync(path.join(root, "agents")).filter((f) => f.endsWith(".json")).map((f) => {
@@ -4801,6 +4872,7 @@ async function cmdCrew(args) {
4801
4872
  async function cmdServe(args) {
4802
4873
  const root = boardDir(args);
4803
4874
  const d = requireBoard(root);
4875
+ ensureBoardCliShim(d);
4804
4876
  const host = getFlag(args, "--host") || "127.0.0.1";
4805
4877
  const port = Number(getFlag(args, "--port") || 0);
4806
4878
  if (!(port >= 0 && port < 65536)) fail("--port must be 0-65535 (0 = random)");
@@ -6240,6 +6312,8 @@ Setup:
6240
6312
  crewbus init [--global] [--board <path>] [--force] [--no-opencode] [--portable]
6241
6313
  [--harness opencode,claude,codex,antigravity,grok,cursor,generic]
6242
6314
  create board in ./.crewbus, write AGENTS.md block, install harness
6315
+ crewbus install-cli
6316
+ install standalone CLI wrapper to user PATH (~/.crewbus/bin)
6243
6317
  wiring (hooks + MCP config + notes). Without --harness, init applies the
6244
6318
  union of detected markers (.opencode/.claude/.codex/.agents/.grok/.cursor).
6245
6319
  init also pre-approves board-only bus I/O (crewbus MCP server +
@@ -7243,6 +7317,21 @@ async function cmdPoolResume(args) {
7243
7317
  }
7244
7318
  }
7245
7319
 
7320
+ function cmdInstallCli() {
7321
+ const res = installCliToUserPath();
7322
+ if (res.ok) {
7323
+ console.log(`crewbus: standalone CLI wrappers written to ${res.binDir}`);
7324
+ if (res.addedToPath) {
7325
+ console.log(`crewbus: ${res.detail}`);
7326
+ console.log(`crewbus: restart your terminal window, then type "crewbus" to verify`);
7327
+ } else {
7328
+ console.log(`crewbus: ${res.detail}`);
7329
+ }
7330
+ } else {
7331
+ fail(`install-cli failed: ${res.detail || "unknown error"}`);
7332
+ }
7333
+ }
7334
+
7246
7335
  async function main() {
7247
7336
  const [, , cmd, ...rest] = process.argv;
7248
7337
  if (REMOVED.has(cmd)) {
@@ -7250,6 +7339,7 @@ async function main() {
7250
7339
  }
7251
7340
  switch (cmd) {
7252
7341
  case "init": return cmdInit(rest);
7342
+ case "install-cli": return cmdInstallCli();
7253
7343
  case "register": return cmdRegister(rest);
7254
7344
  case "acl": return cmdAcl(rest);
7255
7345
  case "agents": return cmdAgents(rest);
package/bin/lib/groups.js CHANGED
@@ -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}"`);
@@ -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
  }
package/bin/lib/mail.js CHANGED
@@ -9,7 +9,7 @@ import path from "node:path";
9
9
  import crypto from "node:crypto";
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { readJson, writeJson, listJson, newId, sanitizeName, fail, MAX_RECIPIENTS, BROADCAST_AFTER, MAX_FWD_DEPTH, SEND_RATE_CAP, SEND_RATE_WINDOW_MS, DEDUPE_WINDOW_MS, cleanPriority, cleanSubject, cleanReply, cleanArtifact, stampSyncDoc } from "./store.js";
12
- import { writeAgentFile, timingSafeEqualStr } from "./identity.js";
12
+ import { writeAgentFile, timingSafeEqualStr, mergeAgentDoc } from "./identity.js";
13
13
  import { writeTombstone } from "./sync.js";
14
14
  import { boardHmacKey } from "./export.js";
15
15
 
@@ -733,34 +733,15 @@ export function heartbeat(d, name, minAgeMs) { // line 2689
733
733
  if (!(age >= minAgeMs)) return prev;
734
734
  }
735
735
  const { v, hlc } = stampSyncDoc(prev);
736
- const doc = {
736
+ // Merge, never enumerate: heartbeat must not drop fields it doesn't own
737
+ // (spawn bookkeeping, tokens, roles — see mergeAgentDoc in identity.js).
738
+ const doc = mergeAgentDoc(prev, {
737
739
  name,
738
740
  firstSeen: (prev && prev.firstSeen) || now,
739
741
  lastSeen: now,
740
- sessionId: (prev && prev.sessionId) || undefined,
741
742
  lastDir: process.cwd(),
742
- spawnedPid: (prev && prev.spawnedPid) || undefined,
743
- spawnedAt: (prev && prev.spawnedAt) || undefined,
744
- spawnedBy: (prev && prev.spawnedBy) || undefined,
745
- briefId: (prev && prev.briefId) || undefined,
746
- budgetTokens: (prev && prev.budgetTokens) || undefined,
747
- budgetMinutes: (prev && prev.budgetMinutes) || undefined,
748
- budgetSince: (prev && prev.budgetSince) || undefined,
749
- deadlineAt: (prev && prev.deadlineAt) || undefined,
750
- spawnedWorktree: (prev && prev.spawnedWorktree) || undefined,
751
- spawnedBranch: (prev && prev.spawnedBranch) || undefined,
752
- spawnedLifetime: (prev && prev.spawnedLifetime) || undefined,
753
- token: (prev && prev.token) || undefined,
754
- tokenHash: (prev && prev.tokenHash) || undefined,
755
- salt: (prev && prev.salt) || undefined,
756
- expiresAt: (prev && prev.expiresAt !== undefined ? prev.expiresAt : undefined),
757
- rotatedAt: (prev && prev.rotatedAt !== undefined ? prev.rotatedAt : undefined),
758
- service: (prev && prev.service !== undefined ? prev.service : undefined),
759
- offboarded: (prev && prev.offboarded !== undefined ? prev.offboarded : undefined),
760
- revokedAt: (prev && prev.revokedAt !== undefined ? prev.revokedAt : undefined),
761
- role: (prev && prev.role !== undefined ? prev.role : undefined),
762
743
  v, hlc,
763
- };
744
+ });
764
745
  try {
765
746
  fs.mkdirSync(d.agents, { recursive: true });
766
747
  writeAgentFile(d, name, doc);