@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/README.md +90 -31
- package/SECURITY.md +3 -0
- package/dist/credential-state.js +8 -24
- package/dist/direct-report.js +8 -1
- package/dist/direct-route.js +9 -1
- package/dist/direct-sync.js +8 -1
- package/dist/explain-core.js +49 -8
- package/dist/health-journal.js +123 -0
- package/dist/health.js +49 -2
- package/dist/history.js +103 -0
- package/dist/index.js +31 -35
- package/dist/retry-plan.js +2 -0
- package/dist/shim-core.js +197 -34
- package/dist/shim-lock.js +106 -0
- package/dist/shim.js +122 -73
- package/dist/spread.js +54 -0
- package/dist/sticky.js +2 -0
- package/dist/update-core.js +75 -1
- package/openclaw.plugin.json +12 -3
- package/package.json +1 -1
- package/scripts/cli.mjs +107 -22
- package/scripts/doctor.mjs +104 -5
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
});
|
|
494
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
636
|
+
const rawVerdicts = members.map((id) => {
|
|
550
637
|
const state = readJson(join(STATE_DIR, `${id}.json`));
|
|
551
|
-
|
|
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 (
|
|
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(
|