@drakon-systems/multi-clawd 1.6.0 → 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 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.6.0/SETUP-AGENT.md
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
 
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.6.0",
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.6.0",
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
@@ -112,6 +113,114 @@ async function cliSkewNote(cli = cliVersion(), plugin = installedVersion()) {
112
113
  }
113
114
  }
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
+
115
224
  async function update() {
116
225
  let uc;
117
226
  try {
@@ -439,6 +548,9 @@ switch (cmd) {
439
548
  case "explain":
440
549
  await explain();
441
550
  break;
551
+ case "chain":
552
+ await chain(rest);
553
+ break;
442
554
  case "doctor":
443
555
  runSibling("doctor.mjs", rest);
444
556
  break;