@drakon-systems/multi-clawd 1.8.10 → 1.9.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/scripts/cli.mjs CHANGED
@@ -18,6 +18,7 @@
18
18
  import { execFileSync, spawnSync } from "node:child_process";
19
19
  import { readFileSync, existsSync as existsSyncEarly } from "node:fs";
20
20
  import { dirname, join, resolve } from "node:path";
21
+ import { homedir as osHomedir } from "node:os";
21
22
  import { fileURLToPath } from "node:url";
22
23
  import readline from "node:readline/promises";
23
24
 
@@ -37,6 +38,7 @@ ${BOLD}🦞 multi-clawd${RESET} — multi-account Claude failover for OpenClaw
37
38
  ${BOLD}login${RESET} log a configured account in (or re-auth it) — right dir, right env
38
39
  ${BOLD}explain${RESET} your setup in plain English — accounts, pool, fallback chain
39
40
  ${BOLD}chain${RESET} audit your model routing — what actually serves each turn
41
+ ${BOLD}direct${RESET} the direct anthropic/* route — status, or \`direct sync\` to store profiles
40
42
  ${BOLD}update${RESET} update the plugin to the latest version
41
43
  ${BOLD}doctor${RESET} health check (add --probe for a live turn)
42
44
  ${BOLD}hermes${RESET} sync or diagnose Hermes Agent's Anthropic credential pool
@@ -143,6 +145,195 @@ async function cliSkewNote(cli = cliVersion(), plugin = installedVersion()) {
143
145
  }
144
146
  }
145
147
 
148
+
149
+ /** The OpenClaw config as parsed JSON, or undefined when unreadable. */
150
+ function readOpenclawConfigSync() {
151
+ try {
152
+ return JSON.parse(readFileSync(join(osHomedir(), ".openclaw", "openclaw.json"), "utf8"));
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Everything the direct-route surfaces need, loaded from dist. None of these
160
+ * modules import the `openclaw` peer, so they load from a global install.
161
+ * Returns undefined when no account mentions `direct` — callers then print
162
+ * nothing new (the compatibility contract).
163
+ */
164
+ async function loadDirect(config) {
165
+ const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
166
+ const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
167
+ if (!accounts.some((a) => a && a.direct !== undefined && a.direct !== false)) return undefined;
168
+ let dr, ds, rep, run, shim;
169
+ try {
170
+ dr = await import(resolve(__dirname, "..", "dist", "direct-route.js"));
171
+ ds = await import(resolve(__dirname, "..", "dist", "direct-sync.js"));
172
+ rep = await import(resolve(__dirname, "..", "dist", "direct-report.js"));
173
+ run = await import(resolve(__dirname, "..", "dist", "openclaw-runner.js"));
174
+ shim = await import(resolve(__dirname, "..", "dist", "shim-core.js"));
175
+ } catch (err) {
176
+ console.error(distFailure("direct", "direct-report.js", err));
177
+ process.exit(1);
178
+ }
179
+ const { readFileSync: rf } = await import("node:fs");
180
+ const { homedir } = await import("node:os");
181
+ const stateDir = join(homedir(), ".openclaw", "state", "multi-clawd");
182
+ const pool = pc.pool;
183
+ const directRoute = pc.directRoute ?? {};
184
+ const agents = Array.isArray(directRoute.agents) && directRoute.agents.length > 0 ? directRoute.agents : ["main"];
185
+ const configOrder = Array.isArray(config?.auth?.order?.anthropic) ? config.auth.order.anthropic : undefined;
186
+ const readHealth = (id) => {
187
+ try {
188
+ return shim.parseStoredState(rf(join(stateDir, `${id}.json`), "utf8"));
189
+ } catch {
190
+ return undefined;
191
+ }
192
+ };
193
+ let sticky;
194
+ try {
195
+ sticky = JSON.parse(rf(join(stateDir, "direct-order.sticky.json"), "utf8"));
196
+ } catch {
197
+ /* none */
198
+ }
199
+ const runner = run.createOpenclawRunner(directRoute.openclawCommand ?? "openclaw");
200
+ const healthOptions = {
201
+ utilizationThreshold: pool?.utilizationThreshold,
202
+ staleAfterMs: pool?.staleAfterMs,
203
+ rotateOnOverage: pool?.rotateOnOverage,
204
+ };
205
+ const gather = (agentId, opts = {}) =>
206
+ rep.gatherDirectStatus({
207
+ accounts,
208
+ poolAccounts: pool?.accounts ?? [],
209
+ agentId,
210
+ runner,
211
+ readHealth,
212
+ healthOptions,
213
+ configOrder,
214
+ sticky,
215
+ minDwellMs: pool?.minDwellMs,
216
+ nowMs: Date.now(),
217
+ ...opts,
218
+ });
219
+ return { dr, ds, rep, runner, accounts, pool, directRoute, agents, configOrder, readHealth, gather, healthOptions, sticky };
220
+ }
221
+
222
+ /**
223
+ * `direct` — status (default) or `direct sync`: store each opted-in account's
224
+ * setup-token as an OpenClaw `anthropic` profile through OpenClaw's own CLI
225
+ * (secrets apply for refs — no copy; paste-token on stdin for files), then
226
+ * put every managed agent's order in pool-health order right away instead of
227
+ * waiting for the gateway's next tick.
228
+ */
229
+ async function direct(args = []) {
230
+ const { readFileSync: rf, mkdtempSync, writeFileSync, rmSync, statSync } = await import("node:fs");
231
+ const { homedir, tmpdir } = await import("node:os");
232
+ const config = readOpenclawConfigSync();
233
+ if (!config) {
234
+ console.error("direct: could not read your OpenClaw config");
235
+ process.exit(1);
236
+ }
237
+ const ctx = await loadDirect(config);
238
+ const sub = args.find((a) => !a.startsWith("--")) ?? "status";
239
+ console.log(`\n${BOLD}🦞 multi-clawd — direct anthropic/* route${RESET}\n`);
240
+ if (!ctx) {
241
+ console.log(" Not configured: no account sets `direct`, so anthropic/* turns use OpenClaw's own");
242
+ console.log(" anthropic profiles and the pool steers CLI turns only. See the README (\"Direct route\").\n");
243
+ return;
244
+ }
245
+ if (sub === "sync") {
246
+ const dryRun = args.includes("--dry-run");
247
+ const resync = args.includes("--resync");
248
+ const { members, problems } = ctx.dr.collectDirectMembers(ctx.accounts, ctx.pool?.accounts ?? []);
249
+ for (const p of problems) console.log(` ⚠️ ${p.accountId}: skipped — ${p.reason}`);
250
+ if (members.length === 0) {
251
+ console.log(" nothing to sync.\n");
252
+ process.exit(problems.length > 0 ? 1 : 0);
253
+ }
254
+ const agentId = ctx.agents[0];
255
+ if (!dryRun && !args.includes("--yes")) {
256
+ console.log(` Will store missing anthropic profiles for agent ${agentId}${resync ? " (and re-store existing ones)" : ""}:`);
257
+ for (const m of members) console.log(` ${m.accountId} → ${m.profileId} (${ctx.rep.describeDirectSource(m)})`);
258
+ console.log(` ${DIM}Refs are stored as SecretRefs (secrets apply — the token is never copied). Token files go`);
259
+ console.log(` through paste-token on stdin; OpenClaw also records those in its config (auth.profiles/auth.order).${RESET}`);
260
+ if (!(await askYes(" Proceed?"))) return;
261
+ }
262
+ const expand = (p) => (p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p);
263
+ const { results, listError } = await ctx.ds.syncDirectProfiles({
264
+ members,
265
+ agentId,
266
+ runner: ctx.runner,
267
+ resync,
268
+ dryRun,
269
+ readTokenFile: (p) => {
270
+ const abs = expand(p);
271
+ const mode = statSync(abs).mode & 0o777;
272
+ if (mode & 0o077) console.log(` ⚠️ ${abs} is mode ${mode.toString(8)} — chmod 600 it.`);
273
+ return rf(abs, "utf8");
274
+ },
275
+ writePlanFile: (plan) => {
276
+ const dir = mkdtempSync(join(tmpdir(), "multi-clawd-plan-"));
277
+ const path = join(dir, "plan.json");
278
+ writeFileSync(path, JSON.stringify(plan, null, 2), { mode: 0o600 });
279
+ return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
280
+ },
281
+ });
282
+ if (listError) {
283
+ console.error(` ❌ could not read OpenClaw's anthropic profiles: ${listError}`);
284
+ process.exit(1);
285
+ }
286
+ const icon = { present: "✅", adopted: "✅", stored: "✅", "would-store": "📝", "adopt-missing": "❌", failed: "❌" };
287
+ for (const r of results) {
288
+ console.log(` ${icon[r.action] ?? "•"} ${r.accountId} → ${r.profileId}: ${r.action}${r.detail ? ` (${r.detail})` : ""}`);
289
+ }
290
+ const failed = results.filter((r) => r.action === "failed" || r.action === "adopt-missing");
291
+ if (!dryRun && ctx.directRoute.manageOrder !== false) {
292
+ for (const agent of ctx.agents) {
293
+ const status = await ctx.gather(agent, { skipCooldowns: true });
294
+ if (!status || status.errors.length > 0) {
295
+ console.log(` ⚠️ agent ${agent}: order not checked (${status?.errors.join("; ") ?? "no status"})`);
296
+ continue;
297
+ }
298
+ if (!status.pendingOrder) {
299
+ console.log(` ✅ agent ${agent}: anthropic order already in pool-health order`);
300
+ continue;
301
+ }
302
+ const r = await ctx.runner(ctx.ds.orderSetArgs(agent, status.pendingOrder), { timeoutMs: 60000 });
303
+ console.log(
304
+ r.code === 0
305
+ ? ` ✅ agent ${agent}: anthropic order → ${status.pendingOrder.join(" → ")}`
306
+ : ` ❌ agent ${agent}: order set ${ctx.ds.safeCliError(r)}`,
307
+ );
308
+ if (r.code !== 0) failed.push({ action: "failed" });
309
+ }
310
+ }
311
+ console.log("");
312
+ process.exit(failed.length > 0 ? 1 : 0);
313
+ }
314
+ // status
315
+ const ec = await import(resolve(__dirname, "..", "dist", "explain-core.js"));
316
+ let problems = 0;
317
+ for (const agent of ctx.agents) {
318
+ const status = await ctx.gather(agent);
319
+ if (!status) continue;
320
+ console.log(`${DIM}agent ${agent}${RESET}`);
321
+ console.log(ec.renderDirectSection(status.explain, Date.now()).join("\n"));
322
+ for (const v of status.verdicts) console.log(` health ${v.accountId}: ${v.verdict}`);
323
+ if (status.pendingOrder) {
324
+ console.log(` ⏳ pending order (the gateway applies it within a minute, or run \`multi-clawd direct sync\`):`);
325
+ console.log(` ${status.pendingOrder.join(" → ")}`);
326
+ }
327
+ for (const e of status.errors) {
328
+ problems++;
329
+ console.log(` ⚠️ ${e}`);
330
+ }
331
+ problems += status.explain.problems.length + status.explain.members.filter((m) => m.stored === false).length;
332
+ console.log("");
333
+ }
334
+ process.exit(problems > 0 ? 1 : 0);
335
+ }
336
+
146
337
  /**
147
338
  * `chain` — one place that answers "what actually serves my turns, and does it
148
339
  * match what I meant?".
@@ -178,6 +369,14 @@ async function chain(args = []) {
178
369
  const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
179
370
  const poolId = pc.pool?.id?.trim() || (pc.pool ? "clawd" : undefined);
180
371
  const chainCfg = config?.agents?.defaults?.model;
372
+ // v1.9: anthropic/* is pooled when the direct route serves two accounts.
373
+ let directPooled = false;
374
+ try {
375
+ const dr = await import(resolve(__dirname, "..", "dist", "direct-route.js"));
376
+ directPooled = dr.directRoutePools(Array.isArray(pc.accounts) ? pc.accounts : [], pc.directRoute);
377
+ } catch {
378
+ /* older dist: no direct route */
379
+ }
181
380
 
182
381
  console.log(`\n${BOLD}🦞 multi-clawd — model routing${RESET}\n`);
183
382
 
@@ -188,7 +387,10 @@ async function chain(args = []) {
188
387
  if (rungs.length === 0) console.log(" (none configured)");
189
388
  rungs.forEach((r, i) => {
190
389
  const pooled = poolId && r.startsWith(`${poolId}/`);
191
- console.log(` ${i + 1}. ${r}${pooled ? ` ${DIM}→ pooled${RESET}` : ""}`);
390
+ const directRung = directPooled && /^anthropic\//i.test(r);
391
+ console.log(
392
+ ` ${i + 1}. ${r}${pooled ? ` ${DIM}→ pooled${RESET}` : directRung ? ` ${DIM}→ pooled (direct route)${RESET}` : ""}`,
393
+ );
192
394
  });
193
395
  console.log("");
194
396
 
@@ -209,7 +411,7 @@ async function chain(args = []) {
209
411
  `${f.surface} → ${f.ref}`,
210
412
  );
211
413
 
212
- const configFindings = ca.auditEffectiveChain(config, poolId);
414
+ const configFindings = ca.auditEffectiveChain(config, poolId, { directPooled });
213
415
  section(
214
416
  "OFF-POOL REFERENCES",
215
417
  configFindings.filter((f) => f.severity === "warn"),
@@ -236,7 +438,7 @@ async function chain(args = []) {
236
438
  console.log(`${DIM} (session store ${location.path} not audited: ${read.error} — see doctor)${RESET}`);
237
439
  continue;
238
440
  }
239
- sessionFindings.push(...ca.auditSessionOverrides(read.entries, Boolean(poolId)));
441
+ sessionFindings.push(...ca.auditSessionOverrides(read.entries, Boolean(poolId), { directPooled }));
240
442
  }
241
443
  } catch {
242
444
  /* no agents dir, or dist not built — doctor reports install health */
@@ -296,6 +498,7 @@ async function update() {
296
498
  console.log(" ⏳ remember: the new version loads on the next gateway restart.");
297
499
  }
298
500
  await healWatchdogUnit();
501
+ await offerDirectSync();
299
502
  await offerCliSelfUpdate(uc);
300
503
  console.log(`\n${BOLD} health check${RESET}`);
301
504
  const doc = spawnSync(process.execPath, [join(__dirname, "doctor.mjs")], { stdio: "inherit" });
@@ -306,6 +509,24 @@ async function update() {
306
509
  console.log(`\n ✅ done — now on v${installedVersion() ?? "?"}`);
307
510
  }
308
511
 
512
+ /**
513
+ * v1.9: when any account opts into the direct route, store any profile that
514
+ * is missing and bring the order into health order as part of the update —
515
+ * the same `direct sync` the operator can run on its own. Skipped silently
516
+ * when nothing is configured, so updates for CLI-only users are unchanged.
517
+ */
518
+ async function offerDirectSync() {
519
+ const config = readOpenclawConfigSync();
520
+ const accounts = config?.plugins?.entries?.["multi-clawd"]?.config?.accounts ?? [];
521
+ if (!accounts.some((a) => a && a.direct !== undefined && a.direct !== false)) return;
522
+ if (!(await askYes("\n Sync direct-route (anthropic/*) profiles and order now?"))) {
523
+ console.log(` ${DIM}Skipped — run \`multi-clawd direct sync\` when you're ready.${RESET}`);
524
+ return;
525
+ }
526
+ const r = spawnSync(process.execPath, [join(__dirname, "cli.mjs"), "direct", "sync", "--yes"], { stdio: "inherit" });
527
+ if (r.status !== 0) console.log(" ⚠ direct sync reported problems — see above (`multi-clawd direct` for status).");
528
+ }
529
+
309
530
  /**
310
531
  * `update` upgrades the PLUGIN; this finishes the job by offering to upgrade
311
532
  * the CLI too, so "update" means what a user reasonably assumes it means.
@@ -475,9 +696,17 @@ async function explain() {
475
696
  /* no sticky state */
476
697
  }
477
698
  }
