@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.
- package/README.md +121 -31
- package/SECURITY.md +3 -0
- package/dist/credential-state.js +8 -24
- package/dist/direct-report.js +12 -1
- package/dist/direct-route.js +12 -6
- package/dist/direct-sync.js +12 -4
- package/dist/explain-core.js +33 -10
- package/dist/health-journal.js +123 -0
- package/dist/health.js +13 -1
- package/dist/history.js +103 -0
- package/dist/index.js +89 -38
- package/dist/retry-plan.js +2 -0
- package/dist/session-affinity.js +108 -0
- package/dist/shim-core.js +197 -34
- package/dist/shim-lock.js +106 -0
- package/dist/shim.js +170 -79
- package/dist/spread.js +54 -0
- package/dist/sticky.js +2 -0
- package/dist/update-core.js +75 -1
- package/openclaw.plugin.json +26 -3
- package/package.json +1 -1
- package/scripts/account-config.mjs +115 -0
- package/scripts/cli.mjs +131 -25
- package/scripts/doctor.mjs +113 -6
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
});
|
|
493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
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.
|
|
@@ -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
|
|
498
|
-
|
|
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
|
|
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 (
|
|
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(
|