@drakon-systems/multi-clawd 2.4.0 → 2.5.1

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/scripts/cli.mjs CHANGED
@@ -12,8 +12,7 @@
12
12
  * commands are just `multi-clawd setup` / `multi-clawd update` / …)
13
13
  *
14
14
  * `update` wraps the whole upgrade dance — registry version check, the
15
- * openclaw install with the right flags, gateway restart, doctor — so nobody
16
- * has to remember `--pin --force`.
15
+ * ClawHub install with npm fallback, gateway restart, doctor.
17
16
  */
18
17
  import { execFileSync, spawnSync } from "node:child_process";
19
18
  import { readFileSync, existsSync as existsSyncEarly } from "node:fs";
@@ -53,8 +52,8 @@ Run via npx (${DIM}npx ${PKG} <command>${RESET}) or install globally
53
52
 
54
53
  const { resolveInstallDir } = await import(join(__dirname, "_shared.mjs"));
55
54
 
56
- function installedVersion() {
57
- const dir = resolveInstallDir();
55
+ function installedVersion(installPath) {
56
+ const dir = installPath ?? resolveInstallDir();
58
57
  if (!dir) return undefined;
59
58
  try {
60
59
  return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
@@ -63,6 +62,18 @@ function installedVersion() {
63
62
  }
64
63
  }
65
64
 
65
+ function trackedInstallRecord(uc) {
66
+ let inspected;
67
+ try {
68
+ inspected = execFileSync("openclaw", ["plugins", "inspect", "multi-clawd", "--json"], {
69
+ encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"],
70
+ });
71
+ } catch {
72
+ // Legacy OpenClaw may expose the install only in openclaw.json.
73
+ }
74
+ return uc.parseInstallRecord(inspected, readOpenclawConfigSync());
75
+ }
76
+
66
77
  function latestVersion() {
67
78
  try {
68
79
  return execFileSync("npm", ["view", PKG, "version"], { encoding: "utf8", timeout: 15000 })
@@ -167,13 +178,14 @@ async function loadDirect(config) {
167
178
  const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
168
179
  const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
169
180
  if (!accounts.some((a) => a && a.direct !== undefined && a.direct !== false)) return undefined;
170
- let dr, ds, rep, run, shim;
181
+ let dr, ds, rep, run, shim, hist;
171
182
  try {
172
183
  dr = await import(resolve(__dirname, "..", "dist", "direct-route.js"));
173
184
  ds = await import(resolve(__dirname, "..", "dist", "direct-sync.js"));
174
185
  rep = await import(resolve(__dirname, "..", "dist", "direct-report.js"));
175
186
  run = await import(resolve(__dirname, "..", "dist", "openclaw-runner.js"));
176
187
  shim = await import(resolve(__dirname, "..", "dist", "shim-core.js"));
188
+ hist = await import(resolve(__dirname, "..", "dist", "history.js"));
177
189
  } catch (err) {
178
190
  console.error(distFailure("direct", "direct-report.js", err));
179
191
  process.exit(1);
@@ -217,6 +229,8 @@ async function loadDirect(config) {
217
229
  sticky,
218
230
  minDwellMs: pool?.minDwellMs,
219
231
  strategy: pool?.strategy,
232
+ readHistory: () => { try { return hist.readHistory(join(stateDir, hist.HISTORY_FILE)); } catch { return []; } },
233
+ spreadWindowMs: pool?.spreadWindowMs,
220
234
  nowMs: Date.now(),
221
235
  ...opts,
222
236
  });
@@ -473,25 +487,46 @@ async function update() {
473
487
  console.error(" ❌ the `openclaw` CLI is not on PATH — install OpenClaw first.");
474
488
  process.exit(1);
475
489
  }
476
- const installed = installedVersion();
490
+ const source = trackedInstallRecord(uc);
491
+ const installed = installedVersion(source?.installPath) ?? source?.version;
477
492
  process.stdout.write(` checking registry… `);
478
493
  const latest = latestVersion();
479
494
  console.log(latest ? `latest is v${latest}` : "unreachable");
480
495
  const banner = uc.formatUpdateBanner({ installed, latest });
481
496
  const action = uc.decideUpdateAction({ installed, latest });
482
497
  console.log(` ${action === "up-to-date" ? "✅" : action === "unknown" ? "⚠️ " : "⬆️ "} ${banner}\n`);
483
- if (action === "up-to-date") return;
498
+ const migrating = action === "up-to-date" && source?.source === "npm";
499
+ if (action === "up-to-date" && !migrating) return;
500
+ if (migrating) console.log(" This plugin is tracked as an npm install. Reinstalling from ClawHub clears OpenClaw's npm source caution.\n");
484
501
  if (action === "unknown") {
485
502
  console.log(" Check your network and try again.");
486
503
  process.exit(1);
487
504
  }
488
- if (!(await askYes(` ${action === "install" ? "Install" : "Update"} now?`))) return;
489
-
490
- console.log(`\n ${DIM}openclaw plugins install ${PKG} --pin --force${RESET}`);
491
- const inst = spawnSync("openclaw", ["plugins", "install", PKG, "--pin", "--force"], {
492
- stdio: "inherit",
493
- });
494
- if (inst.status !== 0) {
505
+ const targetVersion = migrating ? installed : latest;
506
+ if (!uc.exactTargetVersion(targetVersion)) {
507
+ console.error(" ❌ cannot install: no valid exact target version is available.");
508
+ process.exit(1);
509
+ }
510
+ if (!(await askYes(` ${migrating ? "Reinstall from ClawHub" : action === "install" ? "Install" : "Update"} now?`))) return;
511
+ let installedOk = false;
512
+ for (const [index, argv] of uc.pluginInstallAttempts(PKG, targetVersion).entries()) {
513
+ if (index === 1) console.log("\n ClawHub install failed; falling back to npm. OpenClaw may show a source caution for npm installs.");
514
+ console.log(`\n ${DIM}openclaw ${argv.join(" ")}${RESET}`);
515
+ // stdin and stdout stay on the terminal so OpenClaw's install confirmation
516
+ // prompts still work; stderr (where OpenClaw reports failures) is captured
517
+ // to decide whether a ClawHub failure may fall back to npm, then echoed.
518
+ const result = spawnSync("openclaw", argv, { encoding: "utf8", stdio: ["inherit", "inherit", "pipe"] });
519
+ if (result.stderr) process.stderr.write(result.stderr);
520
+ if (result.status === 0 && !result.signal && !result.error) {
521
+ installedOk = true;
522
+ break;
523
+ }
524
+ if (index === 0 && uc.classifyClawhubFailure({ status: result.status, signal: result.signal, error: result.error }, `${result.stderr ?? ""}\n${result.error?.message ?? ""}`) === "stop") {
525
+ console.error("\n ❌ ClawHub install failed without a confirmed availability error; npm was not tried. Review the failure above before retrying.");
526
+ process.exit(1);
527
+ }
528
+ }
529
+ if (!installedOk) {
495
530
  console.error("\n ❌ install failed — see output above.");
496
531
  process.exit(1);
497
532
  }
@@ -510,7 +545,8 @@ async function update() {
510
545
  console.log(`\n ⚠ doctor found problems — if it flagged the watchdog, run ${BOLD}npx ${PKG} setup${RESET} to repair it.`);
511
546
  process.exit(doc.status ?? 1);
512
547
  }
513
- console.log(`\n ✅ done — now on v${installedVersion() ?? "?"}`);
548
+ const updatedSource = trackedInstallRecord(uc);
549
+ console.log(`\n ✅ done — now on v${installedVersion(updatedSource?.installPath) ?? updatedSource?.version ?? "?"}`);
514
550
  }
515
551
 
516
552
  /**
@@ -639,7 +675,29 @@ async function healWatchdogUnit() {
639
675
  }
640
676
 
641
677
  /** `explain` — gather config + live health, render the plain-English view. */
642
- async function explain() {
678
+ async function explain(args = []) {
679
+ let historySince;
680
+ if (args.length) {
681
+ const allowed = args[0] === "--history" && (args.length === 1 ||
682
+ (args.length === 3 && args[1] === "--since"));
683
+ if (!allowed) {
684
+ console.error("explain: usage: multi-clawd explain [--history [--since 7d|24h]]");
685
+ process.exitCode = 1;
686
+ return;
687
+ }
688
+ historySince = args[2] ?? "7d";
689
+ }
690
+ let history;
691
+ if (historySince) {
692
+ try {
693
+ history = await import(resolve(__dirname, "..", "dist", "history.js"));
694
+ history.parseHistorySince(historySince);
695
+ } catch (err) {
696
+ console.error(`explain: ${err.message}`);
697
+ process.exitCode = 1;
698
+ return;
699
+ }
700
+ }
643
701
  const { readFileSync: rf, existsSync } = await import("node:fs");
644
702
  const { homedir } = await import("node:os");
645
703
  let ec, health, shim;
@@ -691,7 +749,8 @@ async function explain() {
691
749
  weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
692
750
  staleAfterMs: pool?.staleAfterMs,
693
751
  }, now);
694
- return { id: a.id, verdict: h.verdict, detail, usage };
752
+ // #93: the headroom figure the pool ranks near-limit members by.
753
+ return { id: a.id, verdict: h.verdict, detail, usage, utilization: h.utilization };
695
754
  });
696
755
  let stickyAccount;
697
756
  let stickyEntry;
@@ -704,6 +763,18 @@ async function explain() {
704
763
  /* no sticky state */
705
764
  }
706
765
  }
766
+ // #92: the spread strategy places by the served-launch journal; hand
767
+ // explain the same events the launch path reads so the reason shown is the
768
+ // reason acted on.
769
+ let spreadHistory;
770
+ if (pool?.strategy === "spread") {
771
+ try {
772
+ const hist = history ?? await import(resolve(__dirname, "..", "dist", "history.js"));
773
+ spreadHistory = hist.readHistory(join(stateDir, hist.HISTORY_FILE));
774
+ } catch {
775
+ /* no journal yet: every account counts as never served */
776
+ }
777
+ }
707
778
  // v1.9: the direct anthropic/* route, when any account opted in.
708
779
  let direct;
709
780
  const dctx = await loadDirect(config);
@@ -714,9 +785,13 @@ async function explain() {
714
785
  }
715
786
  console.log(`\n${BOLD}🦞 multi-clawd — your setup, in plain English${RESET}\n`);
716
787
  console.log(
717
- ec.renderExplanation({ accounts, pool, chain, health: healthRows, stickyAccount, stickyEntry, nowMs: now, direct }),
788
+ ec.renderExplanation({ accounts, pool, chain, health: healthRows, stickyAccount, stickyEntry, nowMs: now, direct, history: spreadHistory }),
718
789
  );
719
790
  console.log(`\n${DIM}(health checks: multi-clawd doctor · change things: multi-clawd setup)${RESET}`);
791
+ if (history) {
792
+ console.log(`\n${history.renderHistory(history.readHistory(join(stateDir, history.HISTORY_FILE)),
793
+ accounts.map((a) => a.id), historySince, now)}`);
794
+ }
720
795
  }
721
796
 
722
797
  /**
@@ -790,18 +865,28 @@ async function usageCommand(args) {
790
865
  const h = health.classifyAccountHealth(state, healthOptions, now);
791
866
  row.verdict = h.verdict;
792
867
  row.reason = h.reason;
868
+ if (h.utilization !== undefined) row.utilization = h.utilization;
793
869
  if (pool?.strategy === "reset-aware") resetById.set(a.id, health.sevenDayReset(state, healthOptions, now));
794
870
  rows.push(row);
795
871
  }
796
872
  let next;
797
873
  if (pool) {
798
874
  const verdicts = pool.accounts.filter((id) => !accounts.find((a) => a.id === id)?.disabled).map((id) => ({ id, verdict: rows.find((r) => r.id === id)?.verdict ?? "no_data",
799
- sevenDayResetsAt: resetById.get(id) }));
800
- if (pool.strategy === "reset-aware") {
875
+ utilization: rows.find((r) => r.id === id)?.utilization, sevenDayResetsAt: resetById.get(id) }));
876
+ if (pool.strategy === "reset-aware" || pool.strategy === "spread") {
801
877
  const stickySelection = await import(resolve(__dirname, "..", "dist", "sticky.js"));
802
878
  let sticky;
803
879
  try { sticky = JSON.parse(rf(join(stateDir, `pool-${pool.id}.sticky.json`), "utf8")); } catch { /* none */ }
804
- next = verdicts.length ? stickySelection.decideStickySelection({ verdicts, sticky, nowMs: now, minDwellMs: pool.minDwellMs, strategy: pool.strategy }).account : undefined;
880
+ let ranked = verdicts;
881
+ if (pool.strategy === "spread") {
882
+ // Same journal and window as the launch path (#92).
883
+ const hist = await import(resolve(__dirname, "..", "dist", "history.js"));
884
+ const spread = await import(resolve(__dirname, "..", "dist", "spread.js"));
885
+ let events = [];
886
+ try { events = hist.readHistory(join(stateDir, hist.HISTORY_FILE)); } catch { /* none */ }
887
+ ranked = spread.withSpreadStats(verdicts, spread.spreadStatsFromHistory(events, now, spread.spreadWindowMs(pool.spreadWindowMs)));
888
+ }
889
+ next = ranked.length ? stickySelection.decideStickySelection({ verdicts: ranked, sticky, nowMs: now, minDwellMs: pool.minDwellMs, strategy: pool.strategy }).account : undefined;
805
890
  } else {
806
891
  next = verdicts.length ? health.choosePoolAccount(verdicts) : undefined;
807
892
  }
