@drakon-systems/multi-clawd 1.2.3 → 1.3.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 CHANGED
@@ -11,7 +11,7 @@ same model, next account, full harness on every hop.
11
11
 
12
12
  [![OpenClaw plugin](https://img.shields.io/badge/OpenClaw-plugin-ff4f00)](https://docs.openclaw.ai/plugins)
13
13
  [![npm](https://img.shields.io/badge/npm-%40drakon--systems%2Fmulti--clawd-cb3837)](https://www.npmjs.com/package/@drakon-systems/multi-clawd)
14
- [![version](https://img.shields.io/badge/version-1.0.1-4c9aff)](CHANGELOG.md)
14
+ [![version](https://img.shields.io/npm/v/%40drakon-systems%2Fmulti-clawd?color=4c9aff&label=version)](CHANGELOG.md)
15
15
  [![license: MIT](https://img.shields.io/badge/license-MIT-2ea44f)](LICENSE)
16
16
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](tsconfig.json)
17
17
 
@@ -21,6 +21,37 @@ same model, next account, full harness on every hop.
21
21
 
22
22
  ---
23
23
 
24
+ ## Quick start
25
+
26
+ ```bash
27
+ npm i -g @drakon-systems/multi-clawd # the CLI (once)
28
+
29
+ multi-clawd update # install (or update) the OpenClaw plugin — right flags, restart, doctor
30
+ multi-clawd setup # guided wizard: accounts, isolated second login, pool, watchdog
31
+ multi-clawd explain # your whole setup in plain English — accounts, chain, live health
32
+ multi-clawd doctor # health check (add --probe for a live end-to-end turn)
33
+ ```
34
+
35
+ That's the entire lifecycle. `update` installs the plugin when it's missing and
36
+ upgrades it when it's not — nobody types registry flags. `setup` walks you
37
+ through the whole shape below and merges config **non-destructively** (backup
38
+ first, existing accounts never overwritten, re-runs are no-ops). Prefer not to
39
+ install anything? Every command also runs as
40
+ `npx @drakon-systems/multi-clawd <command>`.
41
+
42
+ **The 3-step picture** `setup` walks you through:
43
+
44
+ 1. **Main account** — your existing `claude` login, used as-is (nothing to do).
45
+ 2. **Second account** — lives in its **own isolated config dir** (a separate
46
+ Claude "app", e.g. `~/.claw2`), so the two logins can never clobber each
47
+ other; token via a secret-manager reference (recommended), token file, or
48
+ the dir's own login.
49
+ 3. **The pool** — one backend id (`clawd/…`) fronting both: every launch runs
50
+ on the first account that isn't nearly maxed out, and your fallback chain
51
+ routes through it.
52
+
53
+ Then `multi-clawd explain` shows you exactly what you built.
54
+
24
55
  ## Why
25
56
 
26
57
  OpenClaw's bundled `claude-cli` backend runs Claude Code on a **single**
@@ -113,20 +144,24 @@ Code backend runs.
113
144
 
114
145
  ## Install
115
146
 
116
- **From npm (recommended):**
147
+ **The CLI does it all (recommended)** — see [Quick start](#quick-start):
148
+
149
+ ```bash
150
+ npm i -g @drakon-systems/multi-clawd && multi-clawd update
151
+ ```
152
+
153
+ `update` runs the registry install with the right flags, offers the gateway
154
+ restart, and finishes with a doctor health check. Prefer the raw form?
117
155
 
118
156
  ```bash
119
157
  openclaw plugins install @drakon-systems/multi-clawd --pin
120
158
  openclaw gateway restart
121
159
  ```
122
160
 
123
- The gateway pulls the prebuilt package — no clone, no build step, nothing to
124
- keep in sync. `--pin` records the exact resolved version, so an upgrade is a
125
- deliberate `@latest`, never a surprise. `openclaw` itself is a *peer*
126
- dependency (the host provides it), so the install stays lean. Confirm with
127
- `openclaw plugins list` (expect `multi-clawd` enabled) — it also shows the
128
- install path; run the doctor from there:
129
- `node <install-path>/scripts/doctor.mjs`.
161
+ Either way the gateway pulls the prebuilt package — no clone, no build step,
162
+ nothing to keep in sync. `--pin` records the exact resolved version, so an
163
+ upgrade is a deliberate act, never a surprise. `openclaw` itself is a *peer*
164
+ dependency (the host provides it), so the install stays lean.
130
165
 
131
166
  **From ClawHub (alternative registry):**
132
167
 
@@ -171,7 +206,8 @@ npx @drakon-systems/multi-clawd update
171
206
  One command: checks the registry, installs the new version with the right
172
207
  flags, offers the gateway restart, and finishes with a doctor health check.
173
208
  (`npm i -g @drakon-systems/multi-clawd` once, and it's just `multi-clawd
174
- update` — with `multi-clawd setup` and `multi-clawd doctor` alongside.)
209
+ update` — with `multi-clawd setup`, `multi-clawd explain` (your setup in
210
+ plain English), and `multi-clawd doctor` alongside.)
175
211
 
176
212
  ```bash
177
213
  # 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.2.3",
3
+ "version": "1.3.1",
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;