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/AGENTS.template.md +1 -1
- package/CHANGELOG.md +41 -0
- package/README.md +9 -2
- package/bin/crewbus.js +150 -55
- package/bin/lib/groups.js +98 -2
- package/bin/lib/identity.js +22 -26
- package/bin/lib/mail.js +26 -24
- package/bin/lib/spawn.js +102 -29
- package/bin/lib/store.js +3 -1
- package/bin/lib/web.js +182 -29
- package/docs/GROUPS.md +107 -77
- package/docs/TROUBLESHOOTING.md +26 -25
- package/docs/VERSION_SKEW.md +1 -1
- package/documentation.html +3 -3
- package/index.html +5 -5
- package/opencode/plugins/dm-watch.js +38 -18
- package/opencode/tools/dm-send.js +14 -6
- package/package.json +1 -1
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
|
-
|
|
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 '${
|
|
167
|
-
`CREWBUS_DIR and CREWBUS_AGENT ('${
|
|
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 ${
|
|
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 ${
|
|
176
|
-
`2b. Checkpoint every few steps or before risky commands: crewbus send --from ${
|
|
177
|
-
`2c. Blocked and need approval (destructive/irreversible/out-of-scope only): crewbus send --priority high --from ${
|
|
178
|
-
`3. Poll cheap and often: crewbus inbox --from ${
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
.
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
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
|