@@ -978,7 +1063,7 @@ switch (cmd) {
978
1063
  break;
979
1064
  }
980
1065
  case "explain":
981
- await explain();
1066
+ await explain(rest);
982
1067
  break;
983
1068
  case "chain":
984
1069
  await chain(rest);
@@ -33,6 +33,60 @@ import { dirname, join, resolve } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
 
35
35
  const HOME = homedir();
36
+ // OpenClaw's tracked install record (#85). Asking OpenClaw costs a process
37
+ // launch (about 2 s on an idle host), so skip it for an unconfigured install.
38
+ // Cache against the manifest and legacy record, with a one-day expiry.
39
+ let inspectText;
40
+ function loadInspectText(foundDir, configRecord) {
41
+ const cacheFile = join(HOME, ".openclaw", "state", "multi-clawd", "install-record.json");
42
+ const recordKey = JSON.stringify({ source: configRecord?.source, version: configRecord?.version, installPath: configRecord?.installPath });
43
+ let mtimeMs;
44
+ try {
45
+ mtimeMs = statSync(join(foundDir, "openclaw.plugin.json")).mtimeMs;
46
+ } catch {
47
+ /* raced with an uninstall; ask OpenClaw */
48
+ }
49
+ try {
50
+ const cached = JSON.parse(readFileSync(cacheFile, "utf8"));
51
+ if (cached?.installDir === foundDir && cached?.manifestMtimeMs === mtimeMs && cached?.recordKey === recordKey &&
52
+ typeof cached?.checkedAt === "number" && Date.now() - cached.checkedAt < 24 * 60 * 60 * 1000 && cached?.install) {
53
+ inspectText = JSON.stringify({ install: cached.install });
54
+ return;
55
+ }
56
+ } catch {
57
+ /* no cache yet */
58
+ }
59
+ try {
60
+ inspectText = execFileSync("openclaw", ["plugins", "inspect", "multi-clawd", "--json"], {
61
+ encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"],
62
+ });
63
+ } catch {
64
+ // Missing or older OpenClaw: the legacy config record is checked below.
65
+ return;
66
+ }
67
+ try {
68
+ const install = JSON.parse(inspectText)?.install;
69
+ if (install && typeof install === "object" && typeof install.source === "string") {
70
+ mkdirSync(dirname(cacheFile), { recursive: true });
71
+ writeFileSync(
72
+ cacheFile,
73
+ JSON.stringify({ installDir: foundDir, manifestMtimeMs: mtimeMs, recordKey, checkedAt: Date.now(), install }, null, 2) + "\n",
74
+ );
75
+ }
76
+ } catch {
77
+ /* cache is an optimisation, never a requirement */
78
+ }
79
+ }
80
+
81
+ function trackedInstallPath(configRecord) {
82
+ try {
83
+ const inspected = JSON.parse(inspectText ?? "");
84
+ if (typeof inspected?.install?.installPath === "string") return inspected.install.installPath;
85
+ } catch {
86
+ // Fall back to the legacy config record.
87
+ }
88
+ return typeof configRecord?.installPath === "string" ? configRecord.installPath : undefined;
89
+ }
36
90
 
