@drakon-systems/multi-clawd 1.5.4 → 1.7.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/chain-audit.js +63 -0
- package/dist/index.js +56 -0
- package/dist/model-currency.js +26 -0
- package/dist/update-core.js +42 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +183 -1
- package/scripts/doctor.mjs +25 -0
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ multi-clawd update # install (or update) the OpenClaw plugin — right flags,
|
|
|
30
30
|
multi-clawd setup # guided wizard: accounts, isolated second login, pool, watchdog
|
|
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
|
+
multi-clawd chain # audit your model routing — what actually serves each turn
|
|
33
34
|
multi-clawd doctor # health check (add --probe for a live end-to-end turn)
|
|
34
35
|
```
|
|
35
36
|
|
|
@@ -188,7 +189,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
188
189
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
189
190
|
Code on the target machine already? Paste it this and go make coffee:
|
|
190
191
|
|
|
191
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.
|
|
192
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.7.0/SETUP-AGENT.md
|
|
192
193
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
193
194
|
> Claude account — ask me when you need me to log in.
|
|
194
195
|
|
package/dist/chain-audit.js
CHANGED
|
@@ -164,6 +164,69 @@ export function auditEffectiveChain(config, poolId) {
|
|
|
164
164
|
}
|
|
165
165
|
return findings;
|
|
166
166
|
}
|
|
167
|
+
function primaryOf(value) {
|
|
168
|
+
if (typeof value === "string")
|
|
169
|
+
return value;
|
|
170
|
+
if (value && typeof value === "object") {
|
|
171
|
+
const p = value.primary;
|
|
172
|
+
if (typeof p === "string")
|
|
173
|
+
return p;
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
function collectAgentModelBlocks(config) {
|
|
178
|
+
const cfg = (config ?? {});
|
|
179
|
+
const agents = (cfg.agents ?? {});
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const [name, v] of Object.entries(agents)) {
|
|
182
|
+
if (RESERVED_AGENT_KEYS.has(name))
|
|
183
|
+
continue;
|
|
184
|
+
if (!v || typeof v !== "object")
|
|
185
|
+
continue;
|
|
186
|
+
const agent = v;
|
|
187
|
+
if ("model" in agent)
|
|
188
|
+
out.push({ agent: name, surface: `agents.${name}.model`, model: agent.model });
|
|
189
|
+
}
|
|
190
|
+
if (Array.isArray(agents.list)) {
|
|
191
|
+
agents.list.forEach((a, i) => {
|
|
192
|
+
if (!a || typeof a !== "object")
|
|
193
|
+
return;
|
|
194
|
+
const agent = a;
|
|
195
|
+
if (!("model" in agent))
|
|
196
|
+
return;
|
|
197
|
+
const label = typeof agent.id === "string" ? agent.id : String(i);
|
|
198
|
+
out.push({ agent: label, surface: `agents.list[${label}].model`, model: agent.model });
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
export function auditChainShadowing(config) {
|
|
204
|
+
const cfg = (config ?? {});
|
|
205
|
+
const agents = (cfg.agents ?? {});
|
|
206
|
+
const defaults = (agents.defaults ?? {});
|
|
207
|
+
const defaultPrimary = primaryOf(defaults.model);
|
|
208
|
+
const findings = [];
|
|
209
|
+
for (const { agent, surface, model } of collectAgentModelBlocks(config)) {
|
|
210
|
+
const agentPrimary = primaryOf(model);
|
|
211
|
+
const diverges = agentPrimary !== undefined && agentPrimary !== defaultPrimary;
|
|
212
|
+
findings.push({
|
|
213
|
+
surface,
|
|
214
|
+
ref: agentPrimary ?? "(no primary)",
|
|
215
|
+
severity: diverges ? "warn" : "note",
|
|
216
|
+
agent,
|
|
217
|
+
defaultPrimary,
|
|
218
|
+
agentPrimary,
|
|
219
|
+
reason: diverges
|
|
220
|
+
? `agent "${agent}" has its OWN chain, so it serves ${agentPrimary} — NOT the ` +
|
|
221
|
+
`${defaultPrimary ?? "(unset)"} in agents.defaults.model. Editing the defaults will not ` +
|
|
222
|
+
`change this agent. Fix: update this block too, or delete it to inherit the defaults`
|
|
223
|
+
: `agent "${agent}" repeats the default chain — harmless, but it will silently ` +
|
|
224
|
+
`stop tracking agents.defaults.model the next time you change it. Fix: delete this ` +
|
|
225
|
+
`block to inherit the defaults`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return findings;
|
|
229
|
+
}
|
|
167
230
|
const POOL_PROVIDER = "clawd";
|
|
168
231
|
export function auditSessionOverrides(sessions, poolConfigured) {
|
|
169
232
|
if (!poolConfigured)
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js
|
|
|
15
15
|
import { resolveSecretRefValues } from "openclaw/plugin-sdk/secret-ref-runtime";
|
|
16
16
|
import { addAlert, clearAlert, pendingAlertText } from "./alerts.js";
|
|
17
17
|
import { buildAccountChildEnv, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
|
|
18
|
+
import { diffCatalogModels, formatNewModelNotice, } from "./model-currency.js";
|
|
18
19
|
import { checkAccountCredential, createRefProbeTracker, } from "./login-health.js";
|
|
19
20
|
import { execFileSync } from "node:child_process";
|
|
20
21
|
const BASE_ARGS = [
|
|
@@ -231,6 +232,54 @@ const SHIM_PATH = fileURLToPath(new URL("./shim.js", import.meta.url));
|
|
|
231
232
|
export function healthStateFile(accountId) {
|
|
232
233
|
return join(homedir(), ".openclaw", "state", "multi-clawd", `${accountId}.json`);
|
|
233
234
|
}
|
|
235
|
+
function knownModelsFile() {
|
|
236
|
+
return join(homedir(), ".openclaw", "state", "multi-clawd", "known-models.json");
|
|
237
|
+
}
|
|
238
|
+
function readChainRefs() {
|
|
239
|
+
try {
|
|
240
|
+
const cfg = JSON.parse(readFileSync(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
|
|
241
|
+
const m = cfg?.agents?.defaults?.model;
|
|
242
|
+
const refs = [];
|
|
243
|
+
if (typeof m?.primary === "string")
|
|
244
|
+
refs.push(m.primary);
|
|
245
|
+
if (Array.isArray(m?.fallbacks)) {
|
|
246
|
+
for (const f of m.fallbacks)
|
|
247
|
+
if (typeof f === "string")
|
|
248
|
+
refs.push(f);
|
|
249
|
+
}
|
|
250
|
+
return refs;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function checkModelCurrency(catalogIds, chainRefs, poolId) {
|
|
257
|
+
try {
|
|
258
|
+
const file = knownModelsFile();
|
|
259
|
+
let stored;
|
|
260
|
+
try {
|
|
261
|
+
stored = JSON.parse(readFileSync(file, "utf8"));
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
}
|
|
265
|
+
const result = diffCatalogModels(stored, catalogIds, chainRefs, Date.now());
|
|
266
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
267
|
+
const tmp = `${file}.tmp`;
|
|
268
|
+
writeFileSync(tmp, JSON.stringify(result.nextState, null, 2), { mode: 0o600 });
|
|
269
|
+
renameSync(tmp, file);
|
|
270
|
+
const notice = formatNewModelNotice(result.unusedNewIds, poolId);
|
|
271
|
+
if (notice) {
|
|
272
|
+
raiseAlert({
|
|
273
|
+
key: `new-models:${result.unusedNewIds.join(",")}`,
|
|
274
|
+
severity: "info",
|
|
275
|
+
text: notice,
|
|
276
|
+
ttlMs: 24 * 60 * 60 * 1000,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
}
|
|
282
|
+
}
|
|
234
283
|
export function buildBackend(account, execMode) {
|
|
235
284
|
return {
|
|
236
285
|
id: account.id,
|
|
@@ -520,4 +569,11 @@ function registerPoolBackend(api, pool, accounts, registeredIds, execMode) {
|
|
|
520
569
|
api.registerProvider(buildCatalogProvider(poolAccount));
|
|
521
570
|
registeredIds.add(poolId);
|
|
522
571
|
logger.info(`[multi-clawd] pool "${poolId}" active — accounts: ${memberIds.join(" → ")}, threshold: ${options.utilizationThreshold ?? 0.85}`);
|
|
572
|
+
void (async () => {
|
|
573
|
+
try {
|
|
574
|
+
checkModelCurrency(await resolveBaseModelIds(), readChainRefs(), poolId);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
}
|
|
578
|
+
})();
|
|
523
579
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isModernClaudeModelId } from "./models.js";
|
|
2
|
+
function modelIdOf(ref) {
|
|
3
|
+
const i = ref.indexOf("/");
|
|
4
|
+
return i < 0 ? ref : ref.slice(i + 1);
|
|
5
|
+
}
|
|
6
|
+
export function diffCatalogModels(stored, catalogIds, chainRefs, nowMs) {
|
|
7
|
+
const claudeIds = [...new Set(catalogIds.filter(isModernClaudeModelId))].sort();
|
|
8
|
+
const nextState = { ids: claudeIds, updatedAt: nowMs };
|
|
9
|
+
const firstRun = !stored || !Array.isArray(stored.ids) || stored.ids.length === 0;
|
|
10
|
+
if (firstRun)
|
|
11
|
+
return { newIds: [], unusedNewIds: [], nextState, firstRun: true };
|
|
12
|
+
const seen = new Set(stored.ids);
|
|
13
|
+
const newIds = claudeIds.filter((id) => !seen.has(id));
|
|
14
|
+
const referenced = new Set(chainRefs.map(modelIdOf));
|
|
15
|
+
const unusedNewIds = newIds.filter((id) => !referenced.has(id));
|
|
16
|
+
return { newIds, unusedNewIds, nextState, firstRun: false };
|
|
17
|
+
}
|
|
18
|
+
export function formatNewModelNotice(unusedNewIds, poolId) {
|
|
19
|
+
if (unusedNewIds.length === 0)
|
|
20
|
+
return undefined;
|
|
21
|
+
const list = unusedNewIds.map((id) => `${poolId}/${id}`).join(", ");
|
|
22
|
+
const subject = unusedNewIds.length === 1 ? "A new Claude model is" : "New Claude models are";
|
|
23
|
+
return (`${subject} available on your accounts but not referenced in your chain: ${list}. ` +
|
|
24
|
+
`Whether it belongs as your primary, a fallback, or nowhere is your call — ` +
|
|
25
|
+
`run \`multi-clawd chain\` to see your current routing.`);
|
|
26
|
+
}
|
package/dist/update-core.js
CHANGED
|
@@ -15,6 +15,48 @@ export function decideUpdateAction(opts) {
|
|
|
15
15
|
return "unknown";
|
|
16
16
|
return compareVersions(opts.installed, opts.latest) < 0 ? "update" : "up-to-date";
|
|
17
17
|
}
|
|
18
|
+
export function classifyCliSkew(opts) {
|
|
19
|
+
if (opts.pluginVersion === undefined)
|
|
20
|
+
return "plugin-missing";
|
|
21
|
+
const d = compareVersions(opts.cliVersion, opts.pluginVersion);
|
|
22
|
+
if (d === 0)
|
|
23
|
+
return "aligned";
|
|
24
|
+
return d < 0 ? "cli-behind" : "cli-ahead";
|
|
25
|
+
}
|
|
26
|
+
export function detectCliInstallKind(cliDir) {
|
|
27
|
+
if (/[/\\]_npx[/\\]/.test(cliDir))
|
|
28
|
+
return "npx";
|
|
29
|
+
if (/[/\\]node_modules[/\\]/.test(cliDir))
|
|
30
|
+
return "global";
|
|
31
|
+
return "source";
|
|
32
|
+
}
|
|
33
|
+
export function cliUpdateCommand(kind, pkg) {
|
|
34
|
+
switch (kind) {
|
|
35
|
+
case "global":
|
|
36
|
+
return `npm i -g ${pkg}@latest`;
|
|
37
|
+
case "npx":
|
|
38
|
+
return `npx ${pkg}@latest <command>`;
|
|
39
|
+
case "source":
|
|
40
|
+
return "git pull && npm run build";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function formatCliSkew(opts) {
|
|
44
|
+
const skew = classifyCliSkew(opts);
|
|
45
|
+
const fix = cliUpdateCommand(opts.installKind, opts.pkg);
|
|
46
|
+
switch (skew) {
|
|
47
|
+
case "aligned":
|
|
48
|
+
return undefined;
|
|
49
|
+
case "plugin-missing":
|
|
50
|
+
return undefined;
|
|
51
|
+
case "cli-behind":
|
|
52
|
+
return (`CLI v${opts.cliVersion} is older than the installed plugin v${opts.pluginVersion} — ` +
|
|
53
|
+
`\`doctor\`/\`setup\` run from the CLI, so this one reports on the plugin using ` +
|
|
54
|
+
`older logic. Fix: ${fix}`);
|
|
55
|
+
case "cli-ahead":
|
|
56
|
+
return (`CLI v${opts.cliVersion} is newer than the installed plugin v${opts.pluginVersion} — ` +
|
|
57
|
+
`the plugin serving your turns is behind. Fix: multi-clawd update`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
18
60
|
export function formatUpdateBanner(opts) {
|
|
19
61
|
const action = decideUpdateAction(opts);
|
|
20
62
|
switch (action) {
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.7.0",
|
|
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": "1.
|
|
3
|
+
"version": "1.7.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
|
@@ -35,6 +35,7 @@ ${BOLD}🦞 multi-clawd${RESET} — multi-account Claude failover for OpenClaw
|
|
|
35
35
|
${BOLD}setup${RESET} guided setup wizard (accounts, pool, watchdog)
|
|
36
36
|
${BOLD}login${RESET} log a configured account in (or re-auth it) — right dir, right env
|
|
37
37
|
${BOLD}explain${RESET} your setup in plain English — accounts, pool, fallback chain
|
|
38
|
+
${BOLD}chain${RESET} audit your model routing — what actually serves each turn
|
|
38
39
|
${BOLD}update${RESET} update the plugin to the latest version
|
|
39
40
|
${BOLD}doctor${RESET} health check (add --probe for a live turn)
|
|
40
41
|
${BOLD}version${RESET} show CLI + installed plugin versions
|
|
@@ -88,6 +89,138 @@ async function askYes(question, dflt = true) {
|
|
|
88
89
|
return a.startsWith("y");
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/** This package's own version (the CLI half). */
|
|
93
|
+
function cliVersion() {
|
|
94
|
+
return JSON.parse(readFileSync(resolve(__dirname, "..", "package.json"), "utf8")).version;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Skew advice for the current install, or undefined when the two halves agree.
|
|
99
|
+
* Loads the pure classifier from dist; stays silent if dist is unavailable so
|
|
100
|
+
* a missing build can never turn an informational note into a hard failure.
|
|
101
|
+
*/
|
|
102
|
+
async function cliSkewNote(cli = cliVersion(), plugin = installedVersion()) {
|
|
103
|
+
try {
|
|
104
|
+
const uc = await import(resolve(__dirname, "..", "dist", "update-core.js"));
|
|
105
|
+
return uc.formatCliSkew({
|
|
106
|
+
cliVersion: cli,
|
|
107
|
+
pluginVersion: plugin,
|
|
108
|
+
installKind: uc.detectCliInstallKind(resolve(__dirname, "..")),
|
|
109
|
+
pkg: PKG,
|
|
110
|
+
});
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `chain` — one place that answers "what actually serves my turns, and does it
|
|
118
|
+
* match what I meant?".
|
|
119
|
+
*
|
|
120
|
+
* Every routing fault this project has hit was config that no longer matched
|
|
121
|
+
* intent: a per-agent chain shadowing the defaults, sessions pinned off-pool,
|
|
122
|
+
* allowlist rungs naming retired providers. Each was individually invisible and
|
|
123
|
+
* each defeated cross-account failover — the entire point of the product. The
|
|
124
|
+
* audits already existed for doctor; this gives them a home where the fix is
|
|
125
|
+
* printed next to the finding.
|
|
126
|
+
*/
|
|
127
|
+
async function chain(args = []) {
|
|
128
|
+
const { readFileSync: rf, existsSync, readdirSync } = await import("node:fs");
|
|
129
|
+
const { homedir } = await import("node:os");
|
|
130
|
+
const raw = args.includes("--raw");
|
|
131
|
+
|
|
132
|
+
let ca;
|
|
133
|
+
try {
|
|
134
|
+
ca = await import(resolve(__dirname, "..", "dist", "chain-audit.js"));
|
|
135
|
+
} catch {
|
|
136
|
+
console.error("chain: built dist/ is missing — reinstall the package.");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let config;
|
|
141
|
+
try {
|
|
142
|
+
config = JSON.parse(rf(join(homedir(), ".openclaw", "openclaw.json"), "utf8"));
|
|
143
|
+
} catch {
|
|
144
|
+
console.error("chain: could not read ~/.openclaw/openclaw.json");
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
|
|
149
|
+
const poolId = pc.pool?.id?.trim() || (pc.pool ? "clawd" : undefined);
|
|
150
|
+
const chainCfg = config?.agents?.defaults?.model;
|
|
151
|
+
|
|
152
|
+
console.log(`\n${BOLD}🦞 multi-clawd — model routing${RESET}\n`);
|
|
153
|
+
|
|
154
|
+
console.log(`${BOLD}DEFAULT CHAIN${RESET} ${DIM}(agents.defaults.model)${RESET}`);
|
|
155
|
+
const rungs = [chainCfg?.primary, ...(chainCfg?.fallbacks ?? [])].filter(
|
|
156
|
+
(r) => typeof r === "string",
|
|
157
|
+
);
|
|
158
|
+
if (rungs.length === 0) console.log(" (none configured)");
|
|
159
|
+
rungs.forEach((r, i) => {
|
|
160
|
+
const pooled = poolId && r.startsWith(`${poolId}/`);
|
|
161
|
+
console.log(` ${i + 1}. ${r}${pooled ? ` ${DIM}→ pooled${RESET}` : ""}`);
|
|
162
|
+
});
|
|
163
|
+
console.log("");
|
|
164
|
+
|
|
165
|
+
let problems = 0;
|
|
166
|
+
const section = (title, findings, renderRef) => {
|
|
167
|
+
if (findings.length === 0) return;
|
|
168
|
+
console.log(`${BOLD}${title}${RESET}`);
|
|
169
|
+
for (const f of findings) {
|
|
170
|
+
const icon = f.severity === "warn" ? "⚠️ " : "ℹ️ ";
|
|
171
|
+
if (f.severity === "warn") problems++;
|
|
172
|
+
console.log(` ${icon} ${renderRef(f)}`);
|
|
173
|
+
console.log(` ${DIM}${f.reason}${RESET}`);
|
|
174
|
+
}
|
|
175
|
+
console.log("");
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
section("AGENTS WITH THEIR OWN CHAIN", ca.auditChainShadowing(config), (f) =>
|
|
179
|
+
`${f.surface} → ${f.ref}`,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const configFindings = ca.auditEffectiveChain(config, poolId);
|
|
183
|
+
section(
|
|
184
|
+
"OFF-POOL REFERENCES",
|
|
185
|
+
configFindings.filter((f) => f.severity === "warn"),
|
|
186
|
+
(f) => `${f.surface}: ${f.ref}`,
|
|
187
|
+
);
|
|
188
|
+
const notes = configFindings.filter((f) => f.severity === "note");
|
|
189
|
+
if (notes.length > 0) {
|
|
190
|
+
console.log(
|
|
191
|
+
`${DIM} (${notes.length} allowlist entr${notes.length === 1 ? "y" : "ies"} name a non-pool Claude ref — registered, not a live tier)${RESET}\n`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Session pins, across every agent's session store.
|
|
196
|
+
const sessionFindings = [];
|
|
197
|
+
const agentsDir = join(homedir(), ".openclaw", "agents");
|
|
198
|
+
try {
|
|
199
|
+
for (const agent of readdirSync(agentsDir)) {
|
|
200
|
+
const p = join(agentsDir, agent, "sessions", "sessions.json");
|
|
201
|
+
if (!existsSync(p)) continue;
|
|
202
|
+
try {
|
|
203
|
+
sessionFindings.push(...ca.auditSessionOverrides(JSON.parse(rf(p, "utf8")), Boolean(poolId)));
|
|
204
|
+
} catch {
|
|
205
|
+
/* unreadable store — skip, doctor reports install health */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
/* no agents dir */
|
|
210
|
+
}
|
|
211
|
+
section("SESSION PINS", sessionFindings, (f) =>
|
|
212
|
+
raw ? f.surface : f.surface.replace(/^session (.*)$/, (_, k) => `session ${ca.maskSessionKey(k)}`),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (problems === 0) {
|
|
216
|
+
console.log(`✅ routing is consistent — every live Claude tier goes through the pool.\n`);
|
|
217
|
+
} else {
|
|
218
|
+
console.log(
|
|
219
|
+
`${problems} thing${problems === 1 ? "" : "s"} to look at. ${DIM}Session ids are masked; --raw shows them in full.${RESET}\n`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
91
224
|
async function update() {
|
|
92
225
|
let uc;
|
|
93
226
|
try {
|
|
@@ -130,6 +263,7 @@ async function update() {
|
|
|
130
263
|
console.log(" ⏳ remember: the new version loads on the next gateway restart.");
|
|
131
264
|
}
|
|
132
265
|
await healWatchdogUnit();
|
|
266
|
+
await offerCliSelfUpdate(uc);
|
|
133
267
|
console.log(`\n${BOLD} health check${RESET}`);
|
|
134
268
|
const doc = spawnSync(process.execPath, [join(__dirname, "doctor.mjs")], { stdio: "inherit" });
|
|
135
269
|
if (doc.status !== 0) {
|
|
@@ -139,6 +273,46 @@ async function update() {
|
|
|
139
273
|
console.log(`\n ✅ done — now on v${installedVersion() ?? "?"}`);
|
|
140
274
|
}
|
|
141
275
|
|
|
276
|
+
/**
|
|
277
|
+
* `update` upgrades the PLUGIN; this finishes the job by offering to upgrade
|
|
278
|
+
* the CLI too, so "update" means what a user reasonably assumes it means.
|
|
279
|
+
*
|
|
280
|
+
* Runs LAST in the update flow on purpose: `npm i -g` replaces this package's
|
|
281
|
+
* own directory, so nothing may dynamically import from it afterwards. Skipped
|
|
282
|
+
* silently when the halves already agree, and never forced — a global install
|
|
283
|
+
* can need permissions we shouldn't assume, and npx users have nothing to
|
|
284
|
+
* update at all.
|
|
285
|
+
*/
|
|
286
|
+
async function offerCliSelfUpdate(uc) {
|
|
287
|
+
const cli = cliVersion();
|
|
288
|
+
const plugin = installedVersion();
|
|
289
|
+
if (uc.classifyCliSkew({ cliVersion: cli, pluginVersion: plugin }) !== "cli-behind") return;
|
|
290
|
+
|
|
291
|
+
const kind = uc.detectCliInstallKind(resolve(__dirname, ".."));
|
|
292
|
+
const fix = uc.cliUpdateCommand(kind, PKG);
|
|
293
|
+
console.log(
|
|
294
|
+
`\n ⚠️ Your ${BOLD}multi-clawd${RESET} command is v${cli} but the plugin is now v${plugin}.`,
|
|
295
|
+
);
|
|
296
|
+
console.log(
|
|
297
|
+
` ${DIM}doctor and setup run from the command, so they'd report on the new plugin using old logic.${RESET}`,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
if (kind !== "global") {
|
|
301
|
+
console.log(` Bring it up to date with: ${BOLD}${fix}${RESET}`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!(await askYes(` Update the command now? (${fix})`))) {
|
|
305
|
+
console.log(` ${DIM}Skipped — run \`${fix}\` when you're ready.${RESET}`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const r = spawnSync("npm", ["i", "-g", `${PKG}@latest`], { stdio: "inherit" });
|
|
309
|
+
if (r.status === 0) {
|
|
310
|
+
console.log(` ✅ command updated — the new version applies from your next run.`);
|
|
311
|
+
} else {
|
|
312
|
+
console.log(` ⚠ that failed (permissions?) — run it yourself: ${BOLD}${fix}${RESET}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
142
316
|
/**
|
|
143
317
|
* Self-heal the scheduled watchdog after an update: the npm install dir is
|
|
144
318
|
* regenerated on every update, so a unit pointing into it just orphaned.
|
|
@@ -374,6 +548,9 @@ switch (cmd) {
|
|
|
374
548
|
case "explain":
|
|
375
549
|
await explain();
|
|
376
550
|
break;
|
|
551
|
+
case "chain":
|
|
552
|
+
await chain(rest);
|
|
553
|
+
break;
|
|
377
554
|
case "doctor":
|
|
378
555
|
runSibling("doctor.mjs", rest);
|
|
379
556
|
break;
|
|
@@ -386,8 +563,13 @@ switch (cmd) {
|
|
|
386
563
|
const cliVersion = JSON.parse(
|
|
387
564
|
readFileSync(resolve(__dirname, "..", "package.json"), "utf8"),
|
|
388
565
|
).version;
|
|
566
|
+
const pluginVersion = installedVersion();
|
|
389
567
|
console.log(`cli: v${cliVersion}`);
|
|
390
|
-
console.log(`installed plugin: ${
|
|
568
|
+
console.log(`installed plugin: ${pluginVersion ? `v${pluginVersion}` : "(not installed)"}`);
|
|
569
|
+
// Two versions printed side by side invite exactly one question — "is that
|
|
570
|
+
// a problem?" — so answer it here rather than leaving the reader to guess.
|
|
571
|
+
const skewNote = await cliSkewNote(cliVersion, pluginVersion);
|
|
572
|
+
if (skewNote) console.log(`\n⚠️ ${skewNote}`);
|
|
391
573
|
break;
|
|
392
574
|
}
|
|
393
575
|
default:
|
package/scripts/doctor.mjs
CHANGED
|
@@ -114,6 +114,31 @@ const entry = config?.plugins?.entries?.["multi-clawd"];
|
|
|
114
114
|
const pluginConfig = entry?.config ?? {};
|
|
115
115
|
if (!manifest) bad(`no installed manifest at ${EXT_DIR}`);
|
|
116
116
|
else ok(`installed at ${EXT_DIR}`);
|
|
117
|
+
|
|
118
|
+
// Doctor itself ships in the CLI half, so a stale CLI means these very
|
|
119
|
+
// findings were produced by older logic than the plugin they describe. That
|
|
120
|
+
// has to be the first thing reported, or every line below is suspect.
|
|
121
|
+
{
|
|
122
|
+
// REPO_DIR first, unlike the health imports below: this is CLI-side logic and
|
|
123
|
+
// doctor IS the CLI half, so it must use its own copy. Reaching for the
|
|
124
|
+
// plugin's copy would ask a possibly-older artifact whether it is older.
|
|
125
|
+
const uc = await import(join(REPO_DIR, "dist", "update-core.js")).catch(() =>
|
|
126
|
+
import(join(EXT_DIR, "dist", "update-core.js")).catch(() => undefined),
|
|
127
|
+
);
|
|
128
|
+
const cliVer = readJson(join(REPO_DIR, "package.json"))?.version;
|
|
129
|
+
const pluginVer = manifest?.version;
|
|
130
|
+
const note =
|
|
131
|
+
uc && cliVer && typeof uc.formatCliSkew === "function"
|
|
132
|
+
? uc.formatCliSkew({
|
|
133
|
+
cliVersion: cliVer,
|
|
134
|
+
pluginVersion: pluginVer,
|
|
135
|
+
installKind: uc.detectCliInstallKind(REPO_DIR),
|
|
136
|
+
pkg: "@drakon-systems/multi-clawd",
|
|
137
|
+
})
|
|
138
|
+
: undefined;
|
|
139
|
+
if (note) warn(note);
|
|
140
|
+
else if (cliVer && pluginVer) ok(`CLI and plugin both v${cliVer}`);
|
|
141
|
+
}
|
|
117
142
|
if (!entry) bad("no plugins.entries[\"multi-clawd\"] in openclaw.json");
|
|
118
143
|
else if (entry.enabled !== true) bad("plugin entry present but not enabled");
|
|
119
144
|
else ok("plugin entry enabled");
|