@drakon-systems/multi-clawd 2.4.0 → 2.5.0

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;
@@ -704,6 +762,18 @@ async function explain() {
704
762
  /* no sticky state */
705
763
  }
706
764
  }
765
+ // #92: the spread strategy places by the served-launch journal; hand
766
+ // explain the same events the launch path reads so the reason shown is the
767
+ // reason acted on.
768
+ let spreadHistory;
769
+ if (pool?.strategy === "spread") {
770
+ try {
771
+ const hist = history ?? await import(resolve(__dirname, "..", "dist", "history.js"));
772
+ spreadHistory = hist.readHistory(join(stateDir, hist.HISTORY_FILE));
773
+ } catch {
774
+ /* no journal yet: every account counts as never served */
775
+ }
776
+ }
707
777
  // v1.9: the direct anthropic/* route, when any account opted in.
708
778
  let direct;
709
779
  const dctx = await loadDirect(config);
@@ -714,9 +784,13 @@ async function explain() {
714
784
  }
715
785
  console.log(`\n${BOLD}🦞 multi-clawd — your setup, in plain English${RESET}\n`);
716
786
  console.log(
717
- ec.renderExplanation({ accounts, pool, chain, health: healthRows, stickyAccount, stickyEntry, nowMs: now, direct }),
787
+ ec.renderExplanation({ accounts, pool, chain, health: healthRows, stickyAccount, stickyEntry, nowMs: now, direct, history: spreadHistory }),
718
788
  );
719
789
  console.log(`\n${DIM}(health checks: multi-clawd doctor · change things: multi-clawd setup)${RESET}`);
790
+ if (history) {
791
+ console.log(`\n${history.renderHistory(history.readHistory(join(stateDir, history.HISTORY_FILE)),
792
+ accounts.map((a) => a.id), historySince, now)}`);
793
+ }
720
794
  }
721
795
 
722
796
  /**
@@ -797,11 +871,20 @@ async function usageCommand(args) {
797
871
  if (pool) {
798
872
  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
873
  sevenDayResetsAt: resetById.get(id) }));
800
- if (pool.strategy === "reset-aware") {
874
+ if (pool.strategy === "reset-aware" || pool.strategy === "spread") {
801
875
  const stickySelection = await import(resolve(__dirname, "..", "dist", "sticky.js"));
802
876
  let sticky;
803
877
  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;
878
+ let ranked = verdicts;
879
+ if (pool.strategy === "spread") {
880
+ // Same journal and window as the launch path (#92).
881
+ const hist = await import(resolve(__dirname, "..", "dist", "history.js"));
882
+ const spread = await import(resolve(__dirname, "..", "dist", "spread.js"));
883
+ let events = [];
884
+ try { events = hist.readHistory(join(stateDir, hist.HISTORY_FILE)); } catch { /* none */ }
885
+ ranked = spread.withSpreadStats(verdicts, spread.spreadStatsFromHistory(events, now, spread.spreadWindowMs(pool.spreadWindowMs)));
886
+ }
887
+ next = ranked.length ? stickySelection.decideStickySelection({ verdicts: ranked, sticky, nowMs: now, minDwellMs: pool.minDwellMs, strategy: pool.strategy }).account : undefined;
805
888
  } else {
806
889
  next = verdicts.length ? health.choosePoolAccount(verdicts) : undefined;
807
890
  }
@@ -978,7 +1061,7 @@ switch (cmd) {
978
1061
  break;
979
1062
  }
980
1063
  case "explain":
981
- await explain();
1064
+ await explain(rest);
982
1065
  break;
983
1066
  case "chain":
984
1067
  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,14 @@ 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
638
  return { id, verdict: classifyAccountHealth(state, healthOptions, now).verdict,
552
639
  sevenDayResetsAt: pool.strategy === "reset-aware" ? sevenDayReset(state, healthOptions, now) : undefined };
553
640
  });
641
+ const verdicts = pool.strategy === "spread"
642
+ ? withSpreadStats(rawVerdicts, spreadStatsFromHistory(readHistory(join(STATE_DIR, HISTORY_FILE)), now, spreadWindowMs(pool.spreadWindowMs)))
643
+ : rawVerdicts;
554
644
  const decision = decideStickySelection({
555
645
  verdicts,
556
646
  sticky,
@@ -563,7 +653,15 @@ else {
563
653
  identity?.status === "resolved" ? ` (${describeIdentity(identity, { raw: RAW })})` : "";
564
654
  const verdict = verdicts.find((v) => v.id === decision.account)?.verdict ?? "no_data";
565
655
  const home = members[0];
566
- if (decision.account === home) {
656
+ if (pool.strategy === "spread") {
657
+ // No home under spread: a launch off the first member is a placement,
658
+ // not a rotation, so it is never reported as one.
659
+ const picked = verdicts.find((v) => v.id === decision.account);
660
+ ok(
661
+ `next new session on ${decision.account}${who} — spread, ${picked?.servedInWindow ?? 0} served in the last ${
662
+ describeWindow(spreadWindowMs(pool.spreadWindowMs))}, health ${verdict}; existing sessions stay on their serving account`,
663
+ );
664
+ } else if (decision.account === home) {
567
665
  ok(`serving on ${decision.account}${who} — home account, health ${verdict}`);
568
666
  if (sticky) {
569
667
  note(