@drakon-systems/multi-clawd 2.1.3 → 2.2.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
@@ -386,7 +386,7 @@ openclaw plugins install (Get-Location).Path
386
386
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
387
387
  Code on the target machine already? Paste it this and go make coffee:
388
388
 
389
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.1.3/SETUP-AGENT.md
389
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.2.0/SETUP-AGENT.md
390
390
  > and follow it to set up multi-clawd on this machine. I own a second
391
391
  > Claude account — ask me when you need me to log in.
392
392
 
@@ -554,7 +554,8 @@ error.
554
554
  "pool": {
555
555
  "id": "clawd",
556
556
  "accounts": ["claw1", "claw2"], // preference order; first = home
557
- "utilizationThreshold": 0.85, // hand over at 85% of any window
557
+ "utilizationThreshold": 0.85, // default hand-over; minute/hour and overage windows use this
558
+ "weeklyUtilizationThreshold": 0.97, // optional: day/week/month quota windows at 97%
558
559
  "minDwellMs": 600000, // v0.3: anti-flap dwell before returning home
559
560
  "degrade": { // v0.3.5: last step before provider drop
560
561
  "ladder": ["claude-opus-4-8"], // whole pool exhausted → same account, lower tier
@@ -630,6 +631,7 @@ from the provider** on a timer — the same figures the Claude CLI's own
630
631
  "id": "clawd",
631
632
  "accounts": ["claw1", "claw2"],
632
633
  "utilizationThreshold": 0.85, // hand over here (unchanged)
634
+ "weeklyUtilizationThreshold": 0.97, // optional: day/week/month quota windows at 97%; overage stays at 85%
633
635
  "usagePoll": { // v1.10 — on by default
634
636
  "intervalMs": 120000, // every 2 min (minimum 60000)
635
637
  "warnThreshold": 0.95 // operator alert from here
@@ -830,6 +832,18 @@ event, and no plugin API can force a rebuild. See `DESIGN.md`.
830
832
  a usable login now reports as available, a dead one still reports
831
833
  "missing-auth", and the direct API transport keeps refusing CLI-login
832
834
  accounts as before. Update with `multi-clawd update`.
835
+ If an account's stored login is deleted or becomes unreadable, the account
836
+ can stay available until the 30-second cache window that began at its last
837
+ assessment ends; the window runs from that assessment, not from the loss.
838
+ The first availability check after the window ends starts a background
839
+ re-check, and the account stays available only while that re-check is
840
+ pending; once it finishes it marks the account unavailable. Even if the
841
+ re-check stalls, a login backed by a valid access token is refused at its
842
+ access-token expiry minus 60 seconds, including at that exact moment and
843
+ even inside the first window. A login proven only by a recent successful
844
+ turn (its stored token can already be expired) is refused at two windows
845
+ after the assessment or 24 hours after that turn, whichever comes first. A
846
+ CLI login failure recorded on a real turn removes it at once.
833
847
 
834
848
  - **Shim window persistence is sequential-safe, not concurrent-writer-safe.**
835
849
  `persistState()` in `src/shim.ts` does a read-merge-write on every save so a
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_UTILIZATION_THRESHOLD } from "./health.js";
1
2
  const WINDOW_LABELS = {
2
3
  five_hour: "5-hour",
3
4
  seven_day: "weekly",
@@ -84,11 +85,14 @@ export function renderExplanation(model) {
84
85
  }
85
86
  lines.push("");
86
87
  if (model.pool) {
87
- const pct = Math.round((model.pool.utilizationThreshold ?? 0.85) * 100);
88
+ const generalThreshold = model.pool.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
89
+ const weeklyThreshold = model.pool.weeklyUtilizationThreshold ?? generalThreshold;
88
90
  lines.push(`POOL ${model.pool.id} (${model.pool.accounts.join(" → ")})`);
89
- lines.push(` Every Claude launch runs on the first account that is NOT nearly maxed`);
90
- lines.push(` out — hand-over at ${pct}% of any rate window, home account reclaims`);
91
+ lines.push(` Every Claude launch runs on the first account below its threshold;`);
92
+ lines.push(` the home account reclaims work`);
91
93
  lines.push(` automatically once its window resets.`);
94
+ lines.push(` Thresholds: general ${Math.round(generalThreshold * 100)}%, weekly ${Math.round(weeklyThreshold * 100)}%${model.pool.weeklyUtilizationThreshold === undefined ? " (follows general)" : ""}.`);
95
+ lines.push(` The weekly threshold applies to day/week/month quota windows; overage uses the general threshold.`);
92
96
  const ladder = model.pool.degrade?.ladder ?? [];
93
97
  if (ladder.length > 0) {
94
98
  lines.push(` If the WHOLE pool is exhausted: step down to ${ladder.join(" → ")} first.`);
package/dist/health.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { modelWindowApplies, modelWindowKey } from "./shim-core.js";
2
- const DEFAULT_UTILIZATION_THRESHOLD = 0.85;
2
+ export const DEFAULT_UTILIZATION_THRESHOLD = 0.85;
3
3
  const DEFAULT_STALE_AFTER_MS = 6 * 60 * 60 * 1000;
4
4
  export const MODEL_REJECTED_TTL_MS = 60 * 60 * 1000;
5
5
  export const CREDENTIAL_FAILED_TTL_MS = 15 * 60 * 1000;
@@ -19,6 +19,9 @@ export function isPeriodWindow(window) {
19
19
  export function isOverageWindow(window) {
20
20
  return /overage/i.test(window);
21
21
  }
22
+ export function isLongQuotaWindow(window) {
23
+ return /(^|_)(days?|weeks?|months?)(_|$)/.test(window) && !isOverageWindow(window);
24
+ }
22
25
  export function isWarningStatus(status) {
23
26
  return /warning/i.test(status);
24
27
  }
@@ -75,7 +78,7 @@ export function lastAuthSuccessAt(state) {
75
78
  }
76
79
  export function classifyAccountHealth(state, options, nowMs, requestedModel) {
77
80
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
78
- const threshold = options.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
81
+ const generalThreshold = options.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
79
82
  if (!state)
80
83
  return { verdict: "no_data" };
81
84
  const credentialFailure = credentialFailureFor(state, nowMs);
@@ -153,6 +156,9 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
153
156
  reason: `${window} rejected ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; ${Math.round(resetLessBlockMs(window) / 3600000)}h block)`,
154
157
  };
155
158
  }
159
+ const threshold = isLongQuotaWindow(window)
160
+ ? options.weeklyUtilizationThreshold ?? generalThreshold
161
+ : generalThreshold;
156
162
  if (typeof w.utilization === "number" &&
157
163
  w.utilization >= threshold &&
158
164
  (resetBearing || resetMs === undefined)) {
package/dist/index.js CHANGED
@@ -805,6 +805,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
805
805
  }
806
806
  const options = {
807
807
  utilizationThreshold: pool.utilizationThreshold,
808
+ weeklyUtilizationThreshold: pool.weeklyUtilizationThreshold,
808
809
  staleAfterMs: pool.staleAfterMs,
809
810
  rotateOnOverage: pool.rotateOnOverage,
810
811
  };
@@ -1040,6 +1041,7 @@ export function startDirectOrderSync(params) {
1040
1041
  const intervalMs = Math.max(15_000, params.directRoute?.intervalMs ?? 60_000);
1041
1042
  const healthOptions = {
1042
1043
  utilizationThreshold: params.pool?.utilizationThreshold,
1044
+ weeklyUtilizationThreshold: params.pool?.weeklyUtilizationThreshold,
1043
1045
  staleAfterMs: params.pool?.staleAfterMs,
1044
1046
  rotateOnOverage: params.pool?.rotateOnOverage,
1045
1047
  };
@@ -1137,6 +1139,7 @@ export function startUsagePoll(params) {
1137
1139
  const warnThreshold = effectiveUsageWarnThreshold(pool.usagePoll);
1138
1140
  const healthOptions = {
1139
1141
  utilizationThreshold: pool.utilizationThreshold,
1142
+ weeklyUtilizationThreshold: pool.weeklyUtilizationThreshold,
1140
1143
  staleAfterMs: pool.staleAfterMs,
1141
1144
  rotateOnOverage: pool.rotateOnOverage,
1142
1145
  };
@@ -1205,7 +1208,7 @@ export function startUsagePoll(params) {
1205
1208
  usageInitial.unref?.();
1206
1209
  usageTimer = setInterval(tick, intervalMs);
1207
1210
  usageTimer.unref?.();
1208
- logger.info(`[multi-clawd] usage poll: reading live usage for ${members.map((m) => m.id).join(", ")} every ${Math.round(intervalMs / 1000)}s — rotate at ${Math.round((pool.utilizationThreshold ?? 0.85) * 100)}%, warn at ${Math.round(warnThreshold * 100)}%${skipped.length > 0 ? `; not polled: ${skipped.join("; ")}` : ""}`);
1211
+ logger.info(`[multi-clawd] usage poll: reading live usage for ${members.map((m) => m.id).join(", ")} every ${Math.round(intervalMs / 1000)}s — rotate at ${Math.round((pool.utilizationThreshold ?? 0.85) * 100)}%${pool.weeklyUtilizationThreshold !== undefined ? ` (weekly ${Math.round(pool.weeklyUtilizationThreshold * 100)}%)` : ""}, warn at ${Math.round(warnThreshold * 100)}%${skipped.length > 0 ? `; not polled: ${skipped.join("; ")}` : ""}`);
1209
1212
  return { active: true, members: members.map((m) => m.id) };
1210
1213
  }
1211
1214
  export async function runUsagePollTickNow() {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "2.1.3",
4
+ "version": "2.2.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",
@@ -47,6 +47,12 @@
47
47
  "type": "number",
48
48
  "description": "Hand over when any rate-limit window's utilization reaches this fraction. Default 0.85."
49
49
  },
50
+ "weeklyUtilizationThreshold": {
51
+ "type": "number",
52
+ "minimum": 0,
53
+ "maximum": 1,
54
+ "description": "Hand over when a day/week/month-scale quota window (such as seven_day) reaches this fraction. Defaults to utilizationThreshold. Minute/hour and overage windows keep using utilizationThreshold; overage rotation still requires rotateOnOverage."
55
+ },
50
56
  "staleAfterMs": {
51
57
  "type": "number",
52
58
  "description": "Ignore health data older than this. Default 21600000 (6h)."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "2.1.3",
3
+ "version": "2.2.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. 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
@@ -200,6 +200,7 @@ async function loadDirect(config) {
200
200
  const runner = run.createOpenclawRunner(directRoute.openclawCommand ?? "openclaw");
201
201
  const healthOptions = {
202
202
  utilizationThreshold: pool?.utilizationThreshold,
203
+ weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
203
204
  staleAfterMs: pool?.staleAfterMs,
204
205
  rotateOnOverage: pool?.rotateOnOverage,
205
206
  };
@@ -676,6 +677,7 @@ async function explain() {
676
677
  }
677
678
  const h = health.classifyAccountHealth(state, {
678
679
  utilizationThreshold: pool?.utilizationThreshold,
680
+ weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
679
681
  staleAfterMs: pool?.staleAfterMs,
680
682
  }, now);
681
683
  let detail = h.reason;
@@ -684,6 +686,7 @@ async function explain() {
684
686
  }
685
687
  const usage = health.summarizeWindowUsage(state, {
686
688
  utilizationThreshold: pool?.utilizationThreshold,
689
+ weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
687
690
  staleAfterMs: pool?.staleAfterMs,
688
691
  }, now);
689
692
  return { id: a.id, verdict: h.verdict, detail, usage };
@@ -742,11 +745,13 @@ async function usageCommand(args) {
742
745
  const pool = pc.pool ? { ...pc.pool, id: pc.pool.id?.trim() || "clawd", accounts: pc.pool.accounts ?? [] } : undefined;
743
746
  const healthOptions = {
744
747
  utilizationThreshold: pool?.utilizationThreshold,
748
+ weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
745
749
  staleAfterMs: pool?.staleAfterMs,
746
750
  rotateOnOverage: pool?.rotateOnOverage,
747
751
  };
748
752
  const warnThreshold = up.effectiveUsageWarnThreshold(pool?.usagePoll);
749
753
  const rotateAt = pool?.utilizationThreshold ?? 0.85;
754
+ const weeklyRotateAt = pool?.weeklyUtilizationThreshold;
750
755
  const stateDir = join(homedir(), ".openclaw", "state", "multi-clawd");
751
756
  const now = Date.now();
752
757
  const rows = [];
@@ -797,13 +802,17 @@ async function usageCommand(args) {
797
802
  const mins = Math.max(0, Math.round((resetsAt * 1000 - now) / 60000));
798
803
  return mins < 90 ? ` (resets in ~${mins}m)` : mins < 36 * 60 ? ` (resets in ~${Math.round(mins / 60)}h)` : ` (resets in ~${Math.round(mins / 1440)}d)`;
799
804
  };
800
- const mark = (u) => (u >= 1 ? "⛔" : u >= warnThreshold ? "🔴" : u >= rotateAt ? "🟠" : "🟢");
801
- console.log(`\n${BOLD}🦞 multi-clawd — live usage${RESET} ${DIM}(rotate at ${pct(rotateAt)}, warn at ${pct(warnThreshold)})${RESET}\n`);
805
+ const mark = (u, window) => {
806
+ const weekly = window && health.isLongQuotaWindow(window);
807
+ const threshold = weekly ? weeklyRotateAt ?? rotateAt : rotateAt;
808
+ return u >= 1 ? "⛔" : u >= warnThreshold ? "🔴" : u >= threshold ? "🟠" : "🟢";
809
+ };
810
+ console.log(`\n${BOLD}🦞 multi-clawd — live usage${RESET} ${DIM}(rotate at ${pct(rotateAt)}${weeklyRotateAt !== undefined ? `, weekly ${pct(weeklyRotateAt)}` : ""}, warn at ${pct(warnThreshold)})${RESET}\n`);
802
811
  for (const r of rows) {
803
812
  console.log(` ${BOLD}${r.id}${RESET}${r.label ? ` ${DIM}— ${r.label}${RESET}` : ""} pool verdict: ${r.verdict}${r.reason ? ` ${DIM}(${r.reason})${RESET}` : ""}`);
804
813
  if (r.snapshot) {
805
814
  for (const [w, u] of Object.entries(r.snapshot.windows)) {
806
- console.log(` ${mark(u.utilization)} ${up.usageWindowLabel(w).padEnd(12)} ${pct(u.utilization).padStart(4)}${until(u.resetsAt)}`);
815
+ console.log(` ${mark(u.utilization, w)} ${up.usageWindowLabel(w).padEnd(12)} ${pct(u.utilization).padStart(4)}${until(u.resetsAt)}`);
807
816
  }
808
817
  for (const s of r.snapshot.scoped) {
809
818
  console.log(` ${mark(s.utilization)} ${`${s.label} model`.padEnd(12)} ${pct(s.utilization).padStart(4)}${until(s.resetsAt)}`);
@@ -301,6 +301,7 @@ const { summarizeWindowUsage, classifyAccountHealth, overdueProbeAccount, lastAu
301
301
  "overdueProbeAccount",
302
302
  "lastAuthSuccessAt",
303
303
  ]);
304
+ const { DEFAULT_UTILIZATION_THRESHOLD } = await import(join(REPO_DIR, "dist", "health.js"));
304
305
  // "cli", like the chain audits: resolving WHICH login backs an account is
305
306
  // doctor's own diagnostic, not a description of the running plugin's
306
307
  // behaviour. Asking the installed copy meant a fix to the resolver could not
@@ -495,6 +496,9 @@ else {
495
496
  const members = (pool.accounts ?? []).filter((id) => accounts.some((a) => a.id === id));
496
497
  if (members.length < 2) bad(`pool "${pool.id ?? "clawd"}" has ${members.length} valid member(s); needs ≥ 2`);
497
498
  else ok(`pool "${pool.id ?? "clawd"}": ${members.join(" → ")}`);
499
+ const generalThreshold = pool.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
500
+ const weeklyThreshold = pool.weeklyUtilizationThreshold ?? generalThreshold;
501
+ note(`thresholds: general ${Math.round(generalThreshold * 100)}%, weekly ${Math.round(weeklyThreshold * 100)}%${pool.weeklyUtilizationThreshold === undefined ? " (follows general)" : ""}`);
498
502
  // Setup-token members: what they do and do not get, said once, plainly.
499
503
  const tokenMembers = members
500
504
  .map((id) => accounts.find((a) => a.id === id))
@@ -529,6 +533,7 @@ else {
529
533
  const healthOptions = {
530
534
  staleAfterMs: pool.staleAfterMs,
531
535
  utilizationThreshold: pool.utilizationThreshold,
536
+ weeklyUtilizationThreshold: pool.weeklyUtilizationThreshold,
532
537
  rotateOnOverage: pool.rotateOnOverage,
533
538
  };
534
539
  const verdicts = members.map((id) => ({
@@ -876,6 +881,7 @@ if (accounts.some((a) => a && a.direct !== undefined && a.direct !== false)) {
876
881
  readHealth,
877
882
  healthOptions: {
878
883
  utilizationThreshold: pool?.utilizationThreshold,
884
+ weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
879
885
  staleAfterMs: pool?.staleAfterMs,
880
886
  rotateOnOverage: pool?.rotateOnOverage,
881
887
  },