@drakon-systems/multi-clawd 2.6.0 → 2.6.2
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 +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/_shared.mjs +55 -1
- package/scripts/cli.mjs +36 -34
- package/scripts/doctor.mjs +28 -6
- package/scripts/hermes.mjs +22 -4
package/README.md
CHANGED
|
@@ -416,7 +416,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
416
416
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
417
417
|
Code on the target machine already? Paste it this and go make coffee:
|
|
418
418
|
|
|
419
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.6.
|
|
419
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.6.2/SETUP-AGENT.md
|
|
420
420
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
421
421
|
> Claude account — ask me when you need me to log in.
|
|
422
422
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "2.6.
|
|
4
|
+
"version": "2.6.2",
|
|
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.6.
|
|
3
|
+
"version": "2.6.2",
|
|
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",
|
package/scripts/_shared.mjs
CHANGED
|
@@ -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
|
@@ -189,6 +189,23 @@ function readOpenclawConfigSync() {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
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
|
+
|
|
192
209
|
/**
|
|
193
210
|
* Everything the direct-route surfaces need, loaded from dist. None of these
|
|
194
211
|
* modules import the `openclaw` peer, so they load from a global install.
|
|
@@ -268,11 +285,7 @@ async function loadDirect(config) {
|
|
|
268
285
|
async function direct(args = []) {
|
|
269
286
|
const { readFileSync: rf, mkdtempSync, writeFileSync, rmSync, statSync } = await import("node:fs");
|
|
270
287
|
const { homedir, tmpdir } = await import("node:os");
|
|
271
|
-
const config =
|
|
272
|
-
if (!config) {
|
|
273
|
-
console.error("direct: could not read your OpenClaw config");
|
|
274
|
-
process.exit(1);
|
|
275
|
-
}
|
|
288
|
+
const config = await requireOpenclawConfig("direct");
|
|
276
289
|
const ctx = await loadDirect(config);
|
|
277
290
|
const sub = args.find((a) => !a.startsWith("--")) ?? "status";
|
|
278
291
|
console.log(`\n${BOLD}🦞 multi-clawd — direct anthropic/* route${RESET}\n`);
|
|
@@ -397,13 +410,7 @@ async function chain(args = []) {
|
|
|
397
410
|
process.exit(1);
|
|
398
411
|
}
|
|
399
412
|
|
|
400
|
-
|
|
401
|
-
try {
|
|
402
|
-
config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
|
|
403
|
-
} catch {
|
|
404
|
-
console.error("chain: could not read ~/.openclaw/openclaw.json");
|
|
405
|
-
process.exit(1);
|
|
406
|
-
}
|
|
413
|
+
const config = await requireOpenclawConfig("chain");
|
|
407
414
|
|
|
408
415
|
const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
|
|
409
416
|
const poolId = pc.pool?.id?.trim() || (pc.pool ? "clawd" : undefined);
|
|
@@ -731,13 +738,7 @@ async function explain(args = []) {
|
|
|
731
738
|
console.error(distFailure("explain", "explain-core.js", err));
|
|
732
739
|
process.exit(1);
|
|
733
740
|
}
|
|
734
|
-
|
|
735
|
-
try {
|
|
736
|
-
config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
|
|
737
|
-
} catch {
|
|
738
|
-
console.error("explain: could not read ~/.openclaw/openclaw.json");
|
|
739
|
-
process.exit(1);
|
|
740
|
-
}
|
|
741
|
+
const config = await requireOpenclawConfig("explain");
|
|
741
742
|
const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
|
|
742
743
|
const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
|
|
743
744
|
const pool = pc.pool
|
|
@@ -844,11 +845,7 @@ async function usageCommand(args) {
|
|
|
844
845
|
console.error(distFailure("usage", "usage-poll.js", err));
|
|
845
846
|
process.exit(1);
|
|
846
847
|
}
|
|
847
|
-
const config =
|
|
848
|
-
if (!config) {
|
|
849
|
-
console.error("usage: could not read ~/.openclaw/openclaw.json");
|
|
850
|
-
process.exit(1);
|
|
851
|
-
}
|
|
848
|
+
const config = await requireOpenclawConfig("usage");
|
|
852
849
|
const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
|
|
853
850
|
const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
|
|
854
851
|
const pool = pc.pool ? { ...pc.pool, id: pc.pool.id?.trim() || "clawd", accounts: pc.pool.accounts ?? [] } : undefined;
|
|
@@ -984,13 +981,7 @@ async function login() {
|
|
|
984
981
|
console.error(distFailure("login", "login-plan.js", err));
|
|
985
982
|
process.exit(1);
|
|
986
983
|
}
|
|
987
|
-
|
|
988
|
-
try {
|
|
989
|
-
config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
|
|
990
|
-
} catch {
|
|
991
|
-
console.error("login: could not read ~/.openclaw/openclaw.json — run `multi-clawd setup` first.");
|
|
992
|
-
process.exit(1);
|
|
993
|
-
}
|
|
984
|
+
const config = await requireOpenclawConfig("login");
|
|
994
985
|
const accounts = config?.plugins?.entries?.["multi-clawd"]?.config?.accounts ?? [];
|
|
995
986
|
if (accounts.length === 0) {
|
|
996
987
|
console.error("login: no multi-clawd accounts configured — run `multi-clawd setup` first.");
|
|
@@ -1080,6 +1071,9 @@ switch (cmd) {
|
|
|
1080
1071
|
process.exitCode = 1;
|
|
1081
1072
|
break;
|
|
1082
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");
|
|
1083
1077
|
try {
|
|
1084
1078
|
const { changeAccount } = await import("./account-config.mjs");
|
|
1085
1079
|
const result = changeAccount(id, action === "disable", flags.includes("--dry-run"));
|
|
@@ -1115,7 +1109,8 @@ switch (cmd) {
|
|
|
1115
1109
|
break;
|
|
1116
1110
|
case "version":
|
|
1117
1111
|
case "--version":
|
|
1118
|
-
case "-v":
|
|
1112
|
+
case "-v":
|
|
1113
|
+
case "-V": {
|
|
1119
1114
|
const cliVersion = JSON.parse(
|
|
1120
1115
|
readFileSync(resolve(__dirname, "..", "package.json"), "utf8"),
|
|
1121
1116
|
).version;
|
|
@@ -1128,7 +1123,14 @@ switch (cmd) {
|
|
|
1128
1123
|
if (skewNote) console.log(`\n⚠️ ${skewNote}`);
|
|
1129
1124
|
break;
|
|
1130
1125
|
}
|
|
1131
|
-
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
|
+
}
|
|
1132
1133
|
usage();
|
|
1133
|
-
process.exit(
|
|
1134
|
+
process.exit(wantsHelp ? 0 : 1);
|
|
1135
|
+
}
|
|
1134
1136
|
}
|
package/scripts/doctor.mjs
CHANGED
|
@@ -1055,17 +1055,35 @@ if (accounts.some((a) => a && a.direct !== undefined && a.direct !== false)) {
|
|
|
1055
1055
|
// ── 9. watchdog ─────────────────────────────────────────────────────────────
|
|
1056
1056
|
console.log("eviction watchdog");
|
|
1057
1057
|
let watchdogFound = false;
|
|
1058
|
+
// A scheduler query that FAILS (no user session bus, no systemd, container)
|
|
1059
|
+
// says nothing about whether a watchdog exists. Only a query that succeeded
|
|
1060
|
+
// can confirm absence. Tool stderr is never inherited: it is raw noise.
|
|
1061
|
+
let watchdogQueried = false;
|
|
1062
|
+
const WATCHDOG_NAME = /multi-?clawd|eviction/;
|
|
1058
1063
|
try {
|
|
1059
|
-
const out = execFileSync("launchctl", ["list"], { encoding: "utf8" });
|
|
1064
|
+
const out = execFileSync("launchctl", ["list"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
1065
|
+
watchdogQueried = true;
|
|
1060
1066
|
if (out.includes("multiclawd")) watchdogFound = true;
|
|
1061
1067
|
} catch {
|
|
1062
|
-
/* not macOS */
|
|
1068
|
+
/* not macOS, or launchctl could not be queried */
|
|
1063
1069
|
}
|
|
1064
1070
|
try {
|
|
1065
|
-
const out = execFileSync("systemctl", ["--user", "list-timers", "--all"], { encoding: "utf8" });
|
|
1066
|
-
|
|
1071
|
+
const out = execFileSync("systemctl", ["--user", "list-timers", "--all"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
1072
|
+
watchdogQueried = true;
|
|
1073
|
+
if (WATCHDOG_NAME.test(out)) watchdogFound = true;
|
|
1067
1074
|
} catch {
|
|
1068
|
-
/* not systemd */
|
|
1075
|
+
/* not systemd, or the user manager could not be queried */
|
|
1076
|
+
}
|
|
1077
|
+
if (!watchdogFound && !watchdogQueried) {
|
|
1078
|
+
// Query unavailable: fall back to the unit files on disk, which need no
|
|
1079
|
+
// running manager.
|
|
1080
|
+
for (const d of [join(HOME, "Library", "LaunchAgents"), join(HOME, ".config", "systemd", "user")]) {
|
|
1081
|
+
try {
|
|
1082
|
+
if (readdirSync(d).some((f) => /\.(plist|service|timer)$/.test(f) && WATCHDOG_NAME.test(f))) watchdogFound = true;
|
|
1083
|
+
} catch {
|
|
1084
|
+
/* no such directory */
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1069
1087
|
}
|
|
1070
1088
|
if (watchdogFound) {
|
|
1071
1089
|
// "Scheduled" is not enough: the unit points at a script INSIDE an install
|
|
@@ -1108,7 +1126,11 @@ if (watchdogFound) {
|
|
|
1108
1126
|
`watchdog unit ${orphan.file} points at a MISSING script (${orphan.target}) — it fails silently every tick. Repoint it at ${join(EXT_DIR, "scripts", "eviction-watchdog.mjs")} or run the setup wizard to repair.`,
|
|
1109
1127
|
);
|
|
1110
1128
|
} else ok("watchdog scheduled");
|
|
1111
|
-
} else
|
|
1129
|
+
} else if (watchdogQueried) {
|
|
1130
|
+
warn("no watchdog found (needed until openclaw#107596 ships — see README)");
|
|
1131
|
+
} else {
|
|
1132
|
+
note("could not query the scheduler (no user session or no systemd/launchd) and found no watchdog unit file — watchdog state unknown. Run doctor from a normal login session to confirm.");
|
|
1133
|
+
}
|
|
1112
1134
|
|
|
1113
1135
|
// ── 10. optional live probe ─────────────────────────────────────────────────
|
|
1114
1136
|
if (args.has("--probe")) {
|
package/scripts/hermes.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
279
|
+
} catch (error) {
|
|
280
|
+
fail(describeOpenclawConfigError(error, configPath));
|
|
263
281
|
}
|
|
264
282
|
return config?.plugins?.entries?.["multi-clawd"]?.config;
|
|
265
283
|
}
|