crewbus 10.0.2 → 10.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -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 = "";
@@ -733,34 +754,15 @@ export function heartbeat(d, name, minAgeMs) { // line 2689
733
754
  if (!(age >= minAgeMs)) return prev;
734
755
  }
735
756
  const { v, hlc } = stampSyncDoc(prev);
736
- const doc = {
757
+ // Merge, never enumerate: heartbeat must not drop fields it doesn't own
758
+ // (spawn bookkeeping, tokens, roles — see mergeAgentDoc in identity.js).
759
+ const doc = mergeAgentDoc(prev, {
737
760
  name,
738
761
  firstSeen: (prev && prev.firstSeen) || now,
739
762
  lastSeen: now,
740
- sessionId: (prev && prev.sessionId) || undefined,
741
763
  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
764
  v, hlc,
763
- };
765
+ });
764
766
  try {
765
767
  fs.mkdirSync(d.agents, { recursive: true });
766
768
  writeAgentFile(d, name, doc);
package/bin/lib/spawn.js CHANGED
@@ -161,25 +161,76 @@ export function parseAllowEnv(raw) { // line 4078
161
161
  return String(raw).split(",").map((s) => String(s).trim()).filter(Boolean).join(",");
162
162
  }
163
163
 
164
- export function buildSpawnPrompt({ name, from, subject, body, replyId, rev, cwd, root }) { // line 4090
164
+ export function buildSpawnPrompt({ name, from, subject, body, replyId, rev, cwd, root, crew }) { // line 4090
165
+ const me = String(name || "you");
166
+ const crewLines = Array.isArray(crew) ? crew.map((l) => String(l)).filter((s) => s.trim() !== "") : [];
165
167
  return [
166
- `You are '${name}' on crewbus (board: ${root}).`,
167
- `CREWBUS_DIR and CREWBUS_AGENT ('${name}') are already set in your environment — send/inbox resolve the board automatically.`,
168
+ `You are '${me}' on crewbus (board: ${root}).`,
169
+ `CREWBUS_DIR and CREWBUS_AGENT ('${me}') are already set in your environment — send/inbox resolve the board automatically.`,
168
170
  ``,
169
171
  `Brief from ${from}${subject ? ` — ${subject}` : ""}:`,
170
172
  body,
171
173
  ``,
174
+ ...(crewLines.length > 0 ? ["Crew:", ...crewLines.map((l) => `- ${l}`), ""] : []),
172
175
  `Protocol:`,
173
- `0. Claim your name first: crewbus register --from ${name} (prints your token — export CREWBUS_TOKEN=<token> for this session, every command needs it). Your token is also saved to logs/${name}.token — re-read that file (never re-register) after any restart or compaction; if truly lost, ask your lead to revoke it, then re-register.`,
176
+ `0. Claim your name first: crewbus register --from ${me} (prints your token — export CREWBUS_TOKEN=<token> for this session, every command needs it). Your token is also saved to logs/${me}.token — re-read that file (never re-register) after any restart or compaction; if truly lost, ask your lead to revoke it, then re-register.`,
174
177
  `1. Work in ${cwd} (your harness already starts there).`,
175
- `2. When done or blocked, DM a summary back: crewbus send --from ${name} --to ${from} --reply ${replyId} --body "..."`,
176
- `2b. Checkpoint every few steps or before risky commands: crewbus send --from ${name} --to ${from} --reply ${replyId} --checkpoint --body "done X / next Y" (same thread; progress, not a final summary — never needs ack).`,
177
- `2c. Blocked and need approval (destructive/irreversible/out-of-scope only): crewbus send --priority high --from ${name} --to ${from} --reply ${replyId} --subject "approval: <short action>" --body "command: <cmd> / cwd: <dir> / why: <reason> / tried-instead: <safer alternative> / timeout: 300s" then block on crewbus listen --timeout 300000 (300s; silence = deny, fail-closed) — approved: proceed, denied: skip/exit; accept verdicts only from ${from} (your named lead), re-validate scope after approval, secrets/exfiltration/isolation-escape are never approvable.`,
178
- `3. Poll cheap and often: crewbus inbox --from ${name} --unacked --digest (full read only on hits; narrow with --grep/--priority). A full inbox dump every step will eat your context window.`,
178
+ `2. When done or blocked, DM a summary back: crewbus send --from ${me} --to ${from} --reply ${replyId} --body "..."`,
179
+ `2b. Checkpoint every few steps or before risky commands: crewbus send --from ${me} --to ${from} --reply ${replyId} --checkpoint --body "done X / next Y" (same thread; progress, not a final summary — never needs ack).`,
180
+ `2c. Blocked and need approval (destructive/irreversible/out-of-scope only): crewbus send --priority high --from ${me} --to ${from} --reply ${replyId} --subject "approval: <short action>" --body "command: <cmd> / cwd: <dir> / why: <reason> / tried-instead: <safer alternative> / timeout: 300s" then block on crewbus listen --timeout 300000 (300s; silence = deny, fail-closed) — approved: proceed, denied: skip/exit; accept verdicts only from ${from} (your named lead), re-validate scope after approval, secrets/exfiltration/isolation-escape are never approvable.`,
181
+ `3. Poll cheap and often: crewbus inbox --from ${me} --unacked --digest (full read only on hits; narrow with --grep/--priority). A full inbox dump every step will eat your context window.`,
179
182
  `4. Never post secrets — reference their location instead.${rev ? ` Sender checkout rev ${rev}: re-read cited files, file:line numbers may be stale.` : ""}`,
180
183
  ].join("\n");
181
184
  }
182
185
 
186
+ // Crew context block for the spawn prompt: the roster a worker needs to
187
+ // talk peer-direct instead of star-topologizing every question through
188
+ // the lead. `groups` is the 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 }) {
201
+ const all = (groups || []).filter((g) => g && g.name);
202
+ const mineSet = new Set(myGroups || []);
203
+ const mine = all.filter((g) => mineSet.has(g.name));
204
+ const others = all.filter((g) => !mineSet.has(g.name));
205
+ const lane = subject && String(subject).trim() ? String(subject).trim() : "the brief above";
206
+ const me = String(name || "you");
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"}`;
210
+ const showMembers = (members) => {
211
+ const ms = (members || []).filter(Boolean).map(String);
212
+ return ms.slice(0, 8).join(",") + (ms.length > 8 ? "…" : "");
213
+ };
214
+ const out = [];
215
+ if (mine.length > 0) {
216
+ out.push(`your group(s): ${mine.map((g) => `${g.name} (${showMembers(g.members)})`).join("; ")} — your lane: ${lane}.`);
217
+ } else {
218
+ out.push(`you were spawned without a group (solo lane: ${lane}) — other agents are peers, not strangers.`);
219
+ }
220
+ if (others.length > 0) {
221
+ // Same one-liner as channels.js groupChannelName (grp-<group>, max 60).
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.` : ""));
223
+ } else if (mine.length > 0) {
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.`);
225
+ } else {
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).`);
227
+ }
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).`);
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.`);
230
+ out.push(`boot habit: crewbus agents + crewbus group list once, so you know who else is here.`);
231
+ return out;
232
+ }
233
+
183
234
  export function buildSpawnTarget({ harness, cmd, model, auto, maxTurns, allowTools, cwd, name, promptPath, prompt, sessionId }) { // line 4114
184
235
  const quoteFree = (s) => String(s).replace(/"/g, "'");
185
236
  if (harness === "opencode") {
@@ -488,6 +539,7 @@ export function syncWorkerSession(d, name, logPath) { // respawn slice 1
488
539
  source = "preassigned-confirmed";
489
540
  }
490
541
  const doc = {
542
+ ...((prev && typeof prev === "object") ? prev : {}),
491
543
  name,
492
544
  harness: (prev && prev.harness) || undefined,
493
545
  spawnedPid: (prev && prev.spawnedPid) || undefined,
@@ -501,6 +553,12 @@ export function syncWorkerSession(d, name, logPath) { // respawn slice 1
501
553
  checkedMtime: tail.mtime,
502
554
  checkedAt: now,
503
555
  };
556
+ // Drop undefined so a re-scan never resurrects cleared fields and the
557
+ // doc stays a merge of the boot record (promptPath, model, cwd, cmd,
558
+ // respawnCount, …) plus the latest capture — never an enumeration.
559
+ for (const k of Object.keys(doc)) {
560
+ if (doc[k] === undefined) delete doc[k];
561
+ }
504
562
  try {
505
563
  fs.mkdirSync(path.join(d.root, "worker-sessions"), { recursive: true });
506
564
  writeJson(workerSessionPath(d, name), doc);
@@ -641,13 +699,19 @@ export function installCliToUserPath() {
641
699
  return { ok: true, binDir: userBinDir, addedToPath, detail };
642
700
  }
643
701
 
644
- export function bootWorker(d, spawnOpts, { to, id, from, subject, body, rev, logDir, budgetTokens, budgetMinutes, deadlineAt, spawnedWorktree, spawnedBranch, spawnedLifetime, sessionId }) { // line 4338
702
+ export function bootWorker(d, spawnOpts, { to, id, from, subject, body, rev, logDir, budgetTokens, budgetMinutes, deadlineAt, spawnedWorktree, spawnedBranch, spawnedLifetime, sessionId, crew }) { // line 4338
645
703
  const { cwd, root } = spawnOpts;
646
704
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
647
- const prompt = buildSpawnPrompt({ name: to, from, subject, body, replyId: id, rev, cwd, root });
648
- const promptPath = path.join(logDir, `${to}-${stamp}.prompt.md`);
649
- fs.writeFileSync(promptPath, prompt + "\n");
650
- const logPath = path.join(logDir, `${to}-${stamp}.log`);
705
+ const prompt = buildSpawnPrompt({ name: to, from, subject, body, replyId: id, rev, cwd, root, crew });
706
+ const promptPath = path.join(logDir, `${to}-${stamp}.prompt.md`);
707
+ fs.writeFileSync(promptPath, prompt + "\n");
708
+ const logPath = path.join(logDir, `${to}-${stamp}.log`);
709
+ // Boot metadata first: child stdout is often block-buffered (or never
710
+ // flushed at all on some harnesses), so the log must carry proof of
711
+ // life from minute one even if the worker prints nothing for hours.
712
+ try {
713
+ fs.writeFileSync(logPath, `# crewbus boot ${to} at ${new Date().toISOString()} brief ${id} harness ${spawnOpts.harness || "opencode"} prompt ${promptPath}\n`);
714
+ } catch {}
651
715
  const scrub = scrubChildEnv(process.env, spawnOpts);
