@drakon-systems/multi-clawd 2.5.1 → 2.6.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
@@ -31,6 +31,7 @@ multi-clawd setup # guided wizard: accounts, isolated second login, pool, wa
31
31
  multi-clawd login claw2 # launch the right Claude sign-in for an account (or re-auth it)
32
32
  multi-clawd explain # your whole setup in plain English — accounts, chain, live health
33
33
  multi-clawd explain --history --since 7d # per-account launches, retries, rotations
34
+ multi-clawd explain --json # live health plus a rough weekly pace estimate, machine-readable
34
35
  multi-clawd account disable claw2 --dry-run # preview benching an account
35
36
  multi-clawd chain # audit your model routing — what actually serves each turn
36
37
  multi-clawd direct # the direct anthropic/* route: status, or `direct sync` (v1.9, optional)
@@ -415,7 +416,7 @@ openclaw plugins install (Get-Location).Path
415
416
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
416
417
  Code on the target machine already? Paste it this and go make coffee:
417
418
 
418
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.5.1/SETUP-AGENT.md
419
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.6.1/SETUP-AGENT.md
419
420
  > and follow it to set up multi-clawd on this machine. I own a second
420
421
  > Claude account — ask me when you need me to log in.
421
422
 
@@ -30,6 +30,49 @@ export function renderUsageLine(usage, nowMs) {
30
30
  })
31
31
  .join(" · ");
32
32
  }
