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/AGENTS.template.md
CHANGED
|
@@ -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
|
|
16
|
+
The CLI is run as (after `npm i -g crewbus`, just `crewbus`; otherwise the full
|
|
17
17
|
path `node <this-checkout>/bin/crewbus.js` — `init` writes the real path
|
|
18
18
|
into the project's `AGENTS.md` automatically, so prefer that copy):
|
|
19
19
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Package is now published as unscoped `crewbus` (v10.0.2): `npm i -g crewbus`.
|
|
6
|
+
|
|
7
|
+
## 10.0.4 (2026-10-12)
|
|
8
|
+
|
|
9
|
+
PATCH RELEASE: Briefs view + gather endpoint, task-scoped spawn rosters, and mobile brief/thread/watcher screens. No board-schema change (`board.json` stays v2); all additions backward-compatible.
|
|
10
|
+
|
|
11
|
+
- **Dashboard Briefs view + `GET /api/gather?batch=`**:
|
|
12
|
+
- New Briefs section: one row per fan-out (subject, workers, checkpoint/final counts, acked fraction, relative last activity); click a row for its gathered thread (briefs, checkpoint timeline, finals with ack ticks, telemetry footer).
|
|
13
|
+
- Crews table gains a brief/groups column (batch short-id + group names, or "solo"); Recent activity gains a text filter + checkpoints/finals toggle.
|
|
14
|
+
- **Task-scoped spawn rosters**:
|
|
15
|
+
- `buildCrewContext` takes the task's groups only; groups from unrelated concurrent briefs collapse to a count (`N other groups exist on this board`) instead of leaking names cross-task. Documented in `docs/GROUPS.md`.
|
|
16
|
+
- **Mobile (M5)**:
|
|
17
|
+
- Boards card counts live workers (`alive === true`) instead of all pid-bearing workers.
|
|
18
|
+
- Triage groups inbox by brief thread with checkpoint/final/note chips.
|
|
19
|
+
- New Briefs screen (one row per batch, tap for thread via `/api/gather`).
|
|
20
|
+
- Foreground watcher (`src/lib/watch.js`): polls inbox approvals + board dead-man state, alerts once per new id with in-app banner + haptic. Foreground-only by scaffold rule (no notification plugins); killed-app push remains future work (needs the plugin rule lifted + EAS credentials + a relay sender).
|
|
21
|
+
- `/api/inbox` items now carry `checkpoint` (additive).
|
|
22
|
+
|
|
23
|
+
## 10.0.3 (2026-10-10)
|
|
24
|
+
|
|
25
|
+
PATCH RELEASE: Worker-record integrity, truthful presence, and multi-group collaboration in spawn briefs. No board-schema change (`board.json` stays v2); all prompt additions are backward-compatible (absent unless the spawn carries group/compute context).
|
|
26
|
+
|
|
27
|
+
- **Spawn briefs carry a Crew: roster block**:
|
|
28
|
+
- Every `spawn` brief now names the worker's group(s) + members + lane, the other groups + members, and their group channels (`grp-<group>`).
|
|
29
|
+
- Ships the peer-DM norm with an explicit trigger (ask the group that owns a thing before asking the lead; lead stays for approvals/scope only), the read-channels/write-DMs distinction (channel-post is lead|admin-only), and a `crewbus agents` + `crewbus group list` boot habit.
|
|
30
|
+
- Documented as cross-group collaboration norms in `docs/GROUPS.md`.
|
|
31
|
+
- **Agent-doc merge canonicalization**:
|
|
32
|
+
- New `mergeAgentDoc(prev, patch)` in `bin/lib/identity.js`; `touchAgent`, CLI `heartbeat`, and `register`'s mint path route through it.
|
|
33
|
+
- The `dm-send` tool and `dm-watch` plugin upserts merge over prev instead of enumerating — fixes spawned workers losing `spawnedPid`/`spawnedBy`/`briefId`/`role` (and with it: vanishing from `spawn-status`, unkillable workers, and silent role promotion) the moment they report.
|
|
34
|
+
- `syncWorkerSession` preserves binding fields (`promptPath`, model, cwd, cmd, respawn counts), which `respawn` needs.
|
|
35
|
+
- **Truthful presence + session hygiene**:
|
|
36
|
+
- `boardSnapshot` gates pid-bearing agents on verified process liveness; pid-less agents keep lastSeen freshness.
|
|
37
|
+
- The watcher TTL-expires session mappings (30m without a confirmed delivery or re-registration) instead of heartbeating dead sessions forever.
|
|
38
|
+
- **Observability**:
|
|
39
|
+
- Boot-metadata first line in every worker log; `workerStatus` surfaces `logSize`/`logMtimeMs`/`logIdleMs`.
|
|
40
|
+
- `doctor` gains a read-only dead-man scan (exited without reply, past deadline, alive-but-quiet) — informational, never FAIL.
|
|
41
|
+
- Dashboard shows data age + STALE banner, relative timestamps, alive-gated kill buttons; `spawn-status --all` lists pid-wiped workers as "no pid recorded" instead of dropping them.
|
|
42
|
+
- **Windows ergonomics**: group lists split on whitespace too (unquoted `--add a,b,c` arriving space-rejoined now parses as several members); PowerShell quoting note in `docs/TROUBLESHOOTING.md`.
|
|
43
|
+
|
|
3
44
|
## 10.0.2 (2026-10-09)
|
|
4
45
|
|
|
5
46
|
PATCH RELEASE: Target workspace CWD selector & persistence in Launch Studio, popover clarity, eliminated upload prompt, and Desktop v0.1.16 companion release.
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# crewbus v10.0.
|
|
1
|
+
# crewbus v10.0.4 — DM bus
|
|
2
2
|
|
|
3
3
|
[](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.
|
|
58
|
+
Versions: `package.json` (currently 10.0.4) is the source of truth for the
|
|
52
59
|
release version. `board.json`'s `version: 2` is the **board schema version**
|
|
53
60
|
(a different number on purpose — do not "align" them). New to the project?
|
|
54
61
|
Start with `docs/QUICKSTART.md` (5 minutes).
|
package/bin/crewbus.js
CHANGED
|
@@ -42,11 +42,11 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
42
42
|
// at top level so the import graph (store <- everyone; identity <- sync/spawn/
|
|
43
43
|
// mail/groups/channels/web/relay/export) has no evaluation-order hazards.
|
|
44
44
|
import { BOARD_VERSION, BROADCAST_AFTER, MAX_BODY_CHARS, MAX_RECIPIENTS, MAX_SPAWN, VALUE_FLAGS, boardDir, chmodAgentFile, cleanArtifact, cleanBranchPrefix, cleanChannelName, cleanGroupName, cleanPriority, cleanReply, cleanSenderType, cleanSubject, cleanWebName, dirs, ensureBoard, fail, findBoardUpward, getFlag, gitRevForBoard, listJson, newId, optionalAgent, parseDuration, readBoardMeta, readJson, refuseDriveRootBoard, requireBoard, resolveAgent, restArgs, sanitizeName, writeBoardMeta, writeExclusiveJson, writeJson, nextHlc, stampSyncDoc, hlcCompare, SEND_RATE_CAP, SEND_RATE_WINDOW_MS, MAX_FWD_DEPTH, DEDUPE_WINDOW_MS, webErr } from "./lib/store.js";
|
|
45
|
-
import { VALID_ROLES, agentTokenMatches, assertMintWon, authorize, authorizeCheck, checkToken, cleanRole, countAgentRecords, defaultRoleForNew, ensureSender, hashToken, isHashRevoked, mergeSyncedAgent, mintToken, newSalt, readAgent, readBoardAcl, resolveToken, revokedPathForHash, roleOfRecord, sanitizeAgentForSync, stripAgentSecrets, timingSafeEqualStr, touchAgent, writeAgentFile, writeBoardAcl, isBoardFrozen } from "./lib/identity.js";
|
|
45
|
+
import { VALID_ROLES, agentTokenMatches, assertMintWon, authorize, authorizeCheck, checkToken, cleanRole, countAgentRecords, defaultRoleForNew, ensureSender, hashToken, isHashRevoked, mergeAgentDoc, mergeSyncedAgent, mintToken, newSalt, readAgent, readBoardAcl, resolveToken, revokedPathForHash, roleOfRecord, sanitizeAgentForSync, stripAgentSecrets, timingSafeEqualStr, touchAgent, writeAgentFile, writeBoardAcl, isBoardFrozen } from "./lib/identity.js";
|
|
46
46
|
import { saveTokenFile } from "./lib/tokenfile.js";
|
|
47
47
|
import { CLIENT_TLS, SYNC_LWW, cleanSyncRel, clientInsecureFromArgs, crewSurvey, httpJson, readPemFlag, readSyncState, readTombstones, relayAuthEntries, relayCredFor, relayCredHeaders, setupClientTls, splitByWeight, syncRound, syncWalk, warnInsecureOnce, writeSyncState, writeTombstone, SYNC_SUBS, SYNC_UNION, RELAY_CAPS, SUB_CAP, SUB_CAP_NOTE, tombstoneIdForRel, readSyncDoc } from "./lib/sync.js";
|
|
48
48
|
import { devicePath, isLoopbackHost, newDeviceCred, newPairToken, pairingPath, parseDeviceCred, readDevice, readRelayState, relaySecretFromArgs, remoteSpawn, requireRelayClientCert, requireRelaySecret, requireScope, tryAcquireFence, verifyOidcJwt, writeRelayState, relayStatePath, bearerFromReq, getOidcConfig, getOidcJwks, b64urlDecode, b64urlJson, jwsRawToDer, oidcGetJson, OIDC_SKEW_SEC, OIDC_JWKS_TTL_MS, OIDC_ALG_HASH, OIDC_EC_SIZE, deviceFromReq } from "./lib/relay.js";
|
|
49
|
-
import { assertGitCheckout, bootRespawnedWorker, bootWorker, buildRespawnBrief, buildRespawnTarget, buildSpawnPrompt, buildSpawnTarget, formatSpawnCmd, isPidStale, killWorkers, maybeIsolate, parseAllowEnv, pidAlive, provisionBranch, provisionWorktree, readWorkerSession, requireAutoConfirm, sandboxPresent, scrubChildEnv, syncWorkerSession, workerStatus, worktreeStamp, defaultMaxTurnsFor } from "./lib/spawn.js";
|
|
49
|
+
import { assertGitCheckout, bootRespawnedWorker, bootWorker, buildCrewContext, buildRespawnBrief, buildRespawnTarget, buildSpawnPrompt, buildSpawnTarget, formatSpawnCmd, isPidStale, killWorkers, maybeIsolate, parseAllowEnv, pidAlive, provisionBranch, provisionWorktree, readWorkerSession, requireAutoConfirm, sandboxPresent, scrubChildEnv, syncWorkerSession, workerStatus, worktreeStamp, defaultMaxTurnsFor, ensureBoardCliShim, installCliToUserPath } from "./lib/spawn.js";
|
|
50
50
|
import { appendChainRecord, auditHmacKey, boardTotalBytes, chainFilePath, countChannels, dirSize, doExportToFile, enforceAgentQuota, enforceBytesQuota, enforceChannelQuota, holdActive, holdDocPath, holdRefusal, parseQuotaBytes, parseQuotaCount, readBackupInner, readBoardQuotas, readChainRecords, readHold, readSnapshotSchedule, resolveBackupKeyMaterial, setAuditForward, snapshotStamp, startAuditForwarder, verifyChainRecords, collectBoardFiles, encryptBackupPayload, decryptBackupPayload, rawKeyFromMaterial, deriveBackupKey, toAuditExport, spoolAuditEvent, postAuditEvent, auditSpoolDir, drainAuditSpool, enqueueAuditForward, signAuditRecord, chainRecordHash, AUDIT_FORWARD_URL, AUDIT_FORWARD_KEY } from "./lib/export.js";
|
|
51
51
|
import { ackedIds, checkSendRateLimit, deliverDMs, enforceMaxChars, filterDigest, findDuplicateSend, findMessageById, heartbeat, isVerified, loadManifest, manifestPath, msgTimeMs, parseRecipients, printDigest, printMsg, readDMs, readVisible, recordBroadcastManifest, requireFanoutConfirm, resolveFwdDepth, runVerifier, verifyMessageSig, isHigh, signMessage, untrustedEnvelope, relTime, rateFilePath, broadcastTargets, readBroadcastsFor, formatTo, msgHeader, readAckMarker, splitCommand, readRecipientsFile } from "./lib/mail.js";
|
|
52
52
|
import { batchReplyIds, collectBatch, contributingGroups, expandGroups, expandGroupsOrFail, gatherTelemetry, groupTelemetryData, heuristicSenderType, readGroup, ensureGroupCreatedAt, readResultRecord, writeResultRecord, findFirstVerifiedReply } from "./lib/groups.js";
|
|
@@ -591,22 +591,30 @@ export default tool({
|
|
|
591
591
|
const tokenHint = minted.created ? \` identity '\${from}' claimed, token \${minted.token} (set CREWBUS_TOKEN=\${minted.token})\` : "";
|
|
592
592
|
const rev = gitRev(root);
|
|
593
593
|
const at = new Date().toISOString();
|
|
594
|
-
// upsert sender with live session routing for the watcher plugin
|
|
594
|
+
// upsert sender with live session routing for the watcher plugin.
|
|
595
|
+
// Merge over prev, never enumerate: a hand-built record here once
|
|
596
|
+
// wiped spawn bookkeeping (spawnedPid/By, briefId) and roles off
|
|
597
|
+
// every worker that reported through this tool.
|
|
595
598
|
const ap = path.join(root, "agents", from + ".json");
|
|
596
599
|
let prev = null;
|
|
597
600
|
try {
|
|
598
601
|
prev = JSON.parse(fs.readFileSync(ap, "utf8"));
|
|
599
602
|
} catch {}
|
|
600
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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)
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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:
|
|
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
|
-
|
|
2499
|
+
// Split on commas AND whitespace: PowerShell array-splats an unquoted
|
|
2500
|
+
// --add a,b,c into separate argv items that can rejoin with spaces
|
|
2501
|
+
// (npm shims), so "cam-1 cam-2 cam-3" must parse as three names, not
|
|
2502
|
+
// one joined member.
|
|
2503
|
+
for (const part of String(addRaw).split(/[,\s]+/)) {
|
|
2476
2504
|
if (part.trim() === "") continue;
|
|
2477
2505
|
const clean = sanitizeName(part, "recipient");
|
|
2478
2506
|
if (!adds.includes(clean)) adds.push(clean);
|
|
@@ -2840,6 +2868,22 @@ function cmdSpawn(args) {
|
|
|
2840
2868
|
const toGroupRaw = getFlag(args, "--to-group");
|
|
2841
2869
|
if (alsoChannel && (!toGroupRaw || !String(toGroupRaw).trim())) fail("--also-channel needs --to-group <g,...> (it mirrors the brief into each group's channel)");
|
|
2842
2870
|
const groupNames = String(toGroupRaw || "").split(",").map((s) => String(s).trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 40)).filter(Boolean);
|
|
2871
|
+
// Crew roster for the prompt block (see buildCrewContext in lib/spawn.js):
|
|
2872
|
+
// TASK-scoped, not board-scoped. Only the groups named in THIS spawn
|
|
2873
|
+
// appear in the brief — their lanes are what a worker may need to
|
|
2874
|
+
// contact. Groups from unrelated concurrent briefs collapse to a count
|
|
2875
|
+
// (discoverable via `crewbus group list`, never advertised cross-task:
|
|
2876
|
+
// ambient cross-task rosters are prompt noise and a misleading
|
|
2877
|
+
// invitation to DM crews working on unrelated tasks).
|
|
2878
|
+
const rosterGroups = [];
|
|
2879
|
+
for (const e of listJson(d.groups)) {
|
|
2880
|
+
const g = e && e.data;
|
|
2881
|
+
if (g && g.name && Array.isArray(g.members)) rosterGroups.push({ name: g.name, members: g.members.slice() });
|
|
2882
|
+
}
|
|
2883
|
+
rosterGroups.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
2884
|
+
const taskGroups = rosterGroups.filter((g) => groupNames.includes(g.name));
|
|
2885
|
+
const offTask = rosterGroups.length - taskGroups.length;
|
|
2886
|
+
const crewFor = (to) => buildCrewContext({ name: to, from, subject, groups: taskGroups, myGroups: taskGroups.filter((g) => g.members.includes(to)).map((g) => g.name), offTask });
|
|
2843
2887
|
const model = getFlag(args, "--model");
|
|
2844
2888
|
const auto = args.includes("--auto");
|
|
2845
2889
|
requireAutoConfirm(args);
|
|
@@ -2895,12 +2939,12 @@ function cmdSpawn(args) {
|
|
|
2895
2939
|
const driver = harnesses[i % harnesses.length];
|
|
2896
2940
|
const itemMaxTurns = maxTurnsNum !== undefined ? maxTurnsNum : ((driver === "claude" || driver === "grok") ? 50 : undefined);
|
|
2897
2941
|
const itemSpawnOpts = { ...spawnOpts, harness: driver, maxTurns: itemMaxTurns, allowTools: driver === "claude" ? allowTools : undefined };
|
|
2898
|
-
const previewPrompt = buildSpawnPrompt({ name: to, from, subject, body: body.trim(), replyId: previewBatch || "msg-<id>", rev, cwd, root });
|
|
2942
|
+
const previewPrompt = buildSpawnPrompt({ name: to, from, subject, body: body.trim(), replyId: previewBatch || "msg-<id>", rev, cwd, root, crew: crewFor(to) });
|
|
2899
2943
|
const t = buildSpawnTarget({ ...itemSpawnOpts, name: to, promptPath: `<logs>/${to}-<stamp>.prompt.md`, prompt: previewPrompt });
|
|
2900
2944
|
const wtNote = worktreePrefix !== undefined ? ` worktree ../${to}-<stamp> (branch ${worktreePrefix}/${to}-<stamp>)` : branchPrefix !== undefined ? ` branch ${branchPrefix}/${to}-<stamp>` : "";
|
|
2901
2945
|
console.log(`would spawn ${to} [${driver}] (${lifetime}) cwd ${cwd}${wtNote} cmd: ${formatSpawnCmd(t)} [board ${d.root}]`);
|
|
2902
2946
|
});
|
|
2903
|
-
console.log(`--- prompt (first worker) ---\n${buildSpawnPrompt({ name: recipients[0], from, subject, body: body.trim(), replyId: previewBatch || "msg-<id>", rev, cwd, root })}`);
|
|
2947
|
+
console.log(`--- prompt (first worker) ---\n${buildSpawnPrompt({ name: recipients[0], from, subject, body: body.trim(), replyId: previewBatch || "msg-<id>", rev, cwd, root, crew: crewFor(recipients[0]) })}`);
|
|
2904
2948
|
return;
|
|
2905
2949
|
}
|
|
2906
2950
|
// Direct (N-copy) path is forced here (every worker needs its own message
|
|
@@ -2934,7 +2978,7 @@ function cmdSpawn(args) {
|
|
|
2934
2978
|
} else if (branchPrefix !== undefined) {
|
|
2935
2979
|
workerBranch = provisionBranch(cwd, branchPrefix, to).branch;
|
|
2936
2980
|
}
|
|
2937
|
-
const r = bootWorker(d, { ...itemSpawnOpts, cwd: workerCwd }, { to, id, from, subject, body: body.trim(), rev, logDir, budgetTokens, budgetMinutes, deadlineAt, spawnedWorktree: workerWorktree, spawnedBranch: workerBranch, spawnedLifetime: lifetime });
|
|
2981
|
+
const r = bootWorker(d, { ...itemSpawnOpts, cwd: workerCwd }, { to, id, from, subject, body: body.trim(), rev, logDir, budgetTokens, budgetMinutes, deadlineAt, spawnedWorktree: workerWorktree, spawnedBranch: workerBranch, spawnedLifetime: lifetime, crew: crewFor(to) });
|
|
2938
2982
|
const where = workerWorktree ? ` worktree ${workerWorktree}` : workerBranch ? ` branch ${workerBranch}` : "";
|
|
2939
2983
|
console.log(`spawned ${to} pid ${r.pid} [${driver}] (${lifetime}) log ${r.logPath} reply ${id}${where} [board ${d.root}]`);
|
|
2940
2984
|
} catch (e) {
|
|
@@ -3366,9 +3410,12 @@ function cmdSpawnStatus(args) {
|
|
|
3366
3410
|
const json = args.includes("--json");
|
|
3367
3411
|
let names;
|
|
3368
3412
|
if (args.includes("--all")) {
|
|
3413
|
+
// Pid-bearing workers plus bookkept ones whose pid record was lost
|
|
3414
|
+
// (e.g. an older dm-send wiped it): the latter still report via
|
|
3415
|
+
// workerStatus as "no pid recorded" instead of vanishing silently.
|
|
3369
3416
|
names = listJson(d.agents)
|
|
3370
3417
|
.map((e) => e.data)
|
|
3371
|
-
.filter((x) => x && x.name && typeof x.spawnedPid === "number")
|
|
3418
|
+
.filter((x) => x && x.name && (typeof x.spawnedPid === "number" || x.spawnedBy || x.briefId))
|
|
3372
3419
|
.map((x) => x.name)
|
|
3373
3420
|
.sort();
|
|
3374
3421
|
} else {
|
|
@@ -4303,14 +4350,43 @@ function cmdDoctor(args) {
|
|
|
4303
4350
|
else info(`not a git checkout (sends omit rev)`);
|
|
4304
4351
|
|
|
4305
4352
|
let ids = parseHarnessFlag(args);
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4353
|
+
if (ids.length === 0) {
|
|
4354
|
+
if (meta && Array.isArray(meta.harnesses) && meta.harnesses.length > 0) ids = meta.harnesses.filter((h) => HARNESSES.includes(h));
|
|
4355
|
+
else {
|
|
4356
|
+
ids = detectHarnesses(cwd);
|
|
4357
|
+
if (ids.length === 0) ids = ["generic"];
|
|
4358
|
+
}
|
|
4311
4359
|
}
|
|
4312
|
-
|
|
4313
|
-
|
|
4360
|
+
// Dead-man scan (read-only, board-local only — no cwd needed): the
|
|
4361
|
+
// events a lead cannot see without polling — workers that exited
|
|
4362
|
+
// without replying, passed deadlines, or went quiet while their pid
|
|
4363
|
+
// is still alive. Informational only — never FAIL. Placed before the
|
|
4364
|
+
// non-local early return so it runs for --board boards too.
|
|
4365
|
+
try {
|
|
4366
|
+
const dd = ensureBoard(root);
|
|
4367
|
+
const found = [];
|
|
4368
|
+
for (const e of listJson(path.join(root, "agents"))) {
|
|
4369
|
+
const a = e && e.data;
|
|
4370
|
+
if (!a || !a.name || typeof a.spawnedPid !== "number") continue;
|
|
4371
|
+
let st = null;
|
|
4372
|
+
try {
|
|
4373
|
+
st = workerStatus(dd, a.name, 1);
|
|
4374
|
+
} catch {
|
|
4375
|
+
continue;
|
|
4376
|
+
}
|
|
4377
|
+
if (!st || !st.known) continue;
|
|
4378
|
+
if (st.alive === false && a.briefId && !st.reply) {
|
|
4379
|
+
found.push(`${a.name} exited with no reply to its brief — check its log (${st.logPath || "no log yet"})`);
|
|
4380
|
+
} else if (st.budget && st.budget.exceeded) {
|
|
4381
|
+
found.push(`${a.name} is past its deadline/budget — see spawn-status --to ${a.name}`);
|
|
4382
|
+
} else if (st.alive === true && st.logIdleMs !== null && st.logIdleMs > 15 * 60 * 1000) {
|
|
4383
|
+
found.push(`${a.name} alive but no log output for ${Math.round(st.logIdleMs / 60000)}m — possibly spinning; see spawn-status --to ${a.name}`);
|
|
4384
|
+
}
|
|
4385
|
+
}
|
|
4386
|
+
for (const f of found.slice(0, 5)) info(`dead-man: ${f}`);
|
|
4387
|
+
if (found.length > 5) info(`dead-man: …and ${found.length - 5} more — see spawn-status --all`);
|
|
4388
|
+
} catch {}
|
|
4389
|
+
if (!local) {
|
|
4314
4390
|
info(`non-local board: skipping project-file checks for ${root}`);
|
|
4315
4391
|
console.log(bad === 0 ? "doctor: healthy" : `doctor: ${bad} problem(s)`);
|
|
4316
4392
|
if (bad > 0) process.exitCode = 1;
|
|
@@ -4426,16 +4502,16 @@ function cmdDoctor(args) {
|
|
|
4426
4502
|
break;
|
|
4427
4503
|
}
|
|
4428
4504
|
}
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4505
|
+
// Reconcile hint (read-only): workers whose pids look recycled since a
|
|
4506
|
+
// reboot read dead instead of running. Informational only — never FAIL.
|
|
4507
|
+
try {
|
|
4508
|
+
const stale = listJson(path.join(root, "agents"))
|
|
4509
|
+
.map((e) => e.data)
|
|
4510
|
+
.filter((x) => x && x.name && isPidStale(x.spawnedPid, x.spawnedAt))
|
|
4511
|
+
.map((x) => x.name);
|
|
4512
|
+
if (stale.length > 0) info(`${stale.length} worker(s) with stale pids (presumed dead after reboot): ${stale.slice(0, 5).join(",")}${stale.length > 5 ? "…" : ""} — see spawn-status --all; respawn to reboot`);
|
|
4513
|
+
} catch {}
|
|
4514
|
+
if (!process.env.CREWBUS_AGENT) info("CREWBUS_AGENT is unset — hooks need it to know who you are");
|
|
4439
4515
|
if (!process.env.CREWBUS_TOKEN) info("CREWBUS_TOKEN is unset — sends/reads as a claimed name need it");
|
|
4440
4516
|
try {
|
|
4441
4517
|
const legacy = fs.readdirSync(path.join(root, "agents")).filter((f) => f.endsWith(".json")).map((f) => {
|
|
@@ -4801,6 +4877,7 @@ async function cmdCrew(args) {
|
|
|
4801
4877
|
async function cmdServe(args) {
|
|
4802
4878
|
const root = boardDir(args);
|
|
4803
4879
|
const d = requireBoard(root);
|
|
4880
|
+
ensureBoardCliShim(d);
|
|
4804
4881
|
const host = getFlag(args, "--host") || "127.0.0.1";
|
|
4805
4882
|
const port = Number(getFlag(args, "--port") || 0);
|
|
4806
4883
|
if (!(port >= 0 && port < 65536)) fail("--port must be 0-65535 (0 = random)");
|
|
@@ -6240,6 +6317,8 @@ Setup:
|
|
|
6240
6317
|
crewbus init [--global] [--board <path>] [--force] [--no-opencode] [--portable]
|
|
6241
6318
|
[--harness opencode,claude,codex,antigravity,grok,cursor,generic]
|
|
6242
6319
|
create board in ./.crewbus, write AGENTS.md block, install harness
|
|
6320
|
+
crewbus install-cli
|
|
6321
|
+
install standalone CLI wrapper to user PATH (~/.crewbus/bin)
|
|
6243
6322
|
wiring (hooks + MCP config + notes). Without --harness, init applies the
|
|
6244
6323
|
union of detected markers (.opencode/.claude/.codex/.agents/.grok/.cursor).
|
|
6245
6324
|
init also pre-approves board-only bus I/O (crewbus MCP server +
|
|
@@ -7243,6 +7322,21 @@ async function cmdPoolResume(args) {
|
|
|
7243
7322
|
}
|
|
7244
7323
|
}
|
|
7245
7324
|
|
|
7325
|
+
function cmdInstallCli() {
|
|
7326
|
+
const res = installCliToUserPath();
|
|
7327
|
+
if (res.ok) {
|
|
7328
|
+
console.log(`crewbus: standalone CLI wrappers written to ${res.binDir}`);
|
|
7329
|
+
if (res.addedToPath) {
|
|
7330
|
+
console.log(`crewbus: ${res.detail}`);
|
|
7331
|
+
console.log(`crewbus: restart your terminal window, then type "crewbus" to verify`);
|
|
7332
|
+
} else {
|
|
7333
|
+
console.log(`crewbus: ${res.detail}`);
|
|
7334
|
+
}
|
|
7335
|
+
} else {
|
|
7336
|
+
fail(`install-cli failed: ${res.detail || "unknown error"}`);
|
|
7337
|
+
}
|
|
7338
|
+
}
|
|
7339
|
+
|
|
7246
7340
|
async function main() {
|
|
7247
7341
|
const [, , cmd, ...rest] = process.argv;
|
|
7248
7342
|
if (REMOVED.has(cmd)) {
|
|
@@ -7250,6 +7344,7 @@ async function main() {
|
|
|
7250
7344
|
}
|
|
7251
7345
|
switch (cmd) {
|
|
7252
7346
|
case "init": return cmdInit(rest);
|
|
7347
|
+
case "install-cli": return cmdInstallCli();
|
|
7253
7348
|
case "register": return cmdRegister(rest);
|
|
7254
7349
|
case "acl": return cmdAcl(rest);
|
|
7255
7350
|
case "agents": return cmdAgents(rest);
|
package/bin/lib/groups.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { readJson, writeJson, listJson, fail, parseGroupList, cleanGroupName } from "./store.js";
|
|
10
|
-
import { readDMs, findMessageById, isVerified, scanAllMessages } from "./mail.js";
|
|
10
|
+
import { readDMs, findMessageById, isVerified, scanAllMessages, ackedIdSet } from "./mail.js";
|
|
11
11
|
import { readChannelPosts } from "./channels.js";
|
|
12
12
|
|
|
13
13
|
// parseGroupList + cleanGroupName live in store.js (canonical home).
|
|
@@ -104,7 +104,10 @@ export function readGroup(d, name) {
|
|
|
104
104
|
export function expandGroups(d, raw) {
|
|
105
105
|
const out = [];
|
|
106
106
|
if (raw === undefined || raw === null || String(raw).trim() === "") return out;
|
|
107
|
-
for (const part of String(raw).split(
|
|
107
|
+
for (const part of String(raw).split(/[,\s]+/)) {
|
|
108
|
+
// Group names carry no spaces (see cleanGroupName), so splitting on
|
|
109
|
+
// whitespace too is safe — and it saves PowerShell users whose
|
|
110
|
+
// unquoted --to-group a,b arrives space-rejoined (see cmdGroup --add).
|
|
108
111
|
if (part.trim() === "") continue;
|
|
109
112
|
const g = String(part).trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 40);
|
|
110
113
|
if (!g || g === "@all") throw new Error(`invalid group name "${part}"`);
|
|
@@ -194,6 +197,99 @@ export function gatherTelemetry(items) {
|
|
|
194
197
|
return { messages: items.length, tokensEst: Math.floor(chars / 4) };
|
|
195
198
|
}
|
|
196
199
|
|
|
200
|
+
// Batch (brief) enumeration for the dashboard Briefs view: one row per
|
|
201
|
+
// fan-out/thread on the board — briefs, checkpoints, finals, ack state,
|
|
202
|
+
// last activity. Keys come from message batch stamps plus spawned
|
|
203
|
+
// workers' briefIds (single-recipient briefs carry no batch until
|
|
204
|
+
// replied to); a batch-less root with no replies and no worker pointing
|
|
205
|
+
// at it is a stray DM, not a brief, and is skipped. Stats mirror
|
|
206
|
+
// collectBatch + gatherTelemetry; pure read, no board IO.
|
|
207
|
+
export function listBatches(d) {
|
|
208
|
+
const all = [];
|
|
209
|
+
let subs = [];
|
|
210
|
+
try {
|
|
211
|
+
subs = fs.readdirSync(d.dm);
|
|
212
|
+
} catch {
|
|
213
|
+
subs = [];
|
|
214
|
+
}
|
|
215
|
+
for (const sub of subs) {
|
|
216
|
+
try {
|
|
217
|
+
if (!fs.statSync(path.join(d.dm, sub)).isDirectory()) continue;
|
|
218
|
+
} catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
for (const m of readDMs(d, sub)) all.push(m);
|
|
222
|
+
}
|
|
223
|
+
for (const e of listJson(d.broadcast || path.join(d.root, "broadcast"))) {
|
|
224
|
+
const b = e.data;
|
|
225
|
+
if (b && b.id && b.from) {
|
|
226
|
+
if (!b.batch) b.batch = b.id;
|
|
227
|
+
all.push(b);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const childCount = new Map();
|
|
231
|
+
for (const m of all) {
|
|
232
|
+
if (!m.replyTo) continue;
|
|
233
|
+
childCount.set(m.replyTo, (childCount.get(m.replyTo) || 0) + 1);
|
|
234
|
+
}
|
|
235
|
+
const keys = [];
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
const addKey = (k) => {
|
|
238
|
+
if (k && !seen.has(k)) {
|
|
239
|
+
seen.add(k);
|
|
240
|
+
keys.push(k);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
for (const m of all) {
|
|
244
|
+
if (m.batch) addKey(m.batch);
|
|
245
|
+
else if (!m.replyTo && (childCount.get(m.id) || 0) > 0) addKey(m.id);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
for (const e of listJson(path.join(d.root, "agents"))) {
|
|
249
|
+
const a = e && e.data;
|
|
250
|
+
if (a && a.briefId) addKey(a.briefId);
|
|
251
|
+
}
|
|
252
|
+
} catch {}
|
|
253
|
+
const acked = ackedIdSet(d);
|
|
254
|
+
const rows = [];
|
|
255
|
+
for (const key of keys) {
|
|
256
|
+
const res = collectBatch(d, key);
|
|
257
|
+
if (!res) continue;
|
|
258
|
+
const briefs = res.items.filter((m) => !m.replyTo);
|
|
259
|
+
if (briefs.length === 0) continue;
|
|
260
|
+
briefs.sort((a, b) => String(a.at).localeCompare(String(b.at)) || String(a.id).localeCompare(String(b.id)));
|
|
261
|
+
const replies = res.items.filter((m) => !!m.replyTo);
|
|
262
|
+
const checkpoints = replies.filter((m) => m.checkpoint === true);
|
|
263
|
+
const finals = replies.filter((m) => m.checkpoint !== true);
|
|
264
|
+
const workers = [];
|
|
265
|
+
for (const b of briefs) {
|
|
266
|
+
const tos = Array.isArray(b.to) ? b.to : (b.to ? [b.to] : []);
|
|
267
|
+
for (const t of tos) {
|
|
268
|
+
if (t && !workers.includes(t)) workers.push(t);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
let lastAt = briefs[0].at || "";
|
|
272
|
+
for (const m of res.items) {
|
|
273
|
+
if (String(m.at || "") > String(lastAt || "")) lastAt = m.at;
|
|
274
|
+
}
|
|
275
|
+
rows.push({
|
|
276
|
+
batch: key,
|
|
277
|
+
subject: briefs[0].subject || "",
|
|
278
|
+
from: briefs[0].from || "",
|
|
279
|
+
at: briefs[0].at || "",
|
|
280
|
+
workers,
|
|
281
|
+
briefCount: briefs.length,
|
|
282
|
+
replyCount: replies.length,
|
|
283
|
+
checkpointCount: checkpoints.length,
|
|
284
|
+
finalCount: finals.length,
|
|
285
|
+
ackedFinals: finals.filter((m) => acked.has(m.id)).length,
|
|
286
|
+
lastAt: lastAt || "",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
rows.sort((a, b) => String(b.lastAt).localeCompare(String(a.lastAt)) || String(a.batch).localeCompare(String(b.batch)));
|
|
290
|
+
return rows;
|
|
291
|
+
}
|
|
292
|
+
|
|
197
293
|
export function batchReplyIds(d, batch) {
|
|
198
294
|
const res = collectBatch(d, batch);
|
|
199
295
|
if (!res) return null;
|
package/bin/lib/identity.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|