37
91
  /**
38
92
  * Where is the plugin actually installed? Path installs land in
@@ -42,6 +96,21 @@ const HOME = homedir();
42
96
  * npm-project install carrying a manifest.
43
97
  */
44
98
  function resolveInstallDir() {
99
+ const found = findInstallDirOnDisk();
100
+ let config;
101
+ try { config = JSON.parse(readFileSync(join(HOME, ".openclaw", "openclaw.json"), "utf8")); } catch { /* no config */ }
102
+ const record = config?.plugins?.installs?.["multi-clawd"];
103
+ const legacyPath = typeof record?.installPath === "string" ? record.installPath : undefined;
104
+ const hasFound = existsSync(join(found, "openclaw.plugin.json"));
105
+ if (!hasFound && !record && !config?.plugins?.entries?.["multi-clawd"]) return found;
106
+ const candidate = !hasFound && legacyPath && existsSync(join(legacyPath, "openclaw.plugin.json")) ? legacyPath : found;
107
+ loadInspectText(candidate, record);
108
+ const tracked = trackedInstallPath(record);
109
+ if (tracked && existsSync(join(tracked, "openclaw.plugin.json"))) return tracked;
110
+ return candidate;
111
+ }
112
+
113
+ function findInstallDirOnDisk() {
45
114
  const extDir = join(HOME, ".openclaw", "extensions", "multi-clawd");
46
115
  if (existsSync(join(extDir, "openclaw.plugin.json"))) return extDir;
47
116
  const projects = join(HOME, ".openclaw", "npm", "projects");
@@ -168,7 +237,7 @@ else ok(`installed at ${EXT_DIR}`);
168
237
  );
