@drakon-systems/multi-clawd 2.3.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.
@@ -0,0 +1,115 @@
1
+ import { constants, copyFileSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /** Locate JSON object properties without reserializing the operator's config. */
6
+ export function jsonSpans(source, offset = 0) {
7
+ let at = offset;
8
+ const ws = () => { while (/\s/.test(source[at] ?? "")) at++; };
9
+ function string() {
10
+ const start = at++;
11
+ while (at < source.length) {
12
+ if (source[at] === "\\") { at += 2; continue; }
13
+ if (source[at++] === '"') return { value: JSON.parse(source.slice(start, at)), start, end: at };
14
+ }
15
+ throw new Error("unterminated JSON string");
16
+ }
17
+ function value() {
18
+ ws();
19
+ const start = at;
20
+ if (source[at] === "{") {
21
+ at++; ws(); const properties = [];
22
+ while (source[at] !== "}") {
23
+ const key = string(); ws();
24
+ if (source[at++] !== ":") throw new Error("invalid JSON object");
25
+ const child = value(); ws();
26
+ const end = at;
27
+ properties.push({ key: key.value, keyStart: key.start, start: child.start, end: child.end, propertyEnd: end });
28
+ if (source[at] !== ",") break;
29
+ at++; ws();
30
+ }
31
+ if (source[at++] !== "}") throw new Error("invalid JSON object");
32
+ return { start, end: at, properties };
33
+ }
34
+ if (source[at] === "[") {
35
+ at++; ws(); const items = [];
36
+ while (source[at] !== "]") {
37
+ items.push(value()); ws();
38
+ if (source[at] !== ",") break;
39
+ at++; ws();
40
+ }
41
+ if (source[at++] !== "]") throw new Error("invalid JSON array");
42
+ return { start, end: at, items };
43
+ }
44
+ if (source[at] === '"') return string();
45
+ while (at < source.length && !/[\s,}\]]/.test(source[at])) at++;
46
+ return { start, end: at };
47
+ }
48
+ const root = value(); ws();
49
+ if (offset === 0 && at !== source.length) throw new Error("trailing JSON content");
50
+ return root;
51
+ }
52
+
53
+ const property = (node, key) => {
54
+ if (!node?.properties) throw new Error("expected JSON object");
55
+ const matches = node.properties.filter((p) => p.key === key);
56
+ if (matches.length > 1) throw new Error(`duplicate ${key} property`);
57
+ return matches[0];
58
+ };
59
+ const child = (source, node, key) => {
60
+ const p = property(node, key);
61
+ return p && jsonSpans(source, p.start);
62
+ };
63
+
64
+ export function editAccountDisabled(source, id, disabled) {
65
+ const original = JSON.parse(source);
66
+ let node = jsonSpans(source);
67
+ for (const key of ["plugins", "entries", "multi-clawd", "config", "accounts"]) {
68
+ node = child(source, node, key);
69
+ if (!node) throw new Error(`missing plugins.entries["multi-clawd"].config.accounts`);
70
+ }
71
+ if (!node.items) throw new Error("accounts must be an array");
72
+ const accounts = original?.plugins?.entries?.["multi-clawd"]?.config?.accounts;
73
+ const matches = accounts?.map((a, i) => a?.id === id ? i : -1).filter((i) => i >= 0) ?? [];
74
+ if (matches.length === 0) throw new Error(`unknown account id "${id}"`);
75
+ if (matches.length !== 1) throw new Error(`duplicate account id "${id}"`);
76
+ const account = node.items[matches[0]];
77
+ const flag = property(account, "disabled");
78
+ if (flag && typeof accounts[matches[0]].disabled !== "boolean") throw new Error("disabled must be a boolean");
79
+ let edited;
80
+ if (flag) {
81
+ edited = source.slice(0, flag.start) + String(disabled) + source.slice(flag.end);
82
+ } else if (disabled) {
83
+ const indent = source.slice(source.lastIndexOf("\n", account.start - 1) + 1, account.start);
84
+ const space = source.slice(account.start + 1, account.end - 1);
85
+ const multiline = space.includes("\n");
86
+ const addition = account.properties.length
87
+ ? `,${multiline ? `\n${indent} ` : " "}"disabled": true`
88
+ : `"disabled": true`;
89
+ edited = source.slice(0, account.end - 1) + addition + source.slice(account.end - 1);
90
+ } else {
91
+ return { edited: source, changed: false };
92
+ }
93
+ const expected = structuredClone(original);
94
+ expected.plugins.entries["multi-clawd"].config.accounts[matches[0]].disabled = disabled;
95
+ if (JSON.stringify(JSON.parse(edited)) !== JSON.stringify(expected)) throw new Error("targeted edit changed other config values");
96
+ return { edited, changed: edited !== source };
97
+ }
98
+
99
+ export function changeAccount(id, disabled, dryRun = false, configFile = join(homedir(), ".openclaw", "openclaw.json")) {
100
+ const source = readFileSync(configFile, "utf8");
101
+ const { edited, changed } = editAccountDisabled(source, id, disabled);
102
+ if (dryRun || !changed) return { changed, dryRun };
103
+ if (readFileSync(configFile, "utf8") !== source) throw new Error("config changed during edit; retry");
104
+ const backup = `${configFile}.backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
105
+ copyFileSync(configFile, backup, constants.COPYFILE_EXCL);
106
+ const temporary = `${configFile}.tmp-${process.pid}-${Date.now()}`;
107
+ try {
108
+ writeFileSync(temporary, edited, { mode: statSync(configFile).mode & 0o777 });
109
+ if (readFileSync(configFile, "utf8") !== source) throw new Error("config changed during edit; retry");
110
+ renameSync(temporary, configFile);
111
+ } finally {
112
+ rmSync(temporary, { force: true });
113
+ }
114
+ return { changed, dryRun, backup };
115
+ }
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";
@@ -36,6 +35,7 @@ ${BOLD}🦞 multi-clawd${RESET} — multi-account Claude failover for OpenClaw
36
35
 
37
36
  ${BOLD}setup${RESET} guided setup wizard (accounts, pool, watchdog)
38
37
  ${BOLD}login${RESET} log a configured account in (or re-auth it) — right dir, right env
38
+ ${BOLD}account${RESET} disable or enable a pool account (add --dry-run to preview)
39
39
  ${BOLD}explain${RESET} your setup in plain English — accounts, pool, fallback chain
40
40
  ${BOLD}chain${RESET} audit your model routing — what actually serves each turn
41
41
  ${BOLD}direct${RESET} the direct anthropic/* route — status, or \`direct sync\` to store profiles
@@ -52,8 +52,8 @@ Run via npx (${DIM}npx ${PKG} <command>${RESET}) or install globally
52
52
 
53
53
  const { resolveInstallDir } = await import(join(__dirname, "_shared.mjs"));
54
54
 
55
- function installedVersion() {
56
- const dir = resolveInstallDir();
55
+ function installedVersion(installPath) {
56
+ const dir = installPath ?? resolveInstallDir();
57
57
  if (!dir) return undefined;
58
58
  try {
59
59
  return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
@@ -62,6 +62,18 @@ function installedVersion() {
62
62
  }
63
63
  }
64
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
+
65
77
  function latestVersion() {
66
78
  try {
67
79
  return execFileSync("npm", ["view", PKG, "version"], { encoding: "utf8", timeout: 15000 })
@@ -166,13 +178,14 @@ async function loadDirect(config) {
166
178
  const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
167
179
  const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
168
180
  if (!accounts.some((a) => a && a.direct !== undefined && a.direct !== false)) return undefined;
169
- let dr, ds, rep, run, shim;
181
+ let dr, ds, rep, run, shim, hist;
170
182
  try {
171
183
  dr = await import(resolve(__dirname, "..", "dist", "direct-route.js"));
172
184
  ds = await import(resolve(__dirname, "..", "dist", "direct-sync.js"));
173
185
  rep = await import(resolve(__dirname, "..", "dist", "direct-report.js"));
174
186
  run = await import(resolve(__dirname, "..", "dist", "openclaw-runner.js"));
175
187
  shim = await import(resolve(__dirname, "..", "dist", "shim-core.js"));
188
+ hist = await import(resolve(__dirname, "..", "dist", "history.js"));
176
189
  } catch (err) {
177
190
  console.error(distFailure("direct", "direct-report.js", err));
178
191
  process.exit(1);
@@ -216,6 +229,8 @@ async function loadDirect(config) {
216
229
  sticky,
217
230
  minDwellMs: pool?.minDwellMs,
218
231
  strategy: pool?.strategy,
232
+ readHistory: () => { try { return hist.readHistory(join(stateDir, hist.HISTORY_FILE)); } catch { return []; } },
233
+ spreadWindowMs: pool?.spreadWindowMs,
219
234
  nowMs: Date.now(),
220
235
  ...opts,
221
236
  });
@@ -472,25 +487,46 @@ async function update() {
472
487
  console.error(" ❌ the `openclaw` CLI is not on PATH — install OpenClaw first.");
473
488
  process.exit(1);
474
489
  }
475
- const installed = installedVersion();
490
+ const source = trackedInstallRecord(uc);
491
+ const installed = installedVersion(source?.installPath) ?? source?.version;
476
492
  process.stdout.write(` checking registry… `);
477
493
  const latest = latestVersion();
478
494
  console.log(latest ? `latest is v${latest}` : "unreachable");
479
495
  const banner = uc.formatUpdateBanner({ installed, latest });
480
496
  const action = uc.decideUpdateAction({ installed, latest });
481
497
  console.log(` ${action === "up-to-date" ? "✅" : action === "unknown" ? "⚠️ " : "⬆️ "} ${banner}\n`);
482
- 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");
483
501
  if (action === "unknown") {
484
502
  console.log(" Check your network and try again.");
485
503
  process.exit(1);
486
504
  }
487
- if (!(await askYes(` ${action === "install" ? "Install" : "Update"} now?`))) return;
488
-
489
- console.log(`\n ${DIM}openclaw plugins install ${PKG} --pin --force${RESET}`);
490
- const inst = spawnSync("openclaw", ["plugins", "install", PKG, "--pin", "--force"], {
491
- stdio: "inherit",
492
- });
493
- 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) {
494
530
  console.error("\n ❌ install failed — see output above.");
495
531
  process.exit(1);
496
532
  }
@@ -509,7 +545,8 @@ async function update() {
509
545
  console.log(`\n ⚠ doctor found problems — if it flagged the watchdog, run ${BOLD}npx ${PKG} setup${RESET} to repair it.`);
510
546
  process.exit(doc.status ?? 1);
511
547
  }
512
- 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 ?? "?"}`);
513
550
  }
