@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/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 chain # audit your model routing — what actually serves each turn
34
+ multi-clawd direct # the direct anthropic/* route: status, or `direct sync` (v1.9, optional)
34
35
  multi-clawd doctor # health check (add --probe for a live end-to-end turn)
35
36
  ```
36
37
 
@@ -241,6 +242,11 @@ claude-cli/claude-fable-5 # main login
241
242
  - 🧲 **Sticky rotation (v0.3)** — after handing over, the pool dwells on the
242
243
  spare account (default 10 min) before returning home, so turns never flap
243
244
  across the threshold. Health always overrides stickiness.
245
+ - 🔁 **In-turn retry on a reactive limit** — when an account hits a model cap
246
+ *during* a launch, the pool no longer loses that turn to the next provider in
247
+ your chain: the refusal is swallowed before anything reaches you and the turn
248
+ is re-spawned on a healthy account. Fresh launches, secret-free sibling
249
+ accounts, one retry.
244
250
  - 📟 **Operator alerts (v0.3)** — dead logins (probed every 15 min without
245
251
  spending quota), pool rotations, whole-pool exhaustion, and watchdog
246
252
  restarts surface through your agent's next heartbeat (e.g. straight into
@@ -369,7 +375,7 @@ openclaw plugins install (Get-Location).Path
369
375
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
370
376
  Code on the target machine already? Paste it this and go make coffee:
371
377
 
372
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.10/SETUP-AGENT.md
378
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.9.1/SETUP-AGENT.md
373
379
  > and follow it to set up multi-clawd on this machine. I own a second
374
380
  > Claude account — ask me when you need me to log in.
375
381
 
@@ -589,6 +595,77 @@ Notes:
589
595
  which runs on every launch on every turn path. Details in
590
596
  [`DESIGN.md`](./DESIGN.md).
591
597
 
598
+ ## Direct route: the pool for `anthropic/*` too (v1.9)
599
+
600
+ OpenClaw reaches Claude two ways. The pool above drives **Claude Code CLI**
601
+ backends (`clawd/…`). OpenClaw's own **`anthropic/*`** models skip the CLI and
602
+ call the Anthropic API directly, with an auth profile. A `claude setup-token`
603
+ works for both, and both use up the same account limits.
604
+
605
+ From v1.9 the pool can steer both. Opt accounts in with `direct`:
606
+
607
+ ```jsonc
608
+ "accounts": [
609
+ // native login: give it its own setup-token for the API route
610
+ { "id": "claw1", "native": true,
611
+ "direct": { "tokenRef": { "source": "exec", "provider": "onepassword",
612
+ "id": "op://YourVault/claw1-setup-token/password" } } },
613
+ // already has a setup-token for the CLI: reuse it
614
+ { "id": "claw2", "configDir": "~/.claw2",
615
+ "oauthTokenRef": { "source": "exec", "provider": "onepassword",
616
+ "id": "op://YourVault/claw2-setup-token/password" },
617
+ "direct": true }
618
+ ],
619
+ "directRoute": { "agents": ["main"] } // optional; default ["main"]
620
+ ```
621
+
622
+ Then run `multi-clawd direct sync`. It does two things:
623
+
624
+ 1. **Stores one OpenClaw profile per account** (`anthropic:claw1`,
625
+ `anthropic:claw2`), using OpenClaw's own commands:
626
+ - A **secret reference** is stored as a reference (`openclaw secrets
627
+ apply`). OpenClaw resolves it at runtime, so the token is never copied.
628
+ - A **token file** is piped to `openclaw models auth paste-token` on stdin.
629
+ - Profiles that already exist are left alone; `--resync` rewrites them.
630
+ 2. **Sets the `anthropic` auth order** to match pool health.
631
+
632
+ The plugin then keeps that order up to date inside the gateway, re-checking
633
+ every minute:
634
+
635
+ - The healthiest account goes first; a nearly-maxed one moves to the back
636
+ **before** it errors.
637
+ - The rule is the same one the CLI pool uses, and it reads the same health
638
+ data, so both routes lean on the same account at the same time.
639
+ - OpenClaw's own rotation covers the rest mid-turn: a 429 cools that profile
640
+ down and the turn moves to the next one.
641
+
642
+ What to know:
643
+
644
+ - **Why a native or config-dir login needs its own token.** That login is a
645
+ rotating one-time OAuth grant, and a copy of it breaks on the next refresh.
646
+ multi-clawd never reads Claude's own credential files. Run `claude
647
+ setup-token` signed in as that account and store the token. `setup` walks
648
+ you through it.
649
+ - **Already stored a profile yourself?** Adopt it with
650
+ `"direct": { "profileId": "anthropic:<id>" }`. It gets ordered, never
651
+ rewritten.
652
+ - **Existing sessions stay on their account.** OpenClaw pins a profile to
653
+ each session. A new order applies to new sessions, and to a session whose
654
+ profile cools down. A pinned session moves when its account actually hits
655
+ its limit, and the next account in line is by then the healthiest.
656
+ - **Your own profiles are kept.** Anything in the `anthropic` order that
657
+ multi-clawd doesn't manage stays there, after the pool's accounts.
658
+ `"directRoute": { "manageOrder": false }` hands the order back to you.
659
+ - **Health comes from CLI turns only.** Traffic that only uses the API route
660
+ doesn't update it. With no recent CLI turns an account counts as healthy,
661
+ and OpenClaw's reactive rotation does the work.
662
+ - **Nothing changes without `direct`.** No timer, no OpenClaw calls, and the
663
+ same `explain`/`chain`/`doctor` output as before.
664
+
665
+ `multi-clawd direct` shows, per account: the profile, whether it's stored,
666
+ any cooldown, and the live order. `explain` shows the same, and `doctor
667
+ --probe` makes one tiny live call per profile.
668
+
592
669
  ## How it works
593
670
 
594
671
  Three moves, all through the official plugin SDK (details in
@@ -732,6 +809,9 @@ Early but real — built for and dogfooded in production.
732
809
  for each account shape, verified afterwards (signed-in email shown, token
733
810
  values never touched); ClawHub package published under
734
811
  `@drakon-systems` ✅
812
+ - **v1.9** — one pool, both transports: accounts can also serve OpenClaw's
813
+ direct `anthropic/*` route; the gateway keeps the `anthropic` auth order in
814
+ pool-health order; `multi-clawd direct` / `direct sync` ✅
735
815
  - **Next** — standalone localhost proxy (OpenAI-compatible) so Hermes and
736
816
  custom runtimes can share the pool; true per-session affinity; local
737
817
  five-hour-window signal (turn counting); per-account lock for the shim
package/SECURITY.md CHANGED
@@ -57,6 +57,7 @@ is listed here so you can check them yourself.
57
57
  | `dist/watchdog-schedule.js` | `node <bundled script>` | Runs the eviction watchdog on a timer. Path is the package's own script. |
58
58
  | `scripts/cli.mjs` | `node <bundled script>` | The CLI dispatching to its own subcommands. |
59
59
  | `scripts/setup.mjs` | `launchctl` / `systemctl` | Loads the watchdog timer during setup on macOS/Linux. |
60
+ | `dist/openclaw-runner.js` | the `openclaw` CLI (or `directRoute.openclawCommand`) | Direct route only (v1.9, accounts with `direct`): reads and writes the `anthropic` auth profiles and order through OpenClaw's own subcommands (`models auth list`/`order get`/`order set`/`paste-token`, `secrets apply`, `models status`). A setup-token only ever travels on the child's stdin. Never spawned without `direct` configured. |
60
61
 
61
62
  None of these pass a string to a shell — they are direct process spawns with
62
63
  argument arrays, so there is no quoting or injection surface. Paths are
@@ -47,3 +47,8 @@ export function validateAccountTokenSources(account) {
47
47
  `account "${account.id}" declares ${sources.join(" + ")} — token sources are mutually exclusive; precedence applied is ${sources.includes("native") ? "native" : "oauthTokenFile"} first. Remove the extras.`,
48
48
  ];
49
49
  }
50
+ export function accountConfigDir(account) {
51
+ if (!account.native && account.configDir)
52
+ return expandHomePath(account.configDir);
53
+ return resolve(homedir(), ".claude");
54
+ }
@@ -27,7 +27,7 @@ function parseRef(ref) {
27
27
  return { modelId: ref };
28
28
  return { provider: ref.slice(0, idx).trim().toLowerCase(), modelId: ref.slice(idx + 1) };
29
29
  }
30
- export function offPoolClaudeRef(ref, poolId) {
30
+ export function offPoolClaudeRef(ref, poolId, opts = {}) {
31
31
  if (typeof ref !== "string" || ref.length === 0)
32
32
  return null;
33
33
  const { provider, modelId } = parseRef(ref);
@@ -37,14 +37,16 @@ export function offPoolClaudeRef(ref, poolId) {
37
37
  return null;
38
38
  if (provider === poolId.trim().toLowerCase())
39
39
  return null;
40
+ if (provider === "anthropic" && opts.directPooled)
41
+ return null;
40
42
  if (provider === "anthropic" || provider === "claude-cli")
41
43
  return "strong";
42
44
  if (ACCOUNT_PIN_RE.test(provider))
43
45
  return "warn";
44
46
  return null;
45
47
  }
46
- function classifyBypass(ref, poolId) {
47
- const severity = offPoolClaudeRef(ref, poolId);
48
+ function classifyBypass(ref, poolId, opts) {
49
+ const severity = offPoolClaudeRef(ref, poolId, opts);
48
50
  if (!severity)
49
51
  return null;
50
52
  const { provider } = parseRef(ref);
@@ -170,12 +172,12 @@ export function collectChainRefs(config) {
170
172
  }
171
173
  return out;
172
174
  }
173
- export function auditEffectiveChain(config, poolId) {
175
+ export function auditEffectiveChain(config, poolId, opts = {}) {
174
176
  if (!poolId)
175
177
  return [];
176
178
  const findings = [];
177
179
  for (const { surface, ref, allowlist } of collectChainRefs(config)) {
178
- const reason = classifyBypass(ref, poolId);
180
+ const reason = classifyBypass(ref, poolId, opts);
179
181
  if (!reason)
180
182
  continue;
181
183
  findings.push({ surface, ref, severity: allowlist ? "note" : "warn", reason });
@@ -257,7 +259,7 @@ export function auditChainShadowing(config) {
257
259
  return findings;
258
260
  }
259
261
  const POOL_PROVIDER = "clawd";
260
- export function auditSessionOverrides(sessions, poolConfigured) {
262
+ export function auditSessionOverrides(sessions, poolConfigured, opts = {}) {
261
263
  if (!poolConfigured)
262
264
  return [];
263
265
  const findings = [];
@@ -282,7 +284,7 @@ export function auditSessionOverrides(sessions, poolConfigured) {
282
284
  continue;
283
285
  }
284
286
  const ref = `${provider}/${model}`;
285
- const severity = offPoolClaudeRef(ref, POOL_PROVIDER);
287
+ const severity = offPoolClaudeRef(ref, POOL_PROVIDER, opts);
286
288
  if (!severity)
287
289
  continue;
288
290
  const reason = severity === "strong"
@@ -0,0 +1,119 @@
1
+ import { classifyAccountHealth } from "./health.js";
2
+ import { DIRECT_PROVIDER, collectDirectMembers, } from "./direct-route.js";
3
+ import { parseUnusableProfiles, planAgentOrder, readDirectRouteSnapshot, safeCliError, } from "./direct-sync.js";
4
+ export function describeDirectSource(member) {
5
+ const s = member.source;
6
+ switch (s.kind) {
7
+ case "ref":
8
+ return `setup-token via ${s.ref.provider || "a secret provider"} secret reference${s.reused ? " (the same one its CLI login uses)" : ""}`;
9
+ case "file":
10
+ return `setup-token file ${s.path}${s.reused ? " (the same one its CLI login uses)" : ""}`;
11
+ case "existing":
12
+ return "adopted profile you stored yourself";
13
+ default:
14
+ return "no direct credential";
15
+ }
16
+ }
17
+ export async function gatherDirectStatus(params) {
18
+ const { members, problems } = collectDirectMembers(params.accounts, params.poolAccounts ?? []);
19
+ if (members.length === 0 && problems.length === 0)
20
+ return undefined;
21
+ const errors = [];
22
+ const verdicts = members.map((m) => ({
23
+ accountId: m.accountId,
24
+ profileId: m.profileId,
25
+ verdict: classifyAccountHealth(params.readHealth(m.accountId), params.healthOptions, params.nowMs).verdict,
26
+ }));
27
+ const read = members.length > 0 ? await readDirectRouteSnapshot(params.runner, params.agentId) : {};
28
+ if ("error" in read && read.error)
29
+ errors.push(read.error);
30
+ const snapshot = "snapshot" in read ? read.snapshot : undefined;
31
+ let unusable = [];
32
+ if (members.length > 0 && !params.skipCooldowns) {
33
+ const status = await params.runner(["models", "status", "--agent", params.agentId, "--json"], { timeoutMs: 120_000 });
34
+ const parsed = status.code === 0 ? parseUnusableProfiles(status.stdout) : undefined;
35
+ if (parsed)
36
+ unusable = parsed;
37
+ else
38
+ errors.push(`models status ${safeCliError(status)}`);
39
+ }
40
+ let pendingOrder;
41
+ if (snapshot) {
42
+ pendingOrder = planAgentOrder({
43
+ members: verdicts,
44
+ snapshot,
45
+ configOrder: params.configOrder,
46
+ sticky: params.sticky,
47
+ nowMs: params.nowMs,
48
+ minDwellMs: params.minDwellMs,
49
+ }).order;
50
+ }
51
+ const byProfile = new Map(unusable.map((u) => [u.profileId, u]));
52
+ const order = snapshot?.storedOrder ?? (params.configOrder?.length ? [...params.configOrder] : snapshot ? [] : undefined);
53
+ const orderSource = snapshot?.storedOrder
54
+ ? `stored for agent ${params.agentId}`
55
+ : params.configOrder?.length
56
+ ? "config auth.order"
57
+ : undefined;
58
+ return {
59
+ agentId: params.agentId,
60
+ verdicts,
61
+ pendingOrder,
62
+ unusable,
63
+ errors,
64
+ explain: {
65
+ members: members.map((m) => {
66
+ const u = byProfile.get(m.profileId);
67
+ return {
68
+ accountId: m.accountId,
69
+ profileId: m.profileId,
70
+ source: describeDirectSource(m),
71
+ stored: snapshot ? snapshot.stored.has(m.profileId) : undefined,
72
+ ...(m.source.kind === "existing" ? { adopted: true } : {}),
73
+ ...(u ? { cooldownUntil: u.until, cooldownReason: u.reason ?? u.kind } : {}),
74
+ };
75
+ }),
76
+ problems: problems.map((p) => ({ accountId: p.accountId, reason: p.reason })),
77
+ order,
78
+ orderSource,
79
+ },
80
+ };
81
+ }
82
+ export function parseProbeResults(stdout) {
83
+ const start = stdout.indexOf("{");
84
+ if (start < 0)
85
+ return undefined;
86
+ let doc;
87
+ try {
88
+ doc = JSON.parse(stdout.slice(start));
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ const rows = doc?.auth?.probes?.results;
94
+ if (!Array.isArray(rows))
95
+ return undefined;
96
+ return rows
97
+ .filter((r) => typeof r?.profileId === "string" && (r.provider === undefined || r.provider === DIRECT_PROVIDER))
98
+ .map((r) => ({
99
+ profileId: r.profileId,
100
+ status: typeof r.status === "string" ? r.status : "unknown",
101
+ ...(typeof r.error === "string" ? { error: r.error.replace(/sk-ant-[A-Za-z0-9_-]+/g, "sk-ant-…").slice(0, 200) } : {}),
102
+ }));
103
+ }
104
+ export function probeArgs(agentId, profileIds) {
105
+ return [
106
+ "models",
107
+ "status",
108
+ "--agent",
109
+ agentId,
110
+ "--json",
111
+ "--probe",
112
+ "--probe-provider",
113
+ DIRECT_PROVIDER,
114
+ "--probe-profile",
115
+ profileIds.join(","),
116
+ "--probe-max-tokens",
117
+ "16",
118
+ ];
119
+ }
@@ -0,0 +1,156 @@
1
+ import { isSecretRefShape } from "./token-resolution.js";
2
+ import { decideStickySelection } from "./sticky.js";
3
+ export const DIRECT_PROVIDER = "anthropic";
4
+ export const DIRECT_PROFILE_PREFIX = `${DIRECT_PROVIDER}:`;
5
+ export const DIRECT_SETUP_TOKEN_GUIDANCE = "a native or config-dir Claude login is a rotating single-use OAuth grant — copying it into " +
6
+ "OpenClaw would invalidate one of the two copies on the next refresh. Run `claude setup-token` " +
7
+ "signed in as THIS account, store the printed token in your secret manager (or a 0600 file), " +
8
+ "and set direct.tokenRef (or direct.tokenFile) on the account — or, if a profile for this " +
9
+ 'account is already stored in OpenClaw, name it with direct.profileId';
10
+ function asRecord(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value)
12
+ ? value
13
+ : undefined;
14
+ }
15
+ export function directEnabled(account) {
16
+ const d = account.direct;
17
+ if (d === undefined || d === false)
18
+ return false;
19
+ return true;
20
+ }
21
+ export function directCredentialSource(account) {
22
+ if (!directEnabled(account))
23
+ return { kind: "none" };
24
+ const explicit = asRecord(account.direct);
25
+ const explicitRef = explicit?.tokenRef;
26
+ const explicitFile = typeof explicit?.tokenFile === "string" ? explicit.tokenFile.trim() : "";
27
+ if (explicitRef !== undefined && explicitFile) {
28
+ return {
29
+ kind: "unsupported",
30
+ code: "direct_sources_conflict",
31
+ reason: "direct.tokenRef and direct.tokenFile are mutually exclusive — keep one",
32
+ };
33
+ }
34
+ if (explicitRef !== undefined) {
35
+ if (!isSecretRefShape(explicitRef)) {
36
+ return {
37
+ kind: "unsupported",
38
+ code: "direct_ref_malformed",
39
+ reason: 'direct.tokenRef must be { "source": "...", "provider": "...", "id": "..." } like every other secret reference',
40
+ };
41
+ }
42
+ return { kind: "ref", ref: explicitRef, reused: false };
43
+ }
44
+ if (explicitFile)
45
+ return { kind: "file", path: explicitFile, reused: false };
46
+ if (typeof explicit?.profileId === "string" && explicit.profileId.trim()) {
47
+ return { kind: "existing" };
48
+ }
49
+ if (!account.native) {
50
+ if (account.oauthTokenFile) {
51
+ return { kind: "file", path: account.oauthTokenFile, reused: true };
52
+ }
53
+ if (isSecretRefShape(account.oauthTokenRef)) {
54
+ return { kind: "ref", ref: account.oauthTokenRef, reused: true };
55
+ }
56
+ }
57
+ return {
58
+ kind: "unsupported",
59
+ code: "direct_setup_token_required",
60
+ reason: DIRECT_SETUP_TOKEN_GUIDANCE,
61
+ };
62
+ }
63
+ const PROFILE_SUFFIX_RE = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
64
+ export function directProfileId(account) {
65
+ const explicit = asRecord(account.direct)?.profileId;
66
+ if (typeof explicit === "string" && explicit.trim()) {
67
+ const id = explicit.trim();
68
+ if (!id.startsWith(DIRECT_PROFILE_PREFIX) || !PROFILE_SUFFIX_RE.test(id.slice(DIRECT_PROFILE_PREFIX.length))) {
69
+ throw new Error(`account "${account.id}": direct.profileId must look like "${DIRECT_PROFILE_PREFIX}<name>" (got "${id}")`);
70
+ }
71
+ return id;
72
+ }
73
+ const suffix = account.id.trim();
74
+ if (!PROFILE_SUFFIX_RE.test(suffix)) {
75
+ throw new Error(`account "${account.id}" cannot become an auth profile id — set direct.profileId`);
76
+ }
77
+ return `${DIRECT_PROFILE_PREFIX}${suffix}`;
78
+ }
79
+ export function collectDirectMembers(accounts, poolOrder = []) {
80
+ const ordered = [];
81
+ const byId = new Map(accounts.filter((a) => a?.id).map((a) => [a.id.trim(), a]));
82
+ for (const id of poolOrder) {
83
+ const a = byId.get(id);
84
+ if (a && !ordered.includes(a))
85
+ ordered.push(a);
86
+ }
87
+ for (const a of byId.values())
88
+ if (!ordered.includes(a))
89
+ ordered.push(a);
90
+ const members = [];
91
+ const problems = [];
92
+ const seenProfiles = new Set();
93
+ for (const account of ordered) {
94
+ const source = directCredentialSource(account);
95
+ if (source.kind === "none")
96
+ continue;
97
+ if (source.kind === "unsupported") {
98
+ problems.push({ accountId: account.id, code: source.code, reason: source.reason });
99
+ continue;
100
+ }
101
+ let profileId;
102
+ try {
103
+ profileId = directProfileId(account);
104
+ }
105
+ catch (err) {
106
+ problems.push({ accountId: account.id, code: "direct_profile_invalid", reason: err.message });
107
+ continue;
108
+ }
109
+ if (seenProfiles.has(profileId)) {
110
+ problems.push({
111
+ accountId: account.id,
112
+ code: "direct_profile_duplicate",
113
+ reason: `profile ${profileId} is already used by another account`,
114
+ });
115
+ continue;
116
+ }
117
+ seenProfiles.add(profileId);
118
+ members.push({ accountId: account.id, profileId, source });
119
+ }
120
+ return { members, problems };
121
+ }
122
+ const VERDICT_RANK = {
123
+ ok: 0,
124
+ no_data: 0,
125
+ near_limit: 1,
126
+ exhausted: 2,
127
+ credential_failed: 3,
128
+ };
129
+ export function planDirectOrder(params) {
130
+ const { members } = params;
131
+ if (members.length === 0)
132
+ return undefined;
133
+ const decision = decideStickySelection({
134
+ verdicts: members.map((m) => ({ id: m.accountId, verdict: m.verdict })),
135
+ sticky: params.sticky,
136
+ nowMs: params.nowMs,
137
+ minDwellMs: params.minDwellMs,
138
+ });
139
+ const first = members.find((m) => m.accountId === decision.account) ?? members[0];
140
+ const rest = members
141
+ .filter((m) => m !== first)
142
+ .map((m, index) => ({ m, index }))
143
+ .sort((a, b) => VERDICT_RANK[a.m.verdict] - VERDICT_RANK[b.m.verdict] || a.index - b.index)
144
+ .map(({ m }) => m.profileId);
145
+ const managed = new Set(members.map((m) => m.profileId));
146
+ const unmanaged = (params.currentOrder ?? []).filter((id, i, all) => typeof id === "string" && !managed.has(id) && all.indexOf(id) === i);
147
+ const order = [first.profileId, ...rest, ...unmanaged];
148
+ const current = params.currentOrder ?? [];
149
+ const changed = order.length !== current.length || order.some((id, i) => id !== current[i]);
150
+ return { order, firstAccount: first.accountId, sticky: decision.sticky, changed };
151
+ }
152
+ export function directRoutePools(accounts, directRoute) {
153
+ if (directRoute?.manageOrder === false)
154
+ return false;
155
+ return collectDirectMembers(accounts).members.length >= 2;
156
+ }