169
238
  const cliVer = readJson(join(REPO_DIR, "package.json"))?.version;
170
239
  const pluginVer = manifest?.version;
171
- const note =
240
+ const skewNote =
172
241
  uc && cliVer && typeof uc.formatCliSkew === "function"
173
242
  ? uc.formatCliSkew({
174
243
  cliVersion: cliVer,
@@ -177,9 +246,16 @@ else ok(`installed at ${EXT_DIR}`);
177
246
  pkg: "@drakon-systems/multi-clawd",
178
247
  })
179
248
  : undefined;
180
- if (note) warn(note);
249
+ if (skewNote) warn(skewNote);
181
250
  else if (cliVer && pluginVer) ok(`CLI and plugin both v${cliVer}`);
182
251
 
252
+ if (uc && typeof uc.parseInstallRecord === "function" && typeof uc.classifyInstallSource === "function") {
253
+ const source = uc.classifyInstallSource(uc.parseInstallRecord(inspectText, config));
254
+ if (source?.level === "ok") ok(source.text);
255
+ else if (source?.level === "warn") warn(source.text);
256
+ else if (source?.level === "note") note(source.text);
257
+ }
258
+
183
259
  // Is the installed plugin behind what npm publishes? Nothing else asks:
184
260
  // `openclaw plugins update --all` compares against the PINNED spec and so
