@drakon-systems/multi-clawd 1.2.3 → 1.3.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 +2 -1
- package/dist/explain-core.js +97 -0
- package/package.json +1 -1
- package/scripts/cli.mjs +69 -0
package/README.md
CHANGED
|
@@ -171,7 +171,8 @@ npx @drakon-systems/multi-clawd update
|
|
|
171
171
|
One command: checks the registry, installs the new version with the right
|
|
172
172
|
flags, offers the gateway restart, and finishes with a doctor health check.
|
|
173
173
|
(`npm i -g @drakon-systems/multi-clawd` once, and it's just `multi-clawd
|
|
174
|
-
update` — with `multi-clawd setup
|
|
174
|
+
update` — with `multi-clawd setup`, `multi-clawd explain` (your setup in
|
|
175
|
+
plain English), and `multi-clawd doctor` alongside.)
|
|
175
176
|
|
|
176
177
|
```bash
|
|
177
178
|
# From source:
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export function describeAccount(acc) {
|
|
2
|
+
if (acc.native) {
|
|
3
|
+
return "the machine's main `claude` login (default config dir; OS keychain on macOS)";
|
|
4
|
+
}
|
|
5
|
+
const parts = [];
|
|
6
|
+
if (acc.configDir)
|
|
7
|
+
parts.push(`its own isolated login dir: ${acc.configDir}`);
|
|
8
|
+
if (acc.oauthTokenRef) {
|
|
9
|
+
parts.push(`token resolved from ${acc.oauthTokenRef.provider ?? "a secret provider"} via a secret reference (never stored in plain text)`);
|
|
10
|
+
}
|
|
11
|
+
else if (acc.oauthTokenFile) {
|
|
12
|
+
parts.push(`token file at ${acc.oauthTokenFile}`);
|
|
13
|
+
}
|
|
14
|
+
else if (acc.configDir) {
|
|
15
|
+
parts.push(`uses the login stored inside that dir`);
|
|
16
|
+
}
|
|
17
|
+
return parts.join("; ") || "no credential source configured";
|
|
18
|
+
}
|
|
19
|
+
export function annotateChainRef(ref, pool) {
|
|
20
|
+
const slash = ref.indexOf("/");
|
|
21
|
+
const provider = slash > 0 ? ref.slice(0, slash) : undefined;
|
|
22
|
+
if (pool && provider === pool.id) {
|
|
23
|
+
const order = pool.accounts.join(", then ");
|
|
24
|
+
return `pool → ${order} (same model, next account before any tier drop)`;
|
|
25
|
+
}
|
|
26
|
+
if (!pool && provider && /^claw/.test(provider)) {
|
|
27
|
+
return `no pool configured — runs on the single account "${provider}"`;
|
|
28
|
+
}
|
|
29
|
+
if (provider && /^claw\d+$/.test(provider)) {
|
|
30
|
+
return `pinned to only ${provider} — no cross-account failover on this rung`;
|
|
31
|
+
}
|
|
32
|
+
if (provider === "anthropic" || provider === "claude-cli") {
|
|
33
|
+
return "direct to Anthropic — bypasses the pool (no cross-account failover)";
|
|
34
|
+
}
|
|
35
|
+
if (provider && provider.startsWith("claw")) {
|
|
36
|
+
return `runs on "${provider}"`;
|
|
37
|
+
}
|
|
38
|
+
return "leaves Claude — a different provider entirely";
|
|
39
|
+
}
|
|
40
|
+
const VERDICT_WORDS = {
|
|
41
|
+
ok: "OK — ready to serve",
|
|
42
|
+
no_data: "no recent telemetry — treated as healthy",
|
|
43
|
+
near_limit: "NEAR ITS LIMIT — the pool will hand over before it hard-fails",
|
|
44
|
+
exhausted: "EXHAUSTED",
|
|
45
|
+
};
|
|
46
|
+
export function renderExplanation(model) {
|
|
47
|
+
const lines = [];
|
|
48
|
+
lines.push("ACCOUNTS");
|
|
49
|
+
for (const acc of model.accounts) {
|
|
50
|
+
lines.push(` ${acc.id}${acc.label ? ` "${acc.label}"` : ""}`);
|
|
51
|
+
lines.push(` → ${describeAccount(acc)}`);
|
|
52
|
+
}
|
|
53
|
+
lines.push("");
|
|
54
|
+
if (model.pool) {
|
|
55
|
+
const pct = Math.round((model.pool.utilizationThreshold ?? 0.85) * 100);
|
|
56
|
+
lines.push(`POOL ${model.pool.id} (${model.pool.accounts.join(" → ")})`);
|
|
57
|
+
lines.push(` Every Claude launch runs on the first account that is NOT nearly maxed`);
|
|
58
|
+
lines.push(` out — hand-over at ${pct}% of any rate window, home account reclaims`);
|
|
59
|
+
lines.push(` automatically once its window resets.`);
|
|
60
|
+
const ladder = model.pool.degrade?.ladder ?? [];
|
|
61
|
+
if (ladder.length > 0) {
|
|
62
|
+
lines.push(` If the WHOLE pool is exhausted: step down to ${ladder.join(" → ")} first.`);
|
|
63
|
+
}
|
|
64
|
+
if ((model.pool.degrade?.pins?.length ?? 0) > 0) {
|
|
65
|
+
lines.push(` ${model.pool.degrade?.pins?.length} pinned lane(s) never tier-drop.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
lines.push("POOL (no pool configured — each account is a standalone backend)");
|
|
70
|
+
}
|
|
71
|
+
lines.push("");
|
|
72
|
+
if (model.chain?.primary || model.chain?.fallbacks?.length) {
|
|
73
|
+
lines.push("FAILOVER CHAIN (agents.defaults)");
|
|
74
|
+
const rungs = [model.chain.primary, ...(model.chain.fallbacks ?? [])].filter((r) => typeof r === "string");
|
|
75
|
+
rungs.forEach((ref, i) => {
|
|
76
|
+
lines.push(` ${i + 1}. ${ref}`);
|
|
77
|
+
lines.push(` ${annotateChainRef(ref, model.pool)}`);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
lines.push("FAILOVER CHAIN (none found under agents.defaults)");
|
|
82
|
+
}
|
|
83
|
+
lines.push("");
|
|
84
|
+
if (model.health.length > 0) {
|
|
85
|
+
lines.push("RIGHT NOW");
|
|
86
|
+
for (const h of model.health) {
|
|
87
|
+
const word = VERDICT_WORDS[h.verdict] ?? h.verdict;
|
|
88
|
+
lines.push(` ${h.id}: ${word}${h.detail ? ` — ${h.detail}` : ""}`);
|
|
89
|
+
}
|
|
90
|
+
if (model.pool) {
|
|
91
|
+
lines.push(model.stickyAccount
|
|
92
|
+
? ` pool is rotated onto ${model.stickyAccount} (returns home when the home window resets)`
|
|
93
|
+
: ` pool is on its home account (no rotation active)`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Multi-account Claude Code failover for OpenClaw — register additional Claude (Max/Pro) logins as first-class CLI backends and keep the full skills/MCP harness across every account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/scripts/cli.mjs
CHANGED
|
@@ -33,6 +33,7 @@ function usage() {
|
|
|
33
33
|
${BOLD}🦞 multi-clawd${RESET} — multi-account Claude failover for OpenClaw
|
|
34
34
|
|
|
35
35
|
${BOLD}setup${RESET} guided setup wizard (accounts, pool, watchdog)
|
|
36
|
+
${BOLD}explain${RESET} your setup in plain English — accounts, pool, fallback chain
|
|
36
37
|
${BOLD}update${RESET} update the plugin to the latest version
|
|
37
38
|
${BOLD}doctor${RESET} health check (add --probe for a live turn)
|
|
38
39
|
${BOLD}version${RESET} show CLI + installed plugin versions
|
|
@@ -204,10 +205,78 @@ async function healWatchdogUnit() {
|
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
/** `explain` — gather config + live health, render the plain-English view. */
|
|
209
|
+
async function explain() {
|
|
210
|
+
const { readFileSync: rf, existsSync } = await import("node:fs");
|
|
211
|
+
const { homedir } = await import("node:os");
|
|
212
|
+
let ec, health, shim;
|
|
213
|
+
try {
|
|
214
|
+
ec = await import(resolve(__dirname, "..", "dist", "explain-core.js"));
|
|
215
|
+
health = await import(resolve(__dirname, "..", "dist", "health.js"));
|
|
216
|
+
shim = await import(resolve(__dirname, "..", "dist", "shim-core.js"));
|
|
217
|
+
} catch {
|
|
218
|
+
console.error("explain: built dist/ is missing — reinstall the package.");
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
let config = {};
|
|
222
|
+
try {
|
|
223
|
+
config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
|
|
224
|
+
} catch {
|
|
225
|
+
console.error("explain: could not read ~/.openclaw/openclaw.json");
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
|
|
229
|
+
const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
|
|
230
|
+
const pool = pc.pool
|
|
231
|
+
? { ...pc.pool, id: pc.pool.id?.trim() || "clawd", accounts: pc.pool.accounts ?? [] }
|
|
232
|
+
: undefined;
|
|
233
|
+
const chain = config?.agents?.defaults?.model;
|
|
234
|
+
const stateDir = join(homedir(), ".openclaw", "state", "multi-clawd");
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
const rel = (ms) => {
|
|
237
|
+
const m = Math.round((ms - now) / 60000);
|
|
238
|
+
return m >= 90 ? `~${Math.round(m / 60)}h` : `~${m}m`;
|
|
239
|
+
};
|
|
240
|
+
const healthRows = accounts.map((a) => {
|
|
241
|
+
let state;
|
|
242
|
+
try {
|
|
243
|
+
state = shim.parseStoredState(rf(join(stateDir, `${a.id}.json`), "utf8"));
|
|
244
|
+
} catch {
|
|
245
|
+
/* no telemetry yet */
|
|
246
|
+
}
|
|
247
|
+
const h = health.classifyAccountHealth(state, {
|
|
248
|
+
utilizationThreshold: pool?.utilizationThreshold,
|
|
249
|
+
staleAfterMs: pool?.staleAfterMs,
|
|
250
|
+
}, now);
|
|
251
|
+
let detail = h.reason;
|
|
252
|
+
if (h.verdict === "exhausted" && h.resumeAt) {
|
|
253
|
+
detail = `${h.reason ?? "limit hit"} — back in ${rel(h.resumeAt)}`;
|
|
254
|
+
}
|
|
255
|
+
return { id: a.id, verdict: h.verdict, detail };
|
|
256
|
+
});
|
|
257
|
+
let stickyAccount;
|
|
258
|
+
if (pool) {
|
|
259
|
+
try {
|
|
260
|
+
const sticky = JSON.parse(rf(join(stateDir, `pool-${pool.id}.sticky.json`), "utf8"));
|
|
261
|
+
if (sticky?.account && sticky.account !== pool.accounts[0]) stickyAccount = sticky.account;
|
|
262
|
+
} catch {
|
|
263
|
+
/* no sticky state */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
console.log(`\n${BOLD}🦞 multi-clawd — your setup, in plain English${RESET}\n`);
|
|
267
|
+
console.log(
|
|
268
|
+
ec.renderExplanation({ accounts, pool, chain, health: healthRows, stickyAccount }),
|
|
269
|
+
);
|
|
270
|
+
console.log(`\n${DIM}(health checks: multi-clawd doctor · change things: multi-clawd setup)${RESET}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
207
273
|
switch (cmd) {
|
|
208
274
|
case "setup":
|
|
209
275
|
runSibling("setup.mjs", rest);
|
|
210
276
|
break;
|
|
277
|
+
case "explain":
|
|
278
|
+
await explain();
|
|
279
|
+
break;
|
|
211
280
|
case "doctor":
|
|
212
281
|
runSibling("doctor.mjs", rest);
|
|
213
282
|
break;
|