514
551
 
515
552
  /**
@@ -638,7 +675,29 @@ async function healWatchdogUnit() {
638
675
  }
639
676
 
640
677
  /** `explain` — gather config + live health, render the plain-English view. */
641
- 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
+ }
642
701
  const { readFileSync: rf, existsSync } = await import("node:fs");
643
702
  const { homedir } = await import("node:os");
644
703
  let ec, health, shim;
@@ -703,6 +762,18 @@ async function explain() {
703
762
  /* no sticky state */
704
763
  }
705
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
+ }
706
777
  // v1.9: the direct anthropic/* route, when any account opted in.
707
778
  let direct;
708
779
  const dctx = await loadDirect(config);
@@ -713,9 +784,13 @@ async function explain() {
713
784
  }
714
785
  console.log(`\n${BOLD}🦞 multi-clawd — your setup, in plain English${RESET}\n`);
715
786
  console.log(
716
- 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 }),
717
788
  );
718
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
+ }
719
794
  }
720
795
 
721
796
  /**
@@ -760,7 +835,7 @@ async function usageCommand(args) {
760
835
  const rows = [];
761
836
  const resetById = new Map();
762
837
  for (const a of accounts) {
763
- const row = { id: a.id, label: a.label };
838
+ const row = { id: a.id, label: a.label, ...(a.disabled === true ? { disabled: true, status: "disabled by operator" } : {}) };
764
839
  if (a.oauthTokenFile || a.oauthTokenRef) {
765
840
  row.skipped = "setup-token login — not polled: the usage endpoint needs the user:profile scope, setup-tokens carry only user:inference (stream telemetry only)";
766
841
  } else if (!a.native && !a.configDir) {
@@ -794,15 +869,24 @@ async function usageCommand(args) {
794
869
  }
795
870
  let next;
796
871
  if (pool) {
797
- const verdicts = pool.accounts.map((id) => ({ id, verdict: rows.find((r) => r.id === id)?.verdict ?? "no_data",
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",
798
873
  sevenDayResetsAt: resetById.get(id) }));
799
- if (pool.strategy === "reset-aware") {
874
+ if (pool.strategy === "reset-aware" || pool.strategy === "spread") {
800
875
  const stickySelection = await import(resolve(__dirname, "..", "dist", "sticky.js"));
801
876
  let sticky;
802
877
  try { sticky = JSON.parse(rf(join(stateDir, `pool-${pool.id}.sticky.json`), "utf8")); } catch { /* none */ }