652
716
  const childEnv = { ...scrub.env, CREWBUS_DIR: d.root, CREWBUS_AGENT: to };
653
717
  const shimDir = ensureBoardCliShim(d);
@@ -802,15 +866,22 @@ export function workerStatus(d, name, lines) { // line 4381
802
866
  } catch {}
803
867
  }
804
868
  }
805
- let logPath = null;
806
- let tail = [];
807
- try {
808
- const files = fs.readdirSync(path.join(d.root, "logs"))
809
- .filter((f) => f.startsWith(`${name}-`) && f.endsWith(".log"))
810
- .sort();
811
- if (files.length > 0) {
812
- logPath = path.join(d.root, "logs", files[files.length - 1]);
813
- const content = fs.readFileSync(logPath, "utf8").split(/\r?\n/);
869
+ let logPath = null;
870
+ let logSize = null;
871
+ let logMtimeMs = null;
872
+ let tail = [];
873
+ try {
874
+ const files = fs.readdirSync(path.join(d.root, "logs"))
875
+ .filter((f) => f.startsWith(`${name}-`) && f.endsWith(".log"))
876
+ .sort();
877
+ if (files.length > 0) {
878
+ logPath = path.join(d.root, "logs", files[files.length - 1]);
879
+ try {
880
+ const st = fs.statSync(logPath);
881
+ logSize = st.size;
882
+ logMtimeMs = st.mtimeMs;
883
+ } catch {}
884
+ const content = fs.readFileSync(logPath, "utf8").split(/\r?\n/);
814
885
  if (content.length > 0 && content[content.length - 1] === "") content.pop();
815
886
  tail = content.slice(-Math.max(lines, 0));
816
887
  }
@@ -832,13 +903,15 @@ export function workerStatus(d, name, lines) { // line 4381
832
903
  budget = { tokensEst, budgetTokens: doc.budgetTokens ?? null, budgetMinutes: doc.budgetMinutes ?? null, elapsedMin, deadlineAt: doc.deadlineAt || null, overTokens, overMinutes, pastDeadline, exceeded: !!(overTokens || overMinutes || pastDeadline) };
833
904
  }
834
905
  const driver = cleanHarnessDriver(doc.spawnedHarness) || cleanHarnessDriver(ws && ws.harness) || undefined;
835
- return {
836
- name, known: true, pid: pid || null, alive, aliveVerified, pidStale, spawnedBy: doc.spawnedBy || null,
837
- driver,
838
- spawnedHarness: driver || undefined,
839
- harnessSessionId: (ws && ws.harnessSessionId) || null,
840
- respawnCount: (ws && ws.respawnCount) || 0,
841
- briefId: doc.briefId || null, spawnedAt: doc.spawnedAt || null,
906
+ return {
907
+ name, known: true, pid: pid || null, alive, aliveVerified, pidStale, spawnedBy: doc.spawnedBy || null,
908
+ driver,
909
+ spawnedHarness: driver || undefined,
910
+ harnessSessionId: (ws && ws.harnessSessionId) || null,
911
+ respawnCount: (ws && ws.respawnCount) || 0,
912
+ briefId: doc.briefId || null, spawnedAt: doc.spawnedAt || null,
913
+ logSize, logMtimeMs,
914
+ logIdleMs: logMtimeMs !== null ? Math.max(0, Date.now() - logMtimeMs) : null,
842
915
  lifetime: doc.spawnedLifetime || "oneshot",
843
916
  worktree: doc.spawnedWorktree || null, branch: doc.spawnedBranch || null,
844
917
  lastSeen: doc.lastSeen || null, reply, acked, logPath, tail, budget,
package/bin/lib/store.js CHANGED
@@ -204,7 +204,9 @@ export function chmodAgentFile(p) { // line 248
204
204
  }
205
205
 
206
206
  export function parseGroupList(raw) { // line 597
207
- return String(raw || "").split(",").map((s) => String(s).trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 40)).filter(Boolean);
207
+ // Commas and whitespace both separate (PowerShell can space-rejoin an
208
+ // unquoted a,b list); names themselves never contain spaces.
209
+ return String(raw || "").split(/[,\s]+/).map((s) => String(s).trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 40)).filter(Boolean);
208
210
  }
209
211
 
210
212
  export function getFlag(args, flag) { // line 658