699
+ // v1.9: the direct anthropic/* route, when any account opted in.
700
+ let direct;
701
+ const dctx = await loadDirect(config);
702
+ if (dctx) {
703
+ const status = await dctx.gather(dctx.agents[0]);
704
+ direct = status?.explain;
705
+ for (const e of status?.errors ?? []) console.log(`${DIM}(direct route: ${e})${RESET}`);
706
+ }
478
707
  console.log(`\n${BOLD}🦞 multi-clawd — your setup, in plain English${RESET}\n`);
479
708
  console.log(
480
- ec.renderExplanation({ accounts, pool, chain, health: healthRows, stickyAccount, nowMs: now }),
709
+ ec.renderExplanation({ accounts, pool, chain, health: healthRows, stickyAccount, nowMs: now, direct }),
481
710
  );
482
711
  console.log(`\n${DIM}(health checks: multi-clawd doctor · change things: multi-clawd setup)${RESET}`);
483
712
  }
@@ -599,6 +828,9 @@ switch (cmd) {
599
828
  case "chain":
600
829
  await chain(rest);
601
830
  break;
831
+ case "direct":
832
+ await direct(rest);
833
+ break;
602
834
  case "doctor":
603
835
  runSibling("doctor.mjs", rest);
604
836
  break;
@@ -527,6 +527,14 @@ if (!pool) {
527
527
  "cli",
528
528
  );
529
529
  const poolId = pool.id ?? "clawd";
530
+ // v1.9: anthropic/* rungs are pooled when the direct route serves two accounts.
531
+ let directPooled = false;
532
+ try {
533
+ const dr = await importDist("direct-route.js", ["directRoutePools"], "cli");
534
+ directPooled = dr.directRoutePools(accounts, pluginConfig.directRoute);
535
+ } catch {
536
+ /* dist predates the direct route */
537
+ }
530
538
  // Session keys embed the operator's private channel id (e.g. a Telegram chat
531
539
  // id). Mask the id tail by default so doctor output is safe to paste into
532
540
  // issues/support threads; `--raw` (hoisted, shared with the login identities
@@ -536,7 +544,7 @@ if (!pool) {
536
544
  RAW ? surface : surface.replace(/^session (.+)$/, (_m, k) => `session ${maskSessionKey(k)}`);
537
545
 
538
546
  // ── case 1: config-level refs ──────────────────────────────────────────────
539
- const findings = auditEffectiveChain(config, poolId);
547
+ const findings = auditEffectiveChain(config, poolId, { directPooled });
540
548
  const warns = findings.filter((f) => f.severity === "warn");
541
549
  const notes = findings.filter((f) => f.severity === "note");
542
550
  for (const f of warns) warn(`${f.surface}: ${f.ref} ${f.reason}`);
@@ -593,7 +601,7 @@ if (!pool) {
593
601
  if (read.skippedRows > 0) {
594
602
  warn(`${location.path}: ${read.skippedRows} session row(s) unparseable — those sessions were not audited`);
595
603
  }
596
- const sessionFindings = auditSessionOverrides(read.entries, true);
604
+ const sessionFindings = auditSessionOverrides(read.entries, true, { directPooled });
597
605
  for (const f of sessionFindings) {
598
606
  warn(`${renderSurface(f.surface)}: ${f.ref} ${f.reason}`);
599
607
  sessionWarns++;
@@ -703,6 +711,113 @@ console.log("chain auth");
703
711
  }
704
712
  }
705
713
 
714
+
715
+ // ── 8b. direct route — anthropic/* served by the pool's accounts (v1.9) ────
716
+ //
717
+ // Only when an account opts in with `direct`; otherwise this prints nothing,
718
+ // so doctor output for CLI-only setups is unchanged. Everything is read
719
+ // through OpenClaw's own CLI (auth list / order get / models status); token
720
+ // files are shape-checked without ever printing their contents.
721
+ if (accounts.some((a) => a && a.direct !== undefined && a.direct !== false)) {
722
+ console.log("direct route (anthropic/*)");
723
+ try {
724
+ const dr = await importDist("direct-route.js", ["collectDirectMembers"], "cli");
725
+ const rep = await importDist("direct-report.js", ["gatherDirectStatus", "parseProbeResults", "probeArgs"], "cli");
726
+ const run = await importDist("openclaw-runner.js", ["createOpenclawRunner"], "cli");
727
+ const shim = await importDist("shim-core.js", ["parseStoredState"], "cli");
728
+ const hc = await importDist("hermes-core.js", ["parseClaudeSetupToken"], "cli");
729
+ const directRoute = pluginConfig.directRoute ?? {};
730
+ const agents = Array.isArray(directRoute.agents) && directRoute.agents.length > 0 ? directRoute.agents : ["main"];
731
+ const runner = run.createOpenclawRunner(directRoute.openclawCommand ?? "openclaw");
732
+ const { members } = dr.collectDirectMembers(accounts, pool?.accounts ?? []);
733
+ // Credential sources, checked locally: present, private, setup-token shaped.
734
+ for (const m of members) {
735
+ if (m.source.kind !== "file") continue;
736
+ const path = expandHome(m.source.path);
737
+ try {
738
+ const mode = statSync(path).mode & 0o777;
739
+ hc.parseClaudeSetupToken(readFileSync(path, "utf8"));
740
+ if (mode & 0o077) warn(`${m.accountId}: direct token file ${path} is mode ${mode.toString(8)} — chmod 600 it`);
741
+ else ok(`${m.accountId}: direct token file present, 600, setup-token shaped`);
742
+ } catch (err) {
743
+ bad(`${m.accountId}: direct token file ${path} unusable — ${String(err?.message ?? err).slice(0, 160)}`);
744
+ }
745
+ }
746
+ const readHealth = (id) => {
747
+ try {
748
+ return shim.parseStoredState(readFileSync(join(STATE_DIR, `${id}.json`), "utf8"));
749
+ } catch {
750
+ return undefined;
751
+ }
752
+ };
753
+ for (const agentId of agents) {
754
+ const status = await rep.gatherDirectStatus({
755
+ accounts,
756
+ poolAccounts: pool?.accounts ?? [],
757
+ agentId,
758
+ runner,
759
+ readHealth,
760
+ healthOptions: {
761
+ utilizationThreshold: pool?.utilizationThreshold,
762
+ staleAfterMs: pool?.staleAfterMs,
763
+ rotateOnOverage: pool?.rotateOnOverage,
764
+ },
765
+ configOrder: Array.isArray(config?.auth?.order?.anthropic) ? config.auth.order.anthropic : undefined,
766
+ sticky: readJson(join(STATE_DIR, "direct-order.sticky.json")),
767
+ minDwellMs: pool?.minDwellMs,
768
+ nowMs: Date.now(),
769
+ });
770
+ if (!status) continue;
771
+ for (const p of status.explain.problems) bad(`${p.accountId}: asked for the direct route but cannot join it — ${p.reason}`);
772
+ for (const e of status.errors) warn(`agent ${agentId}: direct-route check partly SKIPPED — ${e}`);
773
+ for (const m of status.explain.members) {
774
+ const v = status.verdicts.find((x) => x.accountId === m.accountId)?.verdict ?? "no_data";
775
+ if (m.stored === false) {
776
+ bad(
777
+ m.adopted
778
+ ? `${m.accountId}: adopted profile ${m.profileId} is NOT stored for agent ${agentId} — store it yourself (openclaw models auth paste-token --provider anthropic --profile-id ${m.profileId}) or fix direct.profileId`
779
+ : `${m.accountId}: ${m.profileId} is NOT stored for agent ${agentId} — run \`multi-clawd direct sync\``,
780
+ );
781
+ }
782
+ else if (m.stored === true) ok(`${m.accountId}: ${m.profileId} stored for agent ${agentId} (health: ${v})`);
783
+ if (m.cooldownUntil && m.cooldownUntil > Date.now()) {
784
+ note(`${m.profileId} is cooling down (${m.cooldownReason ?? "cooldown"}) for ~${Math.ceil((m.cooldownUntil - Date.now()) / 60000)}m — OpenClaw skips it until then`);
785
+ }
786
+ }
787
+ if (directRoute.manageOrder === false) {
788
+ note(`agent ${agentId}: order management is off (directRoute.manageOrder: false)`);
789
+ } else if (status.pendingOrder) {
790
+ warn(
791
+ `agent ${agentId}: anthropic order is not in pool-health order yet — the gateway applies ${status.pendingOrder.join(" → ")} within a minute once this version is loaded (or run \`multi-clawd direct sync\`)`,
792
+ );
793
+ } else if (status.errors.length === 0) {
794
+ ok(`agent ${agentId}: anthropic order is in pool-health order (${(status.explain.order ?? []).join(" → ")})`);
795
+ }
796
+ const unmanaged = (status.explain.order ?? []).filter((id) => !status.explain.members.some((m) => m.profileId === id));
797
+ if (unmanaged.length > 0) {
798
+ note(`agent ${agentId}: ${unmanaged.join(", ")} not managed by multi-clawd — kept after the pool's accounts`);
799
+ }
800
+ // Live probe: one tiny call per stored managed profile.
801
+ if (args.has("--probe") && !args.has("--probe-pool")) {
802
+ const ids = status.explain.members.filter((m) => m.stored).map((m) => m.profileId);
803
+ if (ids.length > 0) {
804
+ const r = await runner(rep.probeArgs(agentId, ids), { timeoutMs: 180000 });
805
+ const results = r.code === 0 ? rep.parseProbeResults(r.stdout) : undefined;
806
+ if (!results) warn(`agent ${agentId}: direct-route probe returned nothing parseable (exit ${r.code})`);
807
+ for (const id of ids) {
808
+ const res = results?.find((x) => x.profileId === id);
809
+ if (!res) continue;
810
+ if (res.status === "ok") ok(`${id}: live probe answered via the direct API`);
811
+ else bad(`${id}: live probe ${res.status}${res.error ? ` — ${res.error}` : ""}`);
812
+ }
813
+ }
814
+ }
815
+ }
816
+ } catch (err) {
817
+ warn(`direct-route check SKIPPED — ${String(err?.message ?? err).split("\n")[0].slice(0, 160)}`);
818
+ }
819
+ }
820
+
706
821
  // ── 9. watchdog ─────────────────────────────────────────────────────────────
707
822
  console.log("eviction watchdog");
708
823
  let watchdogFound = false;
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, existingAccountDefaults, looksLikeSecretRef } = core;
44
+ const { buildMainAccount, buildSecondAccount, buildPool, validateSecondConfigDir, planFromExisting, mergeSetupIntoConfig, existingAccountDefaults, looksLikeSecretRef, buildDirectSetting, canReuseCliTokenForDirect } = 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
@@ -210,6 +210,70 @@ if (accounts.length === 0 && state.accountIds.length === 0) {
210
210
  process.exit(0);
211
211
  }
212
212
 
213
+ // ── direct route (v1.9, optional) ────────────────────────────────────────────
214
+ // The gateway's own anthropic/* models call the Anthropic API directly with an
215
+ // OpenClaw auth profile. Opting accounts in lets the pool steer those turns too
216
+ // (by keeping the auth-profile order in pool-health order). Default NO: an
217
+ // account without `direct` behaves exactly as before.
218
+ let directConfigured = false;
219
+ if (typeof buildDirectSetting === "function" &&
220
+ (await yes("\nAlso serve anthropic/* models (OpenClaw's direct Anthropic route) from these accounts?", false))) {
221
+ const byId = new Map(accounts.map((a) => [a.id, a]));
222
+ const existingAccounts = existing?.plugins?.entries?.["multi-clawd"]?.config?.accounts ?? [];
223
+ for (const id of [...new Set([...accounts.map((a) => a.id), ...state.accountIds])]) {
224
+ const current = { ...(existingAccounts.find((a) => a?.id === id) ?? {}), ...(byId.get(id) ?? {}) };
225
+ if (current.direct !== undefined) {
226
+ console.log(` ${id}: already has a direct-route setting — kept.`);
227
+ continue;
228
+ }
229
+ if (!(await yes(` Add "${id}" to the direct route?`, true))) continue;
230
+ let setting;
231
+ if (canReuseCliTokenForDirect(current) && (await yes(` reuse ${id}'s existing setup-token for it?`, true))) {
232
+ setting = buildDirectSetting(current, { kind: "reuse" });
233
+ } else {
234
+ const how = current.configDir
235
+ ? `CLAUDE_CONFIG_DIR=${current.configDir} claude setup-token`
236
+ : "claude setup-token";
237
+ console.log(`
238
+ ${id} needs a setup-token of its own for the direct route — its Claude login
239
+ is a rotating grant that multi-clawd never copies. In your own terminal, run:
240
+
241
+ ${how}
242
+
243
+ signed in as ${id}'s account (NOT your other one), and store the printed token:
244
+ 1) in your secret manager (RECOMMENDED — OpenClaw stores only the reference)
245
+ 2) in a token file (chmod 600)
246
+ 3) it is ALREADY stored in OpenClaw as an anthropic profile (give its id)
247
+ 4) skip ${id} for now`);
248
+ const choice = await ask(" choice (1/2/3/4):", "1");
249
+ try {
250
+ if (choice === "1") {
251
+ const provider = await ask(" gateway secret provider name:", "onepassword");
252
+ const refId = await ask(" secret reference (e.g. op://Vault/Item/field):");
253
+ if (refId) setting = buildDirectSetting(current, { kind: "ref", ref: { source: "exec", provider, id: refId } });
254
+ } else if (choice === "2") {
255
+ setting = buildDirectSetting(current, { kind: "file", path: await ask(" token file path:", `~/.${id}-direct-token`) });
256
+ } else if (choice === "3") {
257
+ const profileId = await ask(" existing profile id (see `openclaw models auth list --provider anthropic`):");
258
+ if (profileId.startsWith("anthropic:")) setting = { profileId };
259
+ else console.log(" ✗ profile ids for this route start with anthropic: — skipped");
260
+ }
261
+ } catch (err) {
262
+ console.log(` ✗ ${err.message} — skipped`);
263
+ }
264
+ }
265
+ if (!setting) {
266
+ console.log(` ${id}: not added.`);
267
+ continue;
268
+ }
269
+ const planned = byId.get(id);
270
+ if (planned) planned.direct = setting;
271
+ else accounts.push({ id, direct: setting });
272
+ directConfigured = true;
273
+ console.log(` ✅ ${id} will serve anthropic/* as profile ${setting.profileId ?? `anthropic:${id}`}`);
274
+ }
275
+ }
276
+
213
277
  // ── pool ─────────────────────────────────────────────────────────────────────
214
278
  const poolMemberIds = [...new Set([...accounts.map((a) => a.id), ...state.accountIds])];
215
279
  let pool;
@@ -400,6 +464,15 @@ if (DRY_RUN || !wroteConfig) {
400
464
  }
401
465
 
402
466
  // ── next steps ───────────────────────────────────────────────────────────────
467
+ if (directConfigured) {
468
+ console.log(`
469
+ Direct route: store the profiles in OpenClaw and set the order now with
470
+
471
+ multi-clawd direct sync
472
+
473
+ (refs are stored as SecretRefs — the token is never copied; the gateway then
474
+ keeps the anthropic order in pool-health order on its own).`);
475
+ }
403
476
  console.log(`
404
477
  Done. Finish with:
405
478