33
+ const WEEK_MS = 7 * 86_400_000;
34
+ const MIN_PACE_ELAPSED_MS = WEEK_MS / 10;
35
+ const PACE_RESET_SLACK_MS = 3_600_000;
36
+ export function computeWeeklyPace(usage, nowMs) {
37
+ const u = ["usage:seven_day", "seven_day"]
38
+ .map((w) => usage?.find((x) => x.window === w))
39
+ .find((x) => x !== undefined);
40
+ if (!u || u.resetsAt === undefined)
41
+ return undefined;
42
+ if (!Number.isFinite(u.utilization) || u.utilization < 0 || u.utilization > 1)
43
+ return undefined;
44
+ if (!Number.isFinite(u.resetsAt) || !Number.isFinite(nowMs))
45
+ return undefined;
46
+ const remaining = u.resetsAt - nowMs;
47
+ if (remaining <= 0 || remaining > WEEK_MS + PACE_RESET_SLACK_MS)
48
+ return undefined;
49
+ const elapsed = Math.max(0, WEEK_MS - remaining);
50
+ if (elapsed < MIN_PACE_ELAPSED_MS)
51
+ return undefined;
52
+ const expectedPct = elapsed / WEEK_MS;
53
+ const projectedExhaustionAt = u.utilization >= 1
54
+ ? nowMs
55
+ : u.utilization > 0 ? nowMs + ((1 - u.utilization) * elapsed) / u.utilization : undefined;
56
+ return {
57
+ window: u.window,
58
+ expectedPct,
59
+ aheadOfPace: u.utilization - expectedPct,
60
+ projectedExhaustionAt,
61
+ willLastToReset: projectedExhaustionAt === undefined || projectedExhaustionAt >= u.resetsAt,
62
+ resetsAt: u.resetsAt,
63
+ estimate: true,
64
+ };
65
+ }
66
+ export function renderPaceLine(pace, nowMs) {
67
+ if (!pace)
68
+ return undefined;
69
+ const pts = Math.round(pace.aheadOfPace * 100);
70
+ const where = pts === 0 ? "on pace" : pts > 0 ? `${pts} points ahead of pace` : `${-pts} points behind pace`;
71
+ const verdict = pace.willLastToReset
72
+ ? "should last to the reset"
73
+ : `could run out in ${relativeUntil(pace.projectedExhaustionAt ?? nowMs, nowMs)}, before the reset in ${relativeUntil(pace.resetsAt, nowMs)}`;
74
+ return `weekly pace (rough estimate): ~${Math.round(pace.expectedPct * 100)}% of the week gone, ${where}; ${verdict}`;
75
+ }
33
76
  export function describeAccount(acc) {
34
77
  if (acc.native) {
35
78
  return "the machine's main `claude` login (default config dir; OS keychain on macOS)";
@@ -145,6 +188,9 @@ export function renderExplanation(model) {
145
188
  const word = VERDICT_WORDS[h.verdict] ?? h.verdict;
146
189
  lines.push(` ${h.id}: ${model.accounts.find((a) => a.id === h.id)?.disabled ? "disabled by operator" : word}${h.detail ? ` — ${h.detail}` : ""}`);
147
190
  lines.push(` usage: ${h.usage?.length ? renderUsageLine(h.usage, nowMs) : "no live telemetry"}`);
191
+ const paceLine = renderPaceLine(computeWeeklyPace(h.usage, nowMs), nowMs);
192
+ if (paceLine)
193
+ lines.push(` ${paceLine}`);
148
194
  }
149
195
  if (model.pool) {
150
196
  const strategy = poolStrategy(model.pool.strategy);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "2.5.1",
4
+ "version": "2.6.1",
5
5
  "description": "Register additional Claude Code logins (Max/Pro accounts) as first-class OpenClaw CLI backends for cross-account failover, keeping the full skills/MCP harness on every account.",
6
6
  "cliBackends": [
7
7
  "claw1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "2.5.1",
3
+ "version": "2.6.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. Also imports those accounts' setup tokens into Hermes Agent's Anthropic credential pool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { existsSync, readdirSync, statSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
- import { join } from "node:path";
12
+ import { dirname, join } from "node:path";
13
13
 
14
14
  /** Where the plugin is installed: path install wins, else newest npm-project install. */
15
15
  export function resolveInstallDir() {
@@ -50,3 +50,57 @@ export const WATCHDOG_LAUNCHER = join(
50
50
  "multi-clawd",
51
51
  "watchdog-launcher.mjs",
52
52
  );
53
+
54
+ /** The default OpenClaw config path, resolved for display (never a literal `~`). */
55
+ export function openclawConfigPath() {
56
+ return join(homedir(), ".openclaw", "openclaw.json");
57
+ }
58
+
59
+ /**
60
+ * One diagnostic for every command that needs the OpenClaw config (#106).
61
+ *
62
+ * `error` is whatever reading or parsing threw. The message names the resolved
63
+ * path and the next step, and keeps the real causes apart: the file is missing
64
+ * (and whether OpenClaw itself looks installed), it is unreadable for this
65
+ * user, it is not a regular file, or it is present but not valid JSON. It never
66
+ * repeats raw Node error text: an fs message is noise to a first-time user, and
67
+ * a JSON SyntaxError quotes a slice of the file, which may hold credentials.
68
+ *
69
+ * A `configPath` other than the default (hermes `--config`) gets a "check the
70
+ * path" step instead of `multi-clawd setup`, which only writes the default;
71
+ * that split applies to the missing and the not-a-regular-file cases alike.
72
+ */
73
+ export function describeOpenclawConfigError(error, configPath = openclawConfigPath()) {
74
+ const code = typeof error?.code === "string" ? error.code : undefined;
75
+ const isDefault = configPath === openclawConfigPath();
76
+ const at = `OpenClaw config at ${configPath}`;
77
+ if (code === "ENOENT") {
78
+ const dir = dirname(configPath);
79
+ if (isDefault && !existsSync(dir)) {
80
+ return `OpenClaw config not found at ${configPath} (no ${dir} directory). ` +
81
+ "Install and start OpenClaw first, then run `multi-clawd setup`.";
82
+ }
83
+ return `OpenClaw config not found at ${configPath}. ` + (isDefault
84
+ ? "Run `multi-clawd setup` to create it."
85
+ : "Check the --config path, or omit it to use the default location.");
86
+ }
87
+ if (code === "EACCES" || code === "EPERM") {
88
+ return `${at} is not readable: permission denied. Run multi-clawd as the user that ` +
89
+ "owns the OpenClaw install, or fix the file's ownership and mode; `multi-clawd setup` cannot fix this.";
90
+ }
91
+ const notRegular = { EISDIR: "it is a directory", ELOOP: "it is a symbolic-link loop",
92
+ ENOTDIR: "a parent in the path is not a directory" }[code];
93
+ if (notRegular) {
94
+ // `multi-clawd setup` writes only the default path, so for a custom
95
+ // `--config` the fix is the named path (or the option), never setup.
96
+ return `${at} is not a regular file (${notRegular}). ` + (isDefault
97
+ ? "Move whatever is at that path aside, then run `multi-clawd setup` or restore the OpenClaw config file."
98
+ : "Restore the OpenClaw config file at that path, or correct the --config path (omit it to use the " +
99
+ "default location); `multi-clawd setup` only writes the default path.");
100
+ }
101
+ if (error instanceof SyntaxError) {
102
+ return `${at} is malformed JSON. Fix the file by hand (a JSON validator helps) and retry; ` +
103
+ "`multi-clawd setup` does not repair it.";
104
+ }
105
+ return `${at} could not be read${code ? ` (${code})` : ""}.`;
106
+ }
package/scripts/cli.mjs CHANGED
@@ -50,6 +50,27 @@ Run via npx (${DIM}npx ${PKG} <command>${RESET}) or install globally
50
50
  `);
51
51
  }
52
52
 
53
+ // Per-command usage, printed for `<command> --help` / `-h` before anything is
54
+ // dispatched, so asking for help never reads config, runs checks, or starts the
55
+ // setup wizard. `hermes` is absent on purpose: it handles its own help.
56
+ const COMMAND_HELP = {
57
+ setup: "multi-clawd setup [--dry-run]\n\n Guided setup wizard (accounts, pool, watchdog).\n --dry-run preview the changes without writing anything",
58
+ login: "multi-clawd login [<id>]\n\n Log a configured account in (or re-auth it). Without <id>, lists the\n configured accounts to choose from.",
59
+ account: "multi-clawd account <enable|disable> <id> [--dry-run]\n\n Disable or enable a pool account.\n --dry-run preview the change without writing it",
60
+ explain: "multi-clawd explain [--json | --history [--since 7d|24h]]\n\n Your setup in plain English: accounts, pool, fallback chain.\n --json machine-readable output\n --history recent pool events; --since limits the window",
61
+ chain: "multi-clawd chain [--raw]\n\n Audit your model routing: what actually serves each turn.\n --raw include raw routing detail",
62
+ direct: "multi-clawd direct [status | sync [--dry-run] [--yes] [--resync]]\n\n The direct anthropic/* route. Default is status.\n sync store profiles for the direct route\n --dry-run preview without writing\n --yes skip the confirmation prompt\n --resync refresh profiles that are already stored",
63
+ usage: "multi-clawd usage [--json]\n\n Live usage per account from the provider: what is left, and what the\n pool will do.\n --json machine-readable output",
64
+ update: "multi-clawd update\n\n Update the plugin to the latest version.",
65
+ doctor: "multi-clawd doctor [--probe] [--probe-pool] [--verbose] [--raw]\n\n Health check.\n --probe run a live turn per account plus the pool\n --probe-pool with --probe: probe the pool ref only (one turn)\n --verbose show more detail\n --raw show raw output",
66
+ version: "multi-clawd version\n\n Show CLI and installed plugin versions.",
67
+ };
68
+
69
+ if (Object.hasOwn(COMMAND_HELP, cmd) && rest.some((a) => a === "--help" || a === "-h")) {
70
+ console.log(`\n${COMMAND_HELP[cmd]}\n`);
71
+ process.exit(0);
72
+ }
73
+
53
74
  const { resolveInstallDir } = await import(join(__dirname, "_shared.mjs"));
54
75
 
55
76
  function installedVersion(installPath) {
@@ -168,6 +189,23 @@ function readOpenclawConfigSync() {
168
189
  }
169
190
  }
170
191
 
192
+ /**
193
+ * The OpenClaw config for a command that cannot run without it (#106). On any
194
+ * read or parse failure, prints the one shared diagnostic — resolved path,
195
+ * what is wrong, next step — on stderr and exits 1. Read-only: never creates
196
+ * or repairs the file.
197
+ */
198
+ async function requireOpenclawConfig(command) {
199
+ const { describeOpenclawConfigError, openclawConfigPath } = await import(join(__dirname, "_shared.mjs"));
200
+ const path = openclawConfigPath();
201
+ try {
202
+ return JSON.parse(readFileSync(path, "utf8"));
203
+ } catch (err) {
204
+ console.error(`${command}: ${describeOpenclawConfigError(err, path)}`);
205
+ process.exit(1);
206
+ }
207
+ }
208
+
171
209
  /**
172
210
  * Everything the direct-route surfaces need, loaded from dist. None of these
173
211
  * modules import the `openclaw` peer, so they load from a global install.
@@ -247,11 +285,7 @@ async function loadDirect(config) {
247
285
  async function direct(args = []) {
248
286
  const { readFileSync: rf, mkdtempSync, writeFileSync, rmSync, statSync } = await import("node:fs");
249
287
  const { homedir, tmpdir } = await import("node:os");
250
- const config = readOpenclawConfigSync();
251
- if (!config) {
252
- console.error("direct: could not read your OpenClaw config");
253
- process.exit(1);
254
- }
288
+ const config = await requireOpenclawConfig("direct");
255
289
  const ctx = await loadDirect(config);
256
290
  const sub = args.find((a) => !a.startsWith("--")) ?? "status";
257
291
  console.log(`\n${BOLD}🦞 multi-clawd — direct anthropic/* route${RESET}\n`);
@@ -376,13 +410,7 @@ async function chain(args = []) {
376
410
  process.exit(1);
377
411
  }
378
412
 
379
- let config;
380
- try {
381
- config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
382
- } catch {
383
- console.error("chain: could not read ~/.openclaw/openclaw.json");
384
- process.exit(1);
385
- }
413
+ const config = await requireOpenclawConfig("chain");
386
414
 
387
415
  const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
388
416
  const poolId = pc.pool?.id?.trim() || (pc.pool ? "clawd" : undefined);
@@ -677,11 +705,12 @@ async function healWatchdogUnit() {
677
705
  /** `explain` — gather config + live health, render the plain-English view. */
678
706
  async function explain(args = []) {
679
707
  let historySince;
680
- if (args.length) {
708
+ const asJson = args.length === 1 && args[0] === "--json";
709
+ if (args.length && !asJson) {
681
710
  const allowed = args[0] === "--history" && (args.length === 1 ||
682
711
  (args.length === 3 && args[1] === "--since"));
683
712
  if (!allowed) {
684
- console.error("explain: usage: multi-clawd explain [--history [--since 7d|24h]]");
713
+ console.error("explain: usage: multi-clawd explain [--json | --history [--since 7d|24h]]");
685
714
  process.exitCode = 1;
686
715
  return;
687
716
  }
@@ -709,13 +738,7 @@ async function explain(args = []) {
709
738
  console.error(distFailure("explain", "explain-core.js", err));
710
739
  process.exit(1);
711
740
  }
712
- let config = {};
713
- try {
714
- config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
715
- } catch {
716
- console.error("explain: could not read ~/.openclaw/openclaw.json");
717
- process.exit(1);
718
- }
741
+ const config = await requireOpenclawConfig("explain");
719
742
  const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
720
743
  const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
721
744
  const pool = pc.pool
@@ -775,6 +798,14 @@ async function explain(args = []) {
775
798
  /* no journal yet: every account counts as never served */
776
799
  }
777
800
  }
801
+ if (asJson) {
802
+ // #94: machine-readable health with the display-only weekly pace estimate.
803
+ console.log(JSON.stringify({
804
+ at: new Date(now).toISOString(),
805
+ accounts: healthRows.map((r) => ({ ...r, weeklyPace: ec.computeWeeklyPace(r.usage, now) ?? null })),
806
+ }, null, 2));
807
+ return;
808
+ }
778
809
  // v1.9: the direct anthropic/* route, when any account opted in.
779
810
  let direct;
780
811
  const dctx = await loadDirect(config);
@@ -814,11 +845,7 @@ async function usageCommand(args) {
814
845
  console.error(distFailure("usage", "usage-poll.js", err));
815
846
  process.exit(1);
816
847
  }
817
- const config = readOpenclawConfigSync();
818
- if (!config) {
819
- console.error("usage: could not read ~/.openclaw/openclaw.json");
820
- process.exit(1);
821
- }
848
+ const config = await requireOpenclawConfig("usage");
822
849
  const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
823
850
  const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
824
851
  const pool = pc.pool ? { ...pc.pool, id: pc.pool.id?.trim() || "clawd", accounts: pc.pool.accounts ?? [] } : undefined;
@@ -954,13 +981,7 @@ async function login() {
954
981
  console.error(distFailure("login", "login-plan.js", err));
955
982
  process.exit(1);
956
983
  }
957
- let config = {};
958
- try {
959
- config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
960
- } catch {
961
- console.error("login: could not read ~/.openclaw/openclaw.json — run `multi-clawd setup` first.");
962
- process.exit(1);
963
- }
984
+ const config = await requireOpenclawConfig("login");
964
985
  const accounts = config?.plugins?.entries?.["multi-clawd"]?.config?.accounts ?? [];
965
986
  if (accounts.length === 0) {
966
987
  console.error("login: no multi-clawd accounts configured — run `multi-clawd setup` first.");
@@ -1050,6 +1071,9 @@ switch (cmd) {
1050
1071
  process.exitCode = 1;
1051
1072
  break;
1052
1073
  }
1074
+ // Same missing/unreadable/malformed diagnostic as every other config
1075
+ // reader (#106), instead of the raw fs error changeAccount would throw.
1076
+ await requireOpenclawConfig("account");
1053
1077
  try {
1054
1078
  const { changeAccount } = await import("./account-config.mjs");
1055
1079
  const result = changeAccount(id, action === "disable", flags.includes("--dry-run"));
@@ -1085,7 +1109,8 @@ switch (cmd) {
1085
1109
  break;
1086
1110
  case "version":
1087
1111
  case "--version":
1088
- case "-v": {
1112
+ case "-v":
1113
+ case "-V": {
1089
1114
  const cliVersion = JSON.parse(
1090
1115
  readFileSync(resolve(__dirname, "..", "package.json"), "utf8"),
1091
1116
  ).version;
@@ -1098,7 +1123,14 @@ switch (cmd) {
1098
1123
  if (skewNote) console.log(`\n⚠️ ${skewNote}`);
1099
1124
  break;
1100
1125
  }
1101
- default:
1126
+ default: {
1127
+ const wantsHelp = cmd === undefined || cmd === "help" || cmd === "--help" || cmd === "-h";
1128
+ if (!wantsHelp) {
1129
+ // Say what went wrong on stderr, then show the list and exit non-zero.
1130
+ console.error(`multi-clawd: unknown command '${cmd}'`);
1131
+ console.error("Run 'multi-clawd --help' for the list of commands.");
1132
+ }
1102
1133
  usage();
1103
- process.exit(cmd === undefined || cmd === "help" || cmd === "--help" ? 0 : 1);
1134
+ process.exit(wantsHelp ? 0 : 1);
1135
+ }
1104
1136
  }
@@ -15,6 +15,7 @@ import {
15
15
  import { homedir } from "node:os";
16
16
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
+ import { describeOpenclawConfigError } from "./_shared.mjs";
18
19
 
19
20
  const HERE = dirname(fileURLToPath(import.meta.url));
20
21
  const BRIDGE = join(HERE, "hermes_bridge.py");
@@ -56,6 +57,17 @@ function safePath(raw, label) {
56
57
 
57
58
  class ReadLimitedError extends Error {}
58
59
 
60
+ /**
61
+ * A directory at the path. Carries EISDIR, the code `readFileSync` raises for
62
+ * it, so a caller's `describe` (the shared #106 diagnostic) names the cause
63
+ * and the next step instead of the generic size-cap line; without `describe`
64
+ * it still fails closed with that line. Other non-regular files keep the
65
+ * generic guard: nothing is read from them either way.
66
+ */
67
+ class NotRegularFileError extends ReadLimitedError {
68
+ code = "EISDIR";
69
+ }
70
+
59
71
  /**
60
72
  * `requirePrivate` rejects a POSIX file readable or writable by group/other
61
73
  * (mode & 0o077 != 0) — checked via fstat on the already-open fd, so nothing
@@ -63,11 +75,12 @@ class ReadLimitedError extends Error {}
63
75
  * bit layout, so the check is skipped there; the config JSON is read through
64
76
  * here too but never with this flag, since it holds no secret.
65
77
  */
66
- function readLimited(path, limit, label, { requirePrivate = false } = {}) {
78
+ function readLimited(path, limit, label, { requirePrivate = false, describe } = {}) {
67
79
  let fd;
68
80
  try {
69
81
  fd = openSync(path, "r");
70
82
  const stat = fstatSync(fd);
83
+ if (stat.isDirectory()) throw new NotRegularFileError(`${label} is unavailable or too large`);
71
84
  if (!stat.isFile() || stat.size > limit) throw new ReadLimitedError(`${label} is unavailable or too large`);
72
85
  if (requirePrivate && process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
73
86
  throw new ReadLimitedError(
@@ -78,7 +91,9 @@ function readLimited(path, limit, label, { requirePrivate = false } = {}) {
78
91
  if (data.byteLength > limit) throw new ReadLimitedError(`${label} is unavailable or too large`);
79
92
  return data.toString("utf8");
80
93
  } catch (error) {
94
+ if (error instanceof NotRegularFileError && describe) fail(describe(error));
81
95
  if (error instanceof ReadLimitedError) fail(error.message);
96
+ if (describe) fail(describe(error));
82
97
  fail(`${label} could not be read`);
83
98
  } finally {
84
99
  if (fd !== undefined) closeSync(fd);
@@ -254,12 +269,15 @@ function invokeBridge(hermes, home, request) {
254
269
  }
255
270
 
256
271
  function loadPluginConfig(configPath) {
257
- const text = readLimited(configPath, MAX_CONFIG_BYTES, "OpenClaw config");
272
+ // Same resolved-path + next-step diagnostic as the cli.mjs commands (#106).
273
+ const text = readLimited(configPath, MAX_CONFIG_BYTES, "OpenClaw config", {
274
+ describe: (error) => describeOpenclawConfigError(error, configPath),
275
+ });
258
276
  let config;
259
277
  try {
260
278
  config = JSON.parse(text);
261
- } catch {
262
- fail("OpenClaw config is malformed JSON");
279
+ } catch (error) {
280
+ fail(describeOpenclawConfigError(error, configPath));
263
281
  }
264
282
  return config?.plugins?.entries?.["multi-clawd"]?.config;
265
283
  }