@drakon-systems/multi-clawd 2.5.0 → 2.5.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
@@ -415,7 +415,7 @@ openclaw plugins install (Get-Location).Path
415
415
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
416
416
  Code on the target machine already? Paste it this and go make coffee:
417
417
 
418
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.5.0/SETUP-AGENT.md
418
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.5.1/SETUP-AGENT.md
419
419
  > and follow it to set up multi-clawd on this machine. I own a second
420
420
  > Claude account — ask me when you need me to log in.
421
421
 
@@ -633,7 +633,14 @@ a gateway reload. A turn already admitted or running continues on its selected
633
633
  account; the command does not interrupt it.
634
634
 
635
635
  `pool.strategy` defaults to `"ordered"`, which retains the configured account
636
- order. Set it to `"reset-aware"` to use healthy weekly quota before its
636
+ order. In every strategy, when no `ok` or `no_data` account is left but some
637
+ are near their limit, the launch goes to the near-limit account with the most
638
+ headroom: the lowest fresh utilization, taking the newer of the stream and
639
+ usage-poll records for each window. Equal figures keep pool order, an account
640
+ with a figure outranks one without, and two without keep pool order.
641
+ Exhausted and credential-failed accounts are never chosen. `explain` says
642
+ "all near limit, picked X at N%" and the direct Anthropic route orders its
643
+ near-limit profiles the same way. Set it to `"reset-aware"` to use healthy weekly quota before its
637
644
  `seven_day` window resets: among `ok` accounts with live seven-day reset data,
638
645
  the soonest reset wins. Accounts without that data follow in pool order;
639
646
  near-limit or exhausted accounts never outrank a healthy one. Equal reset
