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.
- package/AGENTS.template.md +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +9 -2
- package/bin/crewbus.js +145 -55
- package/bin/lib/groups.js +4 -1
- package/bin/lib/identity.js +22 -26
- package/bin/lib/mail.js +5 -24
- package/bin/lib/spawn.js +95 -29
- package/bin/lib/store.js +3 -1
- package/bin/lib/web.js +78 -13
- package/docs/GROUPS.md +102 -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/spawn.js
CHANGED
|
@@ -161,25 +161,69 @@ 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 board's full group list [{ name, members }],
|
|
189
|
+
// `myGroups` the group names this recipient belongs to, `subject` the lane
|
|
190
|
+
// line for my groups (the spawn call's --subject). Workers may send to
|
|
191
|
+
// each other (and to-group) and tail any channel, but channel-post is
|
|
192
|
+
// lead|admin-only (see identity.js LEAD_PLUS) — so the block points reads
|
|
193
|
+
// at channels and writes at DMs. Returns bullet lines (no header); the
|
|
194
|
+
// caller renders them under a Crew: header. Pure: unit-tested, no board IO.
|
|
195
|
+
export function buildCrewContext({ name, from, subject, groups, myGroups }) {
|
|
196
|
+
const all = (groups || []).filter((g) => g && g.name);
|
|
197
|
+
const mineSet = new Set(myGroups || []);
|
|
198
|
+
const mine = all.filter((g) => mineSet.has(g.name));
|
|
199
|
+
const others = all.filter((g) => !mineSet.has(g.name));
|
|
200
|
+
const lane = subject && String(subject).trim() ? String(subject).trim() : "the brief above";
|
|
201
|
+
const me = String(name || "you");
|
|
202
|
+
const lead = String(from || "lead");
|
|
203
|
+
const showMembers = (members) => {
|
|
204
|
+
const ms = (members || []).filter(Boolean).map(String);
|
|
205
|
+
return ms.slice(0, 8).join(",") + (ms.length > 8 ? "…" : "");
|
|
206
|
+
};
|
|
207
|
+
const out = [];
|
|
208
|
+
if (mine.length > 0) {
|
|
209
|
+
out.push(`your group(s): ${mine.map((g) => `${g.name} (${showMembers(g.members)})`).join("; ")} — your lane: ${lane}.`);
|
|
210
|
+
} else {
|
|
211
|
+
out.push(`you were spawned without a group (solo lane: ${lane}) — other agents are peers, not strangers.`);
|
|
212
|
+
}
|
|
213
|
+
if (others.length > 0) {
|
|
214
|
+
// Same one-liner as channels.js groupChannelName (grp-<group>, max 60).
|
|
215
|
+
out.push(`other groups on this board: ${others.map((g) => `${g.name} (${showMembers(g.members)})`).join("; ")} — their channels: ${others.map((g) => `grp-${g.name}`.slice(0, 60)).join(", ")}.`);
|
|
216
|
+
} else if (mine.length > 0) {
|
|
217
|
+
out.push(`no other groups on this board right now — discover live agents with: crewbus agents.`);
|
|
218
|
+
} else {
|
|
219
|
+
out.push(`discover who else is on this board with: crewbus agents (plus crewbus group list once groups exist).`);
|
|
220
|
+
}
|
|
221
|
+
out.push(`ask the group that owns a thing before asking ${lead}: crewbus send --from ${me} --to-group <group> --subject "..." --body "..." (reaches every member; ${lead} is for approvals and scope disputes only).`);
|
|
222
|
+
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.`);
|
|
223
|
+
out.push(`boot habit: crewbus agents + crewbus group list once, so you know who else is here.`);
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
183
227
|
export function buildSpawnTarget({ harness, cmd, model, auto, maxTurns, allowTools, cwd, name, promptPath, prompt, sessionId }) { // line 4114
|
|
184
228
|
const quoteFree = (s) => String(s).replace(/"/g, "'");
|
|
185
229
|
if (harness === "opencode") {
|
|
@@ -488,6 +532,7 @@ export function syncWorkerSession(d, name, logPath) { // respawn slice 1
|
|
|
488
532
|
source = "preassigned-confirmed";
|
|
489
533
|
}
|
|
490
534
|
const doc = {
|
|
535
|
+
...((prev && typeof prev === "object") ? prev : {}),
|
|
491
536
|
name,
|
|
492
537
|
harness: (prev && prev.harness) || undefined,
|
|
493
538
|
spawnedPid: (prev && prev.spawnedPid) || undefined,
|
|
@@ -501,6 +546,12 @@ export function syncWorkerSession(d, name, logPath) { // respawn slice 1
|
|
|
501
546
|
checkedMtime: tail.mtime,
|
|
502
547
|
checkedAt: now,
|
|
503
548
|
};
|
|
549
|
+
// Drop undefined so a re-scan never resurrects cleared fields and the
|
|
550
|
+
// doc stays a merge of the boot record (promptPath, model, cwd, cmd,
|
|
551
|
+
// respawnCount, …) plus the latest capture — never an enumeration.
|
|
552
|
+
for (const k of Object.keys(doc)) {
|
|
553
|
+
if (doc[k] === undefined) delete doc[k];
|
|
554
|
+
}
|
|
504
555
|
try {
|
|
505
556
|
fs.mkdirSync(path.join(d.root, "worker-sessions"), { recursive: true });
|
|
506
557
|
writeJson(workerSessionPath(d, name), doc);
|
|
@@ -641,13 +692,19 @@ export function installCliToUserPath() {
|
|
|
641
692
|
return { ok: true, binDir: userBinDir, addedToPath, detail };
|
|
642
693
|
}
|
|
643
694
|
|
|
644
|
-
export function bootWorker(d, spawnOpts, { to, id, from, subject, body, rev, logDir, budgetTokens, budgetMinutes, deadlineAt, spawnedWorktree, spawnedBranch, spawnedLifetime, sessionId }) { // line 4338
|
|
695
|
+
export function bootWorker(d, spawnOpts, { to, id, from, subject, body, rev, logDir, budgetTokens, budgetMinutes, deadlineAt, spawnedWorktree, spawnedBranch, spawnedLifetime, sessionId, crew }) { // line 4338
|
|
645
696
|
const { cwd, root } = spawnOpts;
|
|
646
697
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
647
|
-
const prompt = buildSpawnPrompt({ name: to, from, subject, body, replyId: id, rev, cwd, root });
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
698
|
+
const prompt = buildSpawnPrompt({ name: to, from, subject, body, replyId: id, rev, cwd, root, crew });
|
|
699
|
+
const promptPath = path.join(logDir, `${to}-${stamp}.prompt.md`);
|
|
700
|
+
fs.writeFileSync(promptPath, prompt + "\n");
|
|
701
|
+
const logPath = path.join(logDir, `${to}-${stamp}.log`);
|
|
702
|
+
// Boot metadata first: child stdout is often block-buffered (or never
|
|
703
|
+
// flushed at all on some harnesses), so the log must carry proof of
|
|
704
|
+
// life from minute one even if the worker prints nothing for hours.
|
|
705
|
+
try {
|
|
706
|
+
fs.writeFileSync(logPath, `# crewbus boot ${to} at ${new Date().toISOString()} brief ${id} harness ${spawnOpts.harness || "opencode"} prompt ${promptPath}\n`);
|
|
707
|
+
} catch {}
|
|
651
708
|
const scrub = scrubChildEnv(process.env, spawnOpts);
|
|
652
709
|
const childEnv = { ...scrub.env, CREWBUS_DIR: d.root, CREWBUS_AGENT: to };
|
|
653
710
|
const shimDir = ensureBoardCliShim(d);
|
|
@@ -802,15 +859,22 @@ export function workerStatus(d, name, lines) { // line 4381
|
|
|
802
859
|
} catch {}
|
|
803
860
|
}
|
|
804
861
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
.
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
862
|
+
let logPath = null;
|
|
863
|
+
let logSize = null;
|
|
864
|
+
let logMtimeMs = null;
|
|
865
|
+
let tail = [];
|
|
866
|
+
try {
|
|
867
|
+
const files = fs.readdirSync(path.join(d.root, "logs"))
|
|
868
|
+
.filter((f) => f.startsWith(`${name}-`) && f.endsWith(".log"))
|
|
869
|
+
.sort();
|
|
870
|
+
if (files.length > 0) {
|
|
871
|
+
logPath = path.join(d.root, "logs", files[files.length - 1]);
|
|
872
|
+
try {
|
|
873
|
+
const st = fs.statSync(logPath);
|
|
874
|
+
logSize = st.size;
|
|
875
|
+
logMtimeMs = st.mtimeMs;
|
|
876
|
+
} catch {}
|
|
877
|
+
const content = fs.readFileSync(logPath, "utf8").split(/\r?\n/);
|
|
814
878
|
if (content.length > 0 && content[content.length - 1] === "") content.pop();
|
|
815
879
|
tail = content.slice(-Math.max(lines, 0));
|
|
816
880
|
}
|
|
@@ -832,13 +896,15 @@ export function workerStatus(d, name, lines) { // line 4381
|
|
|
832
896
|
budget = { tokensEst, budgetTokens: doc.budgetTokens ?? null, budgetMinutes: doc.budgetMinutes ?? null, elapsedMin, deadlineAt: doc.deadlineAt || null, overTokens, overMinutes, pastDeadline, exceeded: !!(overTokens || overMinutes || pastDeadline) };
|
|
833
897
|
}
|
|
834
898
|
const driver = cleanHarnessDriver(doc.spawnedHarness) || cleanHarnessDriver(ws && ws.harness) || undefined;
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
899
|
+
return {
|
|
900
|
+
name, known: true, pid: pid || null, alive, aliveVerified, pidStale, spawnedBy: doc.spawnedBy || null,
|
|
901
|
+
driver,
|
|
902
|
+
spawnedHarness: driver || undefined,
|
|
903
|
+
harnessSessionId: (ws && ws.harnessSessionId) || null,
|
|
904
|
+
respawnCount: (ws && ws.respawnCount) || 0,
|
|
905
|
+
briefId: doc.briefId || null, spawnedAt: doc.spawnedAt || null,
|
|
906
|
+
logSize, logMtimeMs,
|
|
907
|
+
logIdleMs: logMtimeMs !== null ? Math.max(0, Date.now() - logMtimeMs) : null,
|
|
842
908
|
lifetime: doc.spawnedLifetime || "oneshot",
|
|
843
909
|
worktree: doc.spawnedWorktree || null, branch: doc.spawnedBranch || null,
|
|
844
910
|
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
|
package/bin/lib/web.js
CHANGED
|
@@ -18,7 +18,7 @@ import http from "node:http";
|
|
|
18
18
|
import { boardDir, requireBoard, getFlag, fail, writeJson, listJson, cleanWebName, readJson, gitRevForBoard, newId, MAX_SPAWN, MAX_BODY_CHARS } from "./store.js";
|
|
19
19
|
import { readAgent, agentTokenMatches, authorizeCheck, mintToken, newSalt, hashToken, touchAgent } from "./identity.js";
|
|
20
20
|
import { readDMs, readVisible, ackedIds, findMessageById, deliverDMs, parseRecipients } from "./mail.js";
|
|
21
|
-
import { workerStatus, pidAlive, killWorkers, bootWorker, buildSpawnPrompt, buildSpawnTarget, formatSpawnCmd } from "./spawn.js";
|
|
21
|
+
import { workerStatus, pidAlive, killWorkers, bootWorker, buildSpawnPrompt, buildSpawnTarget, formatSpawnCmd, installCliToUserPath, ensureBoardCliShim } from "./spawn.js";
|
|
22
22
|
import { groupTelemetryData } from "./groups.js";
|
|
23
23
|
import { httpJson } from "./sync.js";
|
|
24
24
|
import { readChainRecords, verifyChainRecords, appendChainRecord, readHold, readBoardQuotas } from "./export.js";
|
|
@@ -32,6 +32,17 @@ export function escapeHtml(s) {
|
|
|
32
32
|
|
|
33
33
|
export function boardSnapshot(d, activeWindowSec) {
|
|
34
34
|
const cutoff = Date.now() - activeWindowSec * 1000;
|
|
35
|
+
// Liveness first: pid-bearing agents are active only while their process
|
|
36
|
+
// is verifiably alive (kill-0 + start-time guard inside workerStatus) —
|
|
37
|
+
// a fresh lastSeen alone proves nothing, because watcher heartbeats keep
|
|
38
|
+
// refreshing docs for long-dead sessions. Pid-less agents (humans, CLI
|
|
39
|
+
// peers) fall back to lastSeen freshness. Reuses the workers array below
|
|
40
|
+
// (no extra status syscalls).
|
|
41
|
+
const workers = listJson(d.agents)
|
|
42
|
+
.map((e) => e.data)
|
|
43
|
+
.filter((a) => a && a.name && typeof a.spawnedPid === "number")
|
|
44
|
+
.map((a) => workerStatus(d, a.name, 5));
|
|
45
|
+
const liveByName = new Map(workers.map((w) => [w.name, w.alive === true]));
|
|
35
46
|
const agents = listJson(d.agents)
|
|
36
47
|
.map((e) => e.data)
|
|
37
48
|
.filter((x) => x && x.name)
|
|
@@ -42,12 +53,13 @@ export function boardSnapshot(d, activeWindowSec) {
|
|
|
42
53
|
const { token, tokenHash, salt, ...safe } = a; // tokens never leave the server
|
|
43
54
|
return {
|
|
44
55
|
...safe,
|
|
45
|
-
active:
|
|
56
|
+
active: typeof a.spawnedPid === "number"
|
|
57
|
+
? liveByName.get(a.name) === true
|
|
58
|
+
: Date.parse(a.lastSeen) >= cutoff,
|
|
46
59
|
dmCount: visible.length,
|
|
47
60
|
unacked: visible.filter((m) => !acked.has(m.id)).length,
|
|
48
61
|
};
|
|
49
62
|
});
|
|
50
|
-
const workers = agents.filter((a) => typeof a.spawnedPid === "number").map((a) => workerStatus(d, a.name, 5));
|
|
51
63
|
const broadcasts = listJson(d.broadcast || path.join(d.root, "broadcast"))
|
|
52
64
|
.map((e) => e.data)
|
|
53
65
|
.filter((b) => b && b.id && b.from)
|
|
@@ -589,6 +601,7 @@ details.tech-details summary{cursor:pointer;font-weight:500;user-select:none}
|
|
|
589
601
|
<div class="pop-path" id="pop-board-path">${e(boardPath)}</div>
|
|
590
602
|
<button type="button" id="copy-board-btn" class="pop-btn"><svg class="btn-ic" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy Board Path</button>
|
|
591
603
|
<button type="button" class="pop-btn" id="pop-pair-btn" style="margin-top:8px"><svg class="btn-ic" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg> Pair Mobile Client (QR)…</button>
|
|
604
|
+
<button type="button" class="pop-btn" id="pop-install-cli-btn" style="margin-top:8px"><svg class="btn-ic" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> Install 'crewbus' CLI in PATH</button>
|
|
592
605
|
</div>
|
|
593
606
|
</div>
|
|
594
607
|
|
|
@@ -646,8 +659,9 @@ details.tech-details summary{cursor:pointer;font-weight:500;user-select:none}
|
|
|
646
659
|
<span>Search commands, workers, sections…</span>
|
|
647
660
|
<kbd>Ctrl+K</kbd>
|
|
648
661
|
</button>
|
|
649
|
-
|
|
650
|
-
|
|
662
|
+
<div class="topbar-right">
|
|
663
|
+
<span id="poll-age" class="dim" title="age of the data on screen (Chrome throttles background tabs — a stale view is otherwise indistinguishable from a live one)"></span>
|
|
664
|
+
<label style="display:none">harness <select id="topbar-harness" aria-label="harness quick-pick"></select></label>
|
|
651
665
|
<button type="button" id="inspector-toggle-btn" title="Toggle Inspector sidebar">Inspector ◨</button>
|
|
652
666
|
</div>
|
|
653
667
|
</header>
|
|
@@ -920,8 +934,8 @@ async function kill(names){
|
|
|
920
934
|
}
|
|
921
935
|
document.getElementById('killall').onclick=async function(){
|
|
922
936
|
var rows=window.__workers||[];
|
|
923
|
-
|
|
924
|
-
|
|
937
|
+
var names=rows.filter(function(w){return w.known&&w.alive===true;}).map(function(w){return w.name;});
|
|
938
|
+
if(!names.length){say('no live workers (kill needs a running pid)');return;}
|
|
925
939
|
kill(names);
|
|
926
940
|
};
|
|
927
941
|
document.getElementById('ackall').onclick=async function(){
|
|
@@ -940,6 +954,8 @@ async function refresh(){
|
|
|
940
954
|
try{
|
|
941
955
|
var r=await fetch('/api/board',{cache:'no-store'});
|
|
942
956
|
var s=await r.json();
|
|
957
|
+
window.__lastBoardPoll=Date.now();
|
|
958
|
+
updatePollAge();
|
|
943
959
|
window.__workers=s.workers;
|
|
944
960
|
var active=s.agents.filter(function(a){return a.active;}).length;
|
|
945
961
|
var unacked=s.agents.reduce(function(n,a){return n+a.unacked;},0);
|
|
@@ -960,12 +976,12 @@ async function refresh(){
|
|
|
960
976
|
document.getElementById('workers').innerHTML=s.workers.map(function(w){
|
|
961
977
|
var tail=(w.tail||[]).slice(-3).map(function(l){return '<div class="log">'+esc(l)+'</div>';}).join('')||'<span class="dim">no log</span>';
|
|
962
978
|
var rep=w.reply?esc(w.reply.id)+'<div class="dim">'+esc(w.reply.head)+'</div>':'—';
|
|
963
|
-
var btn=(w.known&&
|
|
979
|
+
var btn=(w.known&&w.alive===true)?'<button class="danger" data-kill="'+esc(w.name)+'">kill</button>':'';
|
|
964
980
|
return '<tr data-worker="'+esc(w.name)+'" class="worker-row"><td><b>'+esc(w.name)+'</b><div class="dim">by '+esc(w.spawnedBy||'?')+'</div></td><td>'+esc(stateOf(w))+'</td><td>'+(w.pid===null||w.pid===undefined?'—':esc(String(w.pid)))+'</td><td>'+rep+'</td><td>'+tail+'</td><td>'+btn+'</td></tr>';
|
|
965
981
|
}).join('')||'<tr><td colspan="6" class="dim">no spawned workers</td></tr>';
|
|
966
982
|
Array.prototype.forEach.call(document.querySelectorAll('[data-kill]'),function(b){b.onclick=function(){kill([b.getAttribute('data-kill')]);};});
|
|
967
983
|
document.getElementById('agents').innerHTML=s.agents.map(function(a){
|
|
968
|
-
return '<tr><td><b>'+esc(a.name)+'</b></td><td>'+(a.active?'● active':'○ stale')+'</td><td
|
|
984
|
+
return '<tr><td><b>'+esc(a.name)+'</b></td><td>'+(a.active?'● active':'○ stale')+'</td><td title="'+esc(a.lastSeen||'?')+'">'+esc(ago(a.lastSeen))+'</td><td>'+esc(a.sessionId||'—')+'</td><td>'+a.dmCount+'</td><td>'+a.unacked+'</td></tr>';
|
|
969
985
|
}).join('')||'<tr><td colspan="6" class="dim">no agents yet</td></tr>';
|
|
970
986
|
document.getElementById('bcast').innerHTML=s.broadcasts.map(function(b){
|
|
971
987
|
var to=Array.isArray(b.to)?b.to.join(','):String(b.to||'');
|
|
@@ -1337,6 +1353,7 @@ function getActionIcon(act){
|
|
|
1337
1353
|
case 'openfolder':return '<svg class="pal-ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
|
1338
1354
|
case 'pairmobile':return '<svg class="pal-ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>';
|
|
1339
1355
|
case 'refresh':return '<svg class="pal-ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>';
|
|
1356
|
+
case 'installcli':return '<svg class="pal-ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
|
|
1340
1357
|
default:return '';
|
|
1341
1358
|
}
|
|
1342
1359
|
}
|
|
@@ -1348,6 +1365,7 @@ function paletteSections(){
|
|
|
1348
1365
|
{kind:'action',label:'Copy board path',action:'copyboard'},
|
|
1349
1366
|
{kind:'action',label:'Open project folder…',action:'openfolder'},
|
|
1350
1367
|
{kind:'action',label:'Pair mobile client (QR)…',action:'pairmobile'},
|
|
1368
|
+
{kind:'action',label:'Install \'crewbus\' CLI in PATH',action:'installcli'},
|
|
1351
1369
|
{kind:'action',label:'Refresh board state',action:'refresh'},
|
|
1352
1370
|
{kind:'section',label:'Launch',id:'sec-launch'},
|
|
1353
1371
|
{kind:'section',label:'Boards',id:'sec-boards'},
|
|
@@ -1417,6 +1435,7 @@ function paletteActivate(m){
|
|
|
1417
1435
|
try{window.open('./pair.html','_blank','width=420,height=560');}catch(_){}
|
|
1418
1436
|
}
|
|
1419
1437
|
else if(m.action==='refresh'){refresh();}
|
|
1438
|
+
else if(m.action==='installcli'){document.getElementById('pop-install-cli-btn').click();}
|
|
1420
1439
|
}else if(m.kind==='section'){
|
|
1421
1440
|
approotNavTo(m.id);
|
|
1422
1441
|
}else{
|
|
@@ -1536,7 +1555,7 @@ function showInspector(name){
|
|
|
1536
1555
|
+'<div class="dim">reply: '+rep+' · acked: '+esc(w.acked?'yes':'no')+' · session: '+esc(w.harnessSessionId||'—')+'</div>'
|
|
1537
1556
|
+'<div class="log" style="margin-top:6px">'+tail+'</div>'
|
|
1538
1557
|
+'<div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap">'
|
|
1539
|
-
|
|
1558
|
+
+((w.known&&w.alive===true)?'<button class="danger" id="insp-kill">kill</button>':'')
|
|
1540
1559
|
+(w.reply?'<button id="insp-ack">ack reply</button>':'')
|
|
1541
1560
|
+'<button id="insp-respawn">respawn (CLI-only)</button></div>'
|
|
1542
1561
|
+'<div class="dim" id="insp-respawn-cmd" style="margin-top:4px"></div>';
|
|
@@ -1647,6 +1666,27 @@ if(ppb){ppb.onclick=function(){
|
|
|
1647
1666
|
try{window.parent.postMessage({type:'crewbus:open-pair'},'*');}catch(_){}
|
|
1648
1667
|
try{window.open('./pair.html','_blank','width=420,height=560');}catch(_){}
|
|
1649
1668
|
};}
|
|
1669
|
+
var picb=document.getElementById('pop-install-cli-btn');
|
|
1670
|
+
if(picb){picb.onclick=async function(){
|
|
1671
|
+
picb.disabled=true;
|
|
1672
|
+
picb.textContent='Installing CLI…';
|
|
1673
|
+
try{
|
|
1674
|
+
var r=await fetch('/api/install-cli',{method:'POST'});
|
|
1675
|
+
var j=await r.json();
|
|
1676
|
+
if(j&&j.ok){
|
|
1677
|
+
say('CLI installed! '+(j.detail||''));
|
|
1678
|
+
picb.textContent='CLI Installed (in PATH)';
|
|
1679
|
+
}else{
|
|
1680
|
+
say('Failed to install CLI: '+(j&&j.error||'unknown error'));
|
|
1681
|
+
picb.textContent='Install Failed — Retry';
|
|
1682
|
+
picb.disabled=false;
|
|
1683
|
+
}
|
|
1684
|
+
}catch(e){
|
|
1685
|
+
say('Install error: '+e.message);
|
|
1686
|
+
picb.textContent='Install Failed — Retry';
|
|
1687
|
+
picb.disabled=false;
|
|
1688
|
+
}
|
|
1689
|
+
};}
|
|
1650
1690
|
document.getElementById('project-trigger').onclick=function(e){
|
|
1651
1691
|
e.stopPropagation();
|
|
1652
1692
|
var pop=document.getElementById('project-popover');
|
|
@@ -1726,9 +1766,15 @@ document.getElementById('launch-brief').addEventListener('keydown',function(e){
|
|
|
1726
1766
|
document.getElementById('launch-go').click();
|
|
1727
1767
|
}
|
|
1728
1768
|
});
|
|
1729
|
-
loadLaunchMeta();
|
|
1730
|
-
refresh();
|
|
1731
|
-
setInterval(refresh,5000);
|
|
1769
|
+
loadLaunchMeta();
|
|
1770
|
+
refresh();
|
|
1771
|
+
setInterval(refresh,5000);
|
|
1772
|
+
// Data-age indicator: background tabs get their 5s poll throttled, so a
|
|
1773
|
+
// stale view must say so instead of passing for live (see poll-age).
|
|
1774
|
+
function agoMs(ms){var s=Math.max(0,Math.floor(ms/1000));if(s<60)return s+'s';var m=Math.floor(s/60);if(m<60)return m+'m';return Math.floor(m/60)+'h';}
|
|
1775
|
+
function ago(iso){try{var t=Date.parse(iso);if(isNaN(t))return '?';return agoMs(Date.now()-t)+' ago';}catch(e){return '?';}}
|
|
1776
|
+
function updatePollAge(){var el=document.getElementById('poll-age');if(!el)return;if(!window.__lastBoardPoll){el.textContent='not updated yet';return;}var age=Date.now()-window.__lastBoardPoll;var stale=age>15000;el.textContent=(stale?'STALE · ':'')+'updated '+agoMs(age)+' ago';el.style.color=stale?'var(--danger)':'';}
|
|
1777
|
+
setInterval(updatePollAge,1000);
|
|
1732
1778
|
</script>
|
|
1733
1779
|
</body></html>`;
|
|
1734
1780
|
}
|
|
@@ -1889,12 +1935,31 @@ export async function handleWebDashboardRoute(req, res, url, d) {
|
|
|
1889
1935
|
res.end(JSON.stringify({ ok: false, error: "no lead.token found" }));
|
|
1890
1936
|
return true;
|
|
1891
1937
|
}
|
|
1938
|
+
if (req.method === "POST" && url.pathname === "/api/install-cli") {
|
|
1939
|
+
const ip = req.socket.remoteAddress || "";
|
|
1940
|
+
const isLoop = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
|
1941
|
+
if (!isLoop) {
|
|
1942
|
+
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
1943
|
+
res.end(JSON.stringify({ error: "loopback only" }));
|
|
1944
|
+
return true;
|
|
1945
|
+
}
|
|
1946
|
+
try {
|
|
1947
|
+
const resInstall = installCliToUserPath();
|
|
1948
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
1949
|
+
res.end(JSON.stringify({ ok: true, ...resInstall }));
|
|
1950
|
+
} catch (err) {
|
|
1951
|
+
res.writeHead(500, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
1952
|
+
res.end(JSON.stringify({ ok: false, error: String(err && err.message ? err.message : err) }));
|
|
1953
|
+
}
|
|
1954
|
+
return true;
|
|
1955
|
+
}
|
|
1892
1956
|
return false;
|
|
1893
1957
|
}
|
|
1894
1958
|
|
|
1895
1959
|
export async function cmdWeb(args) {
|
|
1896
1960
|
const root = boardDir(args);
|
|
1897
1961
|
const d = requireBoard(root);
|
|
1962
|
+
ensureBoardCliShim(d);
|
|
1898
1963
|
const host = getFlag(args, "--host") || "127.0.0.1";
|
|
1899
1964
|
const port = Number(getFlag(args, "--port") || 0);
|
|
1900
1965
|
if (!(port >= 0 && port < 65536)) fail("--port must be 0-65535 (0 = random)");
|
package/docs/GROUPS.md
CHANGED
|
@@ -1,77 +1,102 @@
|
|
|
1
|
-
# Groups, outcomes, races (Review §4.3)
|
|
2
|
-
|
|
3
|
-
Groups are named recipient sets for variant briefs. Outcomes are native
|
|
4
|
-
records — the interim workaround (one group per approach + `RESULT:`
|
|
5
|
-
subjects + a verifier agent + a `gather --json` script + `spawn-kill`)
|
|
6
|
-
is replaced by the commands below.
|
|
7
|
-
|
|
8
|
-
## Curate + brief + reduce
|
|
9
|
-
|
|
10
|
-
```sh
|
|
11
|
-
crewbus group create team-a --add a1,a2,a3
|
|
12
|
-
crewbus send --from lead --to-group team-a --subject "variant: regularity" --body "..."
|
|
13
|
-
crewbus gather --batch <batch> # briefs + replies + telemetry footer + contributing groups
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
`gather --batch` prints a telemetry footer
|
|
17
|
-
(`msgs, ~tokens (chars/4), contributing groups`) and the same fields in
|
|
18
|
-
`--json` (`contributingGroups`, `telemetry`).
|
|
19
|
-
|
|
20
|
-
## Replies carry artifacts
|
|
21
|
-
|
|
22
|
-
```sh
|
|
23
|
-
crewbus send --from e1 --to lead --reply <brief-id> --artifact out/a1.json --body "done"
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
`--artifact <path-or-url>` (max 500 chars) is stored on the message and
|
|
27
|
-
shown by `inbox` / `gather` / `thread`.
|
|
28
|
-
|
|
29
|
-
## Verify, then record the result
|
|
30
|
-
|
|
31
|
-
```sh
|
|
32
|
-
crewbus ack --from lead --id <reply-id> --verify "node test/check.mjs --input out/a1.json"
|
|
33
|
-
crewbus result record --group team-a --msg <reply-id> --artifact out/a1.json --from lead
|
|
34
|
-
crewbus result show --group team-a
|
|
35
|
-
crewbus result list [--group team-a] [--json]
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
`results/<group>.json` holds ONE record
|
|
39
|
-
`{group, artifact, by, msgId, verifierOutput, at}` — first verified
|
|
40
|
-
result wins. Only verified messages are recordable: the ack marker
|
|
41
|
-
`acked/*/<msgId>.json` must carry `verified:true`, else recording fails
|
|
42
|
-
unless `--force` (which prints a warning to stderr).
|
|
43
|
-
|
|
44
|
-
## Race mode
|
|
45
|
-
|
|
46
|
-
```sh
|
|
47
|
-
crewbus race start --group team-a --batch <batch> [--timeout 60000] [--json]
|
|
48
|
-
crewbus race close --group team-a --from lead [--kill]
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
`race start` reports the winner (recorded result whose `msgId` is in the
|
|
52
|
-
batch, else the first verified ack on a batch reply) or "no verified
|
|
53
|
-
result yet". `--timeout` polls instead of checking once; there is no
|
|
54
|
-
daemon. `race close` DMs "race closed by X" to every other member and,
|
|
55
|
-
with `--kill`, terminates their spawned pids.
|
|
56
|
-
|
|
57
|
-
## Status + telemetry
|
|
58
|
-
|
|
59
|
-
```sh
|
|
60
|
-
crewbus group status team-a [--json] # running (pid alive), replies, verified, spend
|
|
61
|
-
crewbus group telemetry team-a [--json] # {messages, tokensEst, wallClockMs, members, verifiedCount}
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Spend = message count + wall-clock since the group `createdAt` +
|
|
65
|
-
token estimate (`chars/4` over member bodies+subjects). `createdAt` is
|
|
66
|
-
backfilled from the group file mtime for pre-existing groups.
|
|
67
|
-
|
|
68
|
-
## Attribution (agents in several groups)
|
|
69
|
-
|
|
70
|
-
- A reply attributes via its chain: `--reply <msg-id>` → root brief →
|
|
71
|
-
batch → group(s) whose members appear in the transcript.
|
|
72
|
-
- `gather --batch` shows `contributing groups` (groups with ≥1 message
|
|
73
|
-
from a member in the transcript).
|
|
74
|
-
- `result record` **requires `--group` explicitly** — there is no
|
|
75
|
-
auto-assign. When an agent is in several groups, the recorder decides
|
|
76
|
-
which group the result counts for.
|
|
77
|
-
|
|
1
|
+
# Groups, outcomes, races (Review §4.3)
|
|
2
|
+
|
|
3
|
+
Groups are named recipient sets for variant briefs. Outcomes are native
|
|
4
|
+
records — the interim workaround (one group per approach + `RESULT:`
|
|
5
|
+
subjects + a verifier agent + a `gather --json` script + `spawn-kill`)
|
|
6
|
+
is replaced by the commands below.
|
|
7
|
+
|
|
8
|
+
## Curate + brief + reduce
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
crewbus group create team-a --add a1,a2,a3
|
|
12
|
+
crewbus send --from lead --to-group team-a --subject "variant: regularity" --body "..."
|
|
13
|
+
crewbus gather --batch <batch> # briefs + replies + telemetry footer + contributing groups
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`gather --batch` prints a telemetry footer
|
|
17
|
+
(`msgs, ~tokens (chars/4), contributing groups`) and the same fields in
|
|
18
|
+
`--json` (`contributingGroups`, `telemetry`).
|
|
19
|
+
|
|
20
|
+
## Replies carry artifacts
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
crewbus send --from e1 --to lead --reply <brief-id> --artifact out/a1.json --body "done"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`--artifact <path-or-url>` (max 500 chars) is stored on the message and
|
|
27
|
+
shown by `inbox` / `gather` / `thread`.
|
|
28
|
+
|
|
29
|
+
## Verify, then record the result
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
crewbus ack --from lead --id <reply-id> --verify "node test/check.mjs --input out/a1.json"
|
|
33
|
+
crewbus result record --group team-a --msg <reply-id> --artifact out/a1.json --from lead
|
|
34
|
+
crewbus result show --group team-a
|
|
35
|
+
crewbus result list [--group team-a] [--json]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`results/<group>.json` holds ONE record
|
|
39
|
+
`{group, artifact, by, msgId, verifierOutput, at}` — first verified
|
|
40
|
+
result wins. Only verified messages are recordable: the ack marker
|
|
41
|
+
`acked/*/<msgId>.json` must carry `verified:true`, else recording fails
|
|
42
|
+
unless `--force` (which prints a warning to stderr).
|
|
43
|
+
|
|
44
|
+
## Race mode
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
crewbus race start --group team-a --batch <batch> [--timeout 60000] [--json]
|
|
48
|
+
crewbus race close --group team-a --from lead [--kill]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`race start` reports the winner (recorded result whose `msgId` is in the
|
|
52
|
+
batch, else the first verified ack on a batch reply) or "no verified
|
|
53
|
+
result yet". `--timeout` polls instead of checking once; there is no
|
|
54
|
+
daemon. `race close` DMs "race closed by X" to every other member and,
|
|
55
|
+
with `--kill`, terminates their spawned pids.
|
|
56
|
+
|
|
57
|
+
## Status + telemetry
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
crewbus group status team-a [--json] # running (pid alive), replies, verified, spend
|
|
61
|
+
crewbus group telemetry team-a [--json] # {messages, tokensEst, wallClockMs, members, verifiedCount}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Spend = message count + wall-clock since the group `createdAt` +
|
|
65
|
+
token estimate (`chars/4` over member bodies+subjects). `createdAt` is
|
|
66
|
+
backfilled from the group file mtime for pre-existing groups.
|
|
67
|
+
|
|
68
|
+
## Attribution (agents in several groups)
|
|
69
|
+
|
|
70
|
+
- A reply attributes via its chain: `--reply <msg-id>` → root brief →
|
|
71
|
+
batch → group(s) whose members appear in the transcript.
|
|
72
|
+
- `gather --batch` shows `contributing groups` (groups with ≥1 message
|
|
73
|
+
from a member in the transcript).
|
|
74
|
+
- `result record` **requires `--group` explicitly** — there is no
|
|
75
|
+
auto-assign. When an agent is in several groups, the recorder decides
|
|
76
|
+
which group the result counts for.
|
|
77
|
+
|
|
78
|
+
## Cross-group collaboration
|
|
79
|
+
|
|
80
|
+
Groups share one board, so any member can already read anything. The
|
|
81
|
+
thing that decides whether groups actually collaborate is whether the
|
|
82
|
+
brief tells them the map. Since v10.0.x every `spawn` brief carries a
|
|
83
|
+
**Crew:** block (see `buildCrewContext` in `bin/lib/spawn.js`): your
|
|
84
|
+
group(s) + members + lane, the other groups + members, their group
|
|
85
|
+
channels, and the peer-DM norm. Conventions that block assumes:
|
|
86
|
+
|
|
87
|
+
- **Peer-direct for questions.** `crewbus send --from you --to-group
|
|
88
|
+
<group> --subject "..." --body "..."` reaches every member of
|
|
89
|
+
another group. Ask the group that owns a thing first; the lead is
|
|
90
|
+
for approvals (`--priority high`) and scope disputes only.
|
|
91
|
+
- **Channels are the shared read surface.** `crewbus channel tail
|
|
92
|
+
grp-<group>` shows a group's briefs + mirrored progress; reads need
|
|
93
|
+
no token. Channel *posting* is lead|admin-only, so workers write
|
|
94
|
+
via DMs, never via `channel post`.
|
|
95
|
+
- **Boot habit.** `crewbus agents` + `crewbus group list` once at
|
|
96
|
+
start, so a worker knows who else is here. A worker that never runs
|
|
97
|
+
a discovery command will work star-topology through the lead — that
|
|
98
|
+
is a failure of the brief, not the worker.
|
|
99
|
+
- **Scope questions stay lanes-first.** "Stay in your lane" prevents
|
|
100
|
+
collisions; "DM the group that owns a thing" prevents silos. A
|
|
101
|
+
brief should name both the boundary *and* the bridge.
|
|
102
|
+
|