@drakon-systems/multi-clawd 1.2.2 → 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 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` and `multi-clawd doctor` alongside.)
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
+ }
@@ -138,3 +138,22 @@ export function mergeSetupIntoConfig(existing, plan) {
138
138
  }
139
139
  return { config, changes };
140
140
  }
141
+ export function existingAccountDefaults(config, id) {
142
+ const entries = asRecord(asRecord(asRecord(config)?.plugins)?.entries);
143
+ const entryConfig = asRecord(asRecord(entries?.["multi-clawd"])?.config);
144
+ const accounts = Array.isArray(entryConfig?.accounts) ? entryConfig?.accounts : [];
145
+ const acc = accounts.map(asRecord).find((a) => a?.id === id);
146
+ if (!acc)
147
+ return undefined;
148
+ return {
149
+ configDir: typeof acc.configDir === "string" ? acc.configDir : undefined,
150
+ label: typeof acc.label === "string" ? acc.label : undefined,
151
+ hasCredentials: acc.native === true ||
152
+ acc.oauthTokenRef !== undefined ||
153
+ acc.oauthTokenFile !== undefined ||
154
+ typeof acc.configDir === "string",
155
+ };
156
+ }
157
+ export function looksLikeSecretRef(id) {
158
+ return id.includes("://");
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "1.2.2",
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;
package/scripts/setup.mjs CHANGED
@@ -41,7 +41,7 @@ try {
41
41
  console.error("setup: built dist/ modules are missing — run `npm run build` first (source checkout) or reinstall the plugin.");
42
42
  process.exit(1);
43
43
  }
44
- const { buildMainAccount, buildSecondAccount, buildPool, validateSecondConfigDir, planFromExisting, mergeSetupIntoConfig } = core;
44
+ const { buildMainAccount, buildSecondAccount, buildPool, validateSecondConfigDir, planFromExisting, mergeSetupIntoConfig, existingAccountDefaults, looksLikeSecretRef } = core;
45
45
 
46
46
  // Line-queued prompts: interactive AND pipe-safe. With piped stdin, readline
47
47
  // emits every buffered line immediately — a plain question() would capture one
@@ -128,9 +128,29 @@ if (await yes("Add your MAIN account (the machine's existing `claude` login) to
128
128
  // ── second account ───────────────────────────────────────────────────────────
129
129
  if (await yes("Set up a SECOND Claude account (its own isolated config dir)?")) {
130
130
  const id = await ask(" id for the second account:", "claw2");
131
+ // Existing-aware: this account may already be fully configured. Pressing
132
+ // Enter through prompts must NEVER overwrite a working account, so the
133
+ // default here is to keep it exactly as it is.
134
+ const prior = existingAccountDefaults(existing, id);
135
+ if (prior?.hasCredentials) {
136
+ console.log(
137
+ ` "${id}" is already configured${prior.label ? ` (${prior.label}` : " ("}${prior.configDir ? `, dir ${prior.configDir})` : ")"}.`,
138
+ );
139
+ if (await yes(" Keep its existing credentials and config unchanged?", true)) {
140
+ accounts.push({ id });
141
+ console.log(" ✅ keeping as-is");
142
+ } else {
143
+ await secondAccountFlow(id, prior);
144
+ }
145
+ } else {
146
+ await secondAccountFlow(id, prior);
147
+ }
148
+ }
149
+
150
+ async function secondAccountFlow(id, prior) {
131
151
  let configDir;
132
152
  for (;;) {
133
- configDir = await ask(" isolated config dir:", `~/.${id}`);
153
+ configDir = await ask(" isolated config dir:", prior?.configDir ?? `~/.${id}`);
134
154
  const err = validateSecondConfigDir(configDir);
135
155
  if (!err) break;
136
156
  console.log(` ✗ ${err}`);
@@ -158,12 +178,19 @@ if (await yes("Set up a SECOND Claude account (its own isolated config dir)?"))
158
178
  let refId;
159
179
  for (;;) {
160
180
  refId = await ask(" secret reference (e.g. op://Vault/Item/field):");
161
- if (refId) break;
162
- if (stdinClosed) {
163
- console.error("setup: a secret reference is required for token source 1 — aborting (nothing written).");
164
- process.exit(1);
181
+ if (!refId) {
182
+ if (stdinClosed) {
183
+ console.error("setup: a secret reference is required for token source 1 — aborting (nothing written).");
184
+ process.exit(1);
185
+ }
186
+ console.log(" ✗ the reference is required (it is NOT the token itself — just the pointer to it; answer 3 above for no token)");
187
+ continue;
188
+ }
189
+ if (!looksLikeSecretRef(refId)) {
190
+ console.log(` ⚠ "${refId}" doesn't look like a secret reference (expected something URI-like, e.g. op://Vault/Item/field)`);
191
+ if (!(await yes(" Use it anyway?", false))) continue;
165
192
  }
166
- console.log(" ✗ the reference is required (it is NOT the token itself — just the pointer to it)");
193
+ break;
167
194
  }
168
195
  tokenSource = { kind: "ref", ref: { source: "exec", provider, id: refId } };
169
196
  } else if (choice === "2") {
@@ -171,7 +198,7 @@ if (await yes("Set up a SECOND Claude account (its own isolated config dir)?"))
171
198
  } else {
172
199
  tokenSource = { kind: "dir-login" };
173
200
  }
174
- accounts.push(buildSecondAccount({ id, label: await ask(" label:", "Second Claude"), configDir, tokenSource }));
201
+ accounts.push(buildSecondAccount({ id, label: await ask(" label:", prior?.label ?? "Second Claude"), configDir, tokenSource }));
175
202
  }
176
203
 
177
204
  if (accounts.length === 0 && state.accountIds.length === 0) {