185
261
  // always reports a pinned plugin up to date (found 1 Aug 2026), and doctor
@@ -320,6 +396,17 @@ const {
320
396
  "cli",
321
397
  );
322
398
  const { decideStickySelection } = await importDist("sticky.js", ["decideStickySelection"]);
399
+ // #92: the spread strategy ranks by the shim's served-launch journal; doctor
400
+ // re-reads the same file with the same window so its answer is the launch's.
401
+ // `required` names must be functions: HISTORY_FILE is a string, so it is
402
+ // read off the module rather than listed, or the fallback would reject both
403
+ // candidates and an install that predates history.js would fail doctor.
404
+ const historyModule = await importDist("history.js", ["readHistory"]);
405
+ const readHistory = historyModule.readHistory;
406
+ const HISTORY_FILE = typeof historyModule.HISTORY_FILE === "string" ? historyModule.HISTORY_FILE : "history.jsonl";
407
+ const { spreadStatsFromHistory, spreadWindowMs, withSpreadStats, describeWindow } = await importDist(
408
+ "spread.js", ["spreadStatsFromHistory", "spreadWindowMs", "withSpreadStats", "describeWindow"],
409
+ );
323
410
  // Keychain reads name the account the Claude CLI uses (`-a`), never just the
324
411
  // service. Token values are reduced to expiry metadata where they are read and