@@ -687,6 +694,7 @@ How it decides, per launch (all data from each account's live
687
694
  | `allowed_warning` and **no** number, on a short window | skipped (v1.7.2) — see below |
688
695
  | `allowed_warning` and no number, on the weekly window | still used — weekly warns from ~30% and would flap |
689
696
  | no data / stale data | used — never rotate on missing evidence |
697
+ | every usable account near its limit | the near-limit account with the most headroom (lowest fresh utilization) serves; equal figures and accounts without a number keep pool order |
690
698
  | whole pool exhausted | home account anyway → real limit error → your chain drops provider |
691
699
 
692
700
  **Why the short-window rule exists (v1.7.2).** (v1.10's live usage poll now
@@ -27,11 +27,13 @@ export async function gatherDirectStatus(params) {
27
27
  : undefined;
28
28
  const verdicts = members.map((m) => {
29
29
  const state = params.readHealth(m.accountId);
30
+ const health = classifyAccountHealth(state, params.healthOptions, params.nowMs);
30
31
  return {
31
32
  accountId: m.accountId,
32
33
  profileId: m.profileId,
33
34
  disabled: m.disabled,
34
- verdict: classifyAccountHealth(state, params.healthOptions, params.nowMs).verdict,
35
+ verdict: health.verdict,
36
+ utilization: health.utilization,
35
37
  sevenDayResetsAt: params.strategy === "reset-aware"
36
38
  ? sevenDayReset(state, params.healthOptions, params.nowMs)
37
39
  : undefined,
@@ -1,4 +1,5 @@
1
1
  import { isSecretRefShape } from "./token-resolution.js";
2
+ import { headroomBefore } from "./health.js";
2
3
  import { decideStickySelection } from "./sticky.js";
3
4
  import { spreadBefore } from "./spread.js";
4
5
  export const DIRECT_PROVIDER = "anthropic";
@@ -135,7 +136,7 @@ export function planDirectOrder(params) {
135
136
  if (members.length === 0)
136
137
  throw new Error("pool.accounts[].disabled: every direct member is disabled");
137
138
  const decision = decideStickySelection({
138
- verdicts: members.map((m) => ({ id: m.accountId, verdict: m.verdict, sevenDayResetsAt: m.sevenDayResetsAt, servedInWindow: m.servedInWindow, lastServedAt: m.lastServedAt })),
139
+ verdicts: members.map((m) => ({ id: m.accountId, verdict: m.verdict, sevenDayResetsAt: m.sevenDayResetsAt, utilization: m.utilization, servedInWindow: m.servedInWindow, lastServedAt: m.lastServedAt })),
139
140
  sticky: params.sticky,
140
141
  nowMs: params.nowMs,
141
142
  minDwellMs: params.minDwellMs,
@@ -151,6 +152,9 @@ export function planDirectOrder(params) {
151
152
  : 0) ||
152
153
  (params.strategy === "spread" && VERDICT_RANK[a.m.verdict] === 0
153
154
  ? (spreadBefore(a.m, b.m) ? -1 : spreadBefore(b.m, a.m) ? 1 : 0)
155
+ : 0) ||
156
+ (a.m.verdict === "near_limit" && b.m.verdict === "near_limit"
157
+ ? (headroomBefore(a.m, b.m) ? -1 : headroomBefore(b.m, a.m) ? 1 : 0)
154
158
  : 0) || a.index - b.index)
155
159
  .map(({ m }) => m.profileId);
156
160
  const managed = new Set(params.members.map((m) => m.profileId));
@@ -253,11 +253,13 @@ export function createDirectOrderController(deps) {
253
253
  : undefined;
254
254
  const verdicts = deps.members.map((m) => {
255
255
  const state = deps.readHealth(m.accountId);
256
+ const health = classifyAccountHealth(state, deps.healthOptions, nowMs);
256
257
  return {
257
258
  accountId: m.accountId,
258
259
  profileId: m.profileId,
259
260
  disabled: m.disabled,
260
- verdict: classifyAccountHealth(state, deps.healthOptions, nowMs).verdict,
261
+ verdict: health.verdict,
262
+ utilization: health.utilization,
261
263
  sevenDayResetsAt: deps.strategy === "reset-aware"
262
264
  ? sevenDayReset(state, deps.healthOptions, nowMs)
263
265
  : undefined,
@@ -155,6 +155,7 @@ export function renderExplanation(model) {
155
155
  return {
156
156
  id,
157
157
  verdict: h?.verdict ?? "no_data",
158
+ utilization: h?.utilization,
158
159
  sevenDayResetsAt: (h?.usage?.find((u) => u.window === "usage:seven_day" && u.resetsAt !== undefined && u.resetsAt > nowMs)
159
160
  ?? h?.usage?.find((u) => u.window === "seven_day" && u.resetsAt !== undefined && u.resetsAt > nowMs))?.resetsAt,
160
161
  };
@@ -167,8 +168,10 @@ export function renderExplanation(model) {
167
168
  let reason;
168
169
  if (preferred && decision.account !== preferred)
169
170
  reason = "sticky dwell keeps this usable account";
171
+ else if (picked?.verdict === "near_limit")
172
+ reason = describeNearLimitPick(verdicts, picked);
170
173
  else if (strategy === "ordered")
171
- reason = picked?.verdict === "near_limit" ? "first near-limit member in pool order" : "first healthy member in pool order";
174
+ reason = "first healthy member in pool order";
172
175
  else if (strategy === "spread")
173
176
  reason = picked && (picked.verdict === "ok" || picked.verdict === "no_data") ? describeSpreadPick(picked, windowMs) : "no healthy member; using the existing fallback";
174
177
  else if (picked?.verdict === "ok" && picked.sevenDayResetsAt !== undefined)
@@ -201,6 +204,21 @@ export function renderExplanation(model) {
201
204
  }
202
205
  return lines.join("\n");
203
206
  }
207
+ export function describeNearLimitPick(verdicts, picked) {
208
+ const nearLimit = verdicts.filter((v) => v.verdict === "near_limit");
209
+ const scope = nearLimit.length === verdicts.length ? "all near limit" : "no healthy member, the rest near limit";
210
+ const others = nearLimit.filter((v) => v.id !== picked.id);
211
+ if (picked.utilization === undefined) {
212
+ return `${scope}, picked ${picked.id} (no utilization number; pool order)`;
213
+ }
214
+ const pct = `${Math.round(picked.utilization * 100)}%`;
215
+ if (others.length === 0)
216
+ return `${scope}, picked ${picked.id} at ${pct}`;
217
+ const shown = others
218
+ .map((v) => `${v.id} ${v.utilization === undefined ? "no number" : `${Math.round(v.utilization * 100)}%`}`)
219
+ .join(", ");
220
+ return `${scope}, picked ${picked.id} at ${pct} — most headroom (${shown})`;
221
+ }
204
222
  export function renderDirectSection(direct, nowMs) {
205
223
  const lines = [];
206
224
  lines.push("DIRECT ROUTE anthropic/* (gateway → Anthropic API with each account's setup-token)");
package/dist/health.js CHANGED
@@ -20,6 +20,9 @@ export function isPeriodWindow(window) {
20
20
  export function isOverageWindow(window) {
21
21
  return /overage/i.test(window);
22
22
  }
23
+ export function logicalWindowKey(window) {
24
+ return window.startsWith("usage:") ? window.slice("usage:".length) : window;
25
+ }
23
26
  export function isLongQuotaWindow(window) {
24
27
  return /(^|_)(days?|weeks?|months?)(_|$)/.test(window) && !isOverageWindow(window);
25
28
  }
@@ -90,6 +93,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
90
93
  let hasLiveEvidence = false;
91
94
  let overageUtilization;
92
95
  let quotaUtilization;
96
+ const freshestByWindow = new Map();
93
97
  for (const [window, w] of Object.entries(state.windows)) {
94
98
  const resetMs = typeof w.resetsAt === "number" ? w.resetsAt * 1000 : undefined;
95
99
  const resetBearing = resetMs !== undefined && resetMs > nowMs;
@@ -176,6 +180,14 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
176
180
  if (!isOverageWindow(window) && typeof w.utilization === "number") {
177
181
  quotaUtilization = Math.max(quotaUtilization ?? 0, w.utilization);
178
182
  }
183
+ if (typeof w.utilization === "number" &&
184
+ (resetBearing || resetMs === undefined) &&
185
+ (!isOverageWindow(window) || options.rotateOnOverage)) {
186
+ const logical = logicalWindowKey(window);
187
+ const seen = freshestByWindow.get(logical);
188
+ if (!seen || w.seenAt > seen.seenAt)
189
+ freshestByWindow.set(logical, { utilization: w.utilization, seenAt: w.seenAt });
190
+ }
179
191
  if (worst.verdict === "ok" &&
180
192
  typeof w.utilization !== "number" &&
181
193
  isShortWindow(window) &&
@@ -189,6 +201,12 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
189
201
  }
190
202
  if (worst.verdict === "ok" && !hasLiveEvidence)
191
203
  return { verdict: "no_data" };
204
+ let utilization;
205
+ for (const { utilization: u } of freshestByWindow.values()) {
206
+ utilization = utilization === undefined ? u : Math.max(utilization, u);
207
+ }
208
+ if (utilization !== undefined)
209
+ worst = { ...worst, utilization };
192
210
  if (worst.verdict === "ok" && overageUtilization !== undefined) {
193
211
  const pct = (n) => `${Math.round(n * 100)}%`;
194
212
  worst = {
@@ -258,7 +276,24 @@ export function choosePoolAccount(pool, strategy = "ordered") {
258
276
  const usable = pool.find((a) => a.verdict === "ok" || a.verdict === "no_data");
259
277
  if (usable)
260
278
  return usable.id;
261
- return pool.find((a) => a.verdict === "near_limit")?.id;
279
+ return nearLimitWithMostHeadroom(pool)?.id;
280
+ }
281
+ export function headroomBefore(a, b) {
282
+ if (a.utilization === undefined)
283
+ return false;
284
+ if (b.utilization === undefined)
285
+ return true;
286
+ return a.utilization < b.utilization;
287
+ }
288
+ export function nearLimitWithMostHeadroom(pool) {
289
+ let best;
290
+ for (const member of pool) {
291
+ if (member.verdict !== "near_limit")
292
+ continue;
293
+ if (!best || headroomBefore(member, best))
294
+ best = member;
295
+ }
296
+ return best;
262
297
  }
263
298
  export function allCredentialFailed(pool) {
264
299
  return pool.length > 0 && pool.every((a) => a.verdict === "credential_failed");
package/dist/index.js CHANGED
@@ -850,6 +850,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
850
850
  id: v.id,
851
851
  verdict: v.health.verdict,
852
852
  observedAt: v.health.observedAt,
853
+ utilization: v.health.utilization,
853
854
  sevenDayResetsAt: strategy === "reset-aware"
854
855
  ? sevenDayReset(states.find((s) => s.id === v.id)?.state, options, now)
855
856
  : undefined,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "2.5.0",
4
+ "version": "2.5.1",
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.5.0",
3
+ "version": "2.5.1",
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/cli.mjs CHANGED
@@ -749,7 +749,8 @@ async function explain(args = []) {
749
749
  weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
750
750
  staleAfterMs: pool?.staleAfterMs,
751
751
  }, now);
752
- return { id: a.id, verdict: h.verdict, detail, usage };
752
+ // #93: the headroom figure the pool ranks near-limit members by.
753
+ return { id: a.id, verdict: h.verdict, detail, usage, utilization: h.utilization };
753
754
  });
754
755
  let stickyAccount;
755
756
  let stickyEntry;
@@ -864,13 +865,14 @@ async function usageCommand(args) {
864
865
  const h = health.classifyAccountHealth(state, healthOptions, now);
865
866
  row.verdict = h.verdict;
866
867
  row.reason = h.reason;
868
+ if (h.utilization !== undefined) row.utilization = h.utilization;
867
869
  if (pool?.strategy === "reset-aware") resetById.set(a.id, health.sevenDayReset(state, healthOptions, now));
868
870
  rows.push(row);
869
871
  }
870
872
  let next;
871
873
  if (pool) {
872
874
  const verdicts = pool.accounts.filter((id) => !accounts.find((a) => a.id === id)?.disabled).map((id) => ({ id, verdict: rows.find((r) => r.id === id)?.verdict ?? "no_data",
873
- sevenDayResetsAt: resetById.get(id) }));
875
+ utilization: rows.find((r) => r.id === id)?.utilization, sevenDayResetsAt: resetById.get(id) }));
874
876
  if (pool.strategy === "reset-aware" || pool.strategy === "spread") {
875
877
  const stickySelection = await import(resolve(__dirname, "..", "dist", "sticky.js"));
876
878
  let sticky;
@@ -635,7 +635,8 @@ else {
635
635
  };
636
636
  const rawVerdicts = members.map((id) => {
637
637
  const state = readJson(join(STATE_DIR, `${id}.json`));
638
- return { id, verdict: classifyAccountHealth(state, healthOptions, now).verdict,
638
+ const health = classifyAccountHealth(state, healthOptions, now);
639
+ return { id, verdict: health.verdict, utilization: health.utilization,
639
640
  sevenDayResetsAt: pool.strategy === "reset-aware" ? sevenDayReset(state, healthOptions, now) : undefined };
640
641
  });
641
642
  const verdicts = pool.strategy === "spread"