803
- next = stickySelection.decideStickySelection({ verdicts, sticky, nowMs: now, minDwellMs: pool.minDwellMs, strategy: pool.strategy }).account;
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;
804
888
  } else {
805
- next = health.choosePoolAccount(verdicts);
889
+ next = verdicts.length ? health.choosePoolAccount(verdicts) : undefined;
806
890
  }
807
891
  }
808
892
  if (args.includes("--json")) {
@@ -822,7 +906,7 @@ async function usageCommand(args) {
822
906
  };
823
907
  console.log(`\n${BOLD}🦞 multi-clawd — live usage${RESET} ${DIM}(rotate at ${pct(rotateAt)}${weeklyRotateAt !== undefined ? `, weekly ${pct(weeklyRotateAt)}` : ""}, warn at ${pct(warnThreshold)})${RESET}\n`);
824
908
  for (const r of rows) {
825
- console.log(` ${BOLD}${r.id}${RESET}${r.label ? ` ${DIM}— ${r.label}${RESET}` : ""} pool verdict: ${r.verdict}${r.reason ? ` ${DIM}(${r.reason})${RESET}` : ""}`);
909
+ console.log(` ${BOLD}${r.id}${RESET}${r.label ? ` ${DIM}— ${r.label}${RESET}` : ""} pool verdict: ${r.disabled ? "disabled by operator" : r.verdict}${r.reason ? ` ${DIM}(${r.reason})${RESET}` : ""}`);
826
910
  if (r.snapshot) {
827
911
  for (const [w, u] of Object.entries(r.snapshot.windows)) {
828
912
  console.log(` ${mark(u.utilization, w)} ${up.usageWindowLabel(w).padEnd(12)} ${pct(u.utilization).padStart(4)}${until(u.resetsAt)}`);
@@ -838,7 +922,10 @@ async function usageCommand(args) {
838
922
  console.log("");
839
923
  }
840
924
  if (pool) {
841
- console.log(next ? ` next pooled launch (${pool.id}/…) runs on ${BOLD}${next}${RESET}` : ` ${BOLD}every pooled account is exhausted${RESET} — turns degrade or fall through the chain`);
925
+ const allDisabled = pool.accounts.length > 0 && pool.accounts.every((id) => accounts.find((a) => a.id === id)?.disabled === true);
926
+ console.log(next ? ` next pooled launch (${pool.id}/…) runs on ${BOLD}${next}${RESET}` : allDisabled
927
+ ? ` ${BOLD}pool.accounts[].disabled: every pool member is disabled${RESET}`
928
+ : ` ${BOLD}no selectable pool member${RESET}`);
842
929
  }
843
930
  console.log(`\n${DIM}(the gateway polls this every ${Math.round(up.effectiveUsagePollInterval(pool?.usagePoll) / 1000)}s and alerts via the heartbeat; see "Live usage" in the README)${RESET}`);
844
931
  }
@@ -954,8 +1041,27 @@ switch (cmd) {
954
1041
  case "login":
955
1042
  await login();
956
1043
  break;
1044
+ case "account": {
1045
+ const [action, id, ...flags] = rest;
1046
+ if (!["enable", "disable"].includes(action) || !id || flags.some((f) => f !== "--dry-run")) {
1047
+ console.error("usage: multi-clawd account <enable|disable> <id> [--dry-run]");
1048
+ process.exitCode = 1;
1049
+ break;
1050
+ }
1051
+ try {
1052
+ const { changeAccount } = await import("./account-config.mjs");
1053
+ const result = changeAccount(id, action === "disable", flags.includes("--dry-run"));
1054
+ console.log(`${result.dryRun ? "dry run: " : ""}${id}: ${action === "disable" ? "disabled" : "enabled"}${result.changed ? "" : " (already set)"}`);
1055
+ if (result.backup) console.log(`backup: ${result.backup}`);
1056
+ if (!result.dryRun && result.changed) console.log("Takes effect when the plugin next reads config (for example after gateway reload); running turns continue.");
1057
+ } catch (err) {
1058
+ console.error(`account: ${err.message}`);
1059
+ process.exitCode = 1;
1060
+ }
1061
+ break;
1062
+ }
957
1063
  case "explain":
958
- await explain();
1064
+ await explain(rest);
959
1065
  break;
960
1066
  case "chain":
961
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.
@@ -354,6 +441,7 @@ const accounts = pluginConfig.accounts ?? [];
354
441
  const identities = [];
355
442
  if (accounts.length === 0) warn("no accounts configured");
356
443
  for (const account of accounts) {
444
+ if (account.disabled === true) note(`${account.id}: disabled by operator`);
357
445
  // WHO this account is, before WHETHER its credential works. An id is a
358
446
  // label the operator chose; the login is the thing quota is spent against,
359
447
  // and the two drift apart silently (a config dir re-logged-in as the wrong
@@ -494,8 +582,16 @@ console.log("pool");
494
582
  const pool = pluginConfig.pool;
495
583
  if (!pool) warn("no pool configured — direct backends only, no proactive rotation");
496
584
  else {
497
- const members = (pool.accounts ?? []).filter((id) => accounts.some((a) => a.id === id));
498
- if (members.length < 2) bad(`pool "${pool.id ?? "clawd"}" has ${members.length} valid member(s); needs ≥ 2`);
585
+ const configuredMembers = (pool.accounts ?? []).filter((id) => accounts.some((a) => a.id === id));
586
+ const members = configuredMembers.filter((id) => accounts.some((a) => a.id === id && a.disabled !== true));
587
+ const { isModernClaudeModelId } = await importDist("models.js", ["isModernClaudeModelId"], "cli");
588
+ const hasDegradeLadder = (pool.degrade?.ladder ?? []).some(isModernClaudeModelId);
589
+ if (members.length === 0 && configuredMembers.length > 0) bad("pool.accounts[].disabled: every pool member is disabled");
590
+ if (members.length === 1 && configuredMembers.length > 1) {
591
+ warn(`pool "${pool.id ?? "clawd"}" has 1 valid member(s); other configured members are disabled — pool remains available`);
592
+ } else if (members.length === 1 && hasDegradeLadder) {
593
+ note(`pool "${pool.id ?? "clawd"}" has 1 valid member(s) with a degrade ladder; pool remains available`);
594
+ } else if (members.length < 2) bad(`pool "${pool.id ?? "clawd"}" has ${members.length} valid member(s); needs ≥ 2`);
499
595
  else ok(`pool "${pool.id ?? "clawd"}": ${members.join(" → ")}`);
500
596
  const generalThreshold = pool.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
501
597
  const weeklyThreshold = pool.weeklyUtilizationThreshold ?? generalThreshold;
@@ -537,11 +633,14 @@ else {
537
633
  weeklyUtilizationThreshold: pool.weeklyUtilizationThreshold,
538
634
  rotateOnOverage: pool.rotateOnOverage,
539
635
  };
540
- const verdicts = members.map((id) => {
636
+ const rawVerdicts = members.map((id) => {
541
637
  const state = readJson(join(STATE_DIR, `${id}.json`));
542
638
  return { id, verdict: classifyAccountHealth(state, healthOptions, now).verdict,
543
639
  sevenDayResetsAt: pool.strategy === "reset-aware" ? sevenDayReset(state, healthOptions, now) : undefined };
544
640
  });
641
+ const verdicts = pool.strategy === "spread"
642
+ ? withSpreadStats(rawVerdicts, spreadStatsFromHistory(readHistory(join(STATE_DIR, HISTORY_FILE)), now, spreadWindowMs(pool.spreadWindowMs)))
643
+ : rawVerdicts;
545
644
  const decision = decideStickySelection({
546
645
  verdicts,
547
646
  sticky,
@@ -554,7 +653,15 @@ else {
554
653
  identity?.status === "resolved" ? ` (${describeIdentity(identity, { raw: RAW })})` : "";
555
654
  const verdict = verdicts.find((v) => v.id === decision.account)?.verdict ?? "no_data";
556
655
  const home = members[0];
557
- 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) {
558
665
  ok(`serving on ${decision.account}${who} — home account, health ${verdict}`);
559
666
  if (sticky) {
560
667
  note(