325
412
  // are never printed.
@@ -546,11 +633,15 @@ else {
546
633
  weeklyUtilizationThreshold: pool.weeklyUtilizationThreshold,
547
634
  rotateOnOverage: pool.rotateOnOverage,
548
635
  };
549
- const verdicts = members.map((id) => {
636
+ const rawVerdicts = members.map((id) => {
550
637
  const state = readJson(join(STATE_DIR, `${id}.json`));
551
- return { id, verdict: classifyAccountHealth(state, healthOptions, now).verdict,
638
+ const health = classifyAccountHealth(state, healthOptions, now);
639
+ return { id, verdict: health.verdict, utilization: health.utilization,
552
640
  sevenDayResetsAt: pool.strategy === "reset-aware" ? sevenDayReset(state, healthOptions, now) : undefined };
553
641
  });
642
+ const verdicts = pool.strategy === "spread"
643
+ ? withSpreadStats(rawVerdicts, spreadStatsFromHistory(readHistory(join(STATE_DIR, HISTORY_FILE)), now, spreadWindowMs(pool.spreadWindowMs)))
644
+ : rawVerdicts;
554
645
  const decision = decideStickySelection({
555
646
  verdicts,
556
647
  sticky,
@@ -563,7 +654,15 @@ else {
563
654
  identity?.status === "resolved" ? ` (${describeIdentity(identity, { raw: RAW })})` : "";
564
655
  const verdict = verdicts.find((v) => v.id === decision.account)?.verdict ?? "no_data";
565
656
  const home = members[0];
566
- if (decision.account === home) {
657
+ if (pool.strategy === "spread") {
658
+ // No home under spread: a launch off the first member is a placement,
659
+ // not a rotation, so it is never reported as one.
660
+ const picked = verdicts.find((v) => v.id === decision.account);
661
+ ok(
662
+ `next new session on ${decision.account}${who} — spread, ${picked?.servedInWindow ?? 0} served in the last ${
663
+ describeWindow(spreadWindowMs(pool.spreadWindowMs))}, health ${verdict}; existing sessions stay on their serving account`,
664
+ );
665
+ } else if (decision.account === home) {
567
666
  ok(`serving on ${decision.account}${who} — home account, health ${verdict}`);
568
667
  if (sticky) {
569
668
  note(