@drakon-systems/multi-clawd 1.7.4 → 1.8.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
@@ -364,7 +364,7 @@ openclaw plugins install (Get-Location).Path
364
364
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
365
365
  Code on the target machine already? Paste it this and go make coffee:
366
366
 
367
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.7.4/SETUP-AGENT.md
367
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.0/SETUP-AGENT.md
368
368
  > and follow it to set up multi-clawd on this machine. I own a second
369
369
  > Claude account — ask me when you need me to log in.
370
370
 
package/dist/alerts.js CHANGED
@@ -13,6 +13,9 @@ export function addAlert(state, alert, nowMs) {
13
13
  export function clearAlert(state, key) {
14
14
  return { alerts: state.alerts.filter((a) => a.key !== key) };
15
15
  }
16
+ export function alertKeysWithPrefix(state, prefix) {
17
+ return state.alerts.filter((a) => a.key.startsWith(prefix)).map((a) => a.key);
18
+ }
16
19
  function isLive(alert, nowMs) {
17
20
  const ttl = alert.ttlMs ?? DEFAULT_TTL_MS[alert.severity];
18
21
  return nowMs - alert.at <= ttl;
package/dist/health.js CHANGED
@@ -16,10 +16,24 @@ const PERIOD_WINDOW_PATTERN = /(^|_)(minutes?|hours?|days?|weeks?|months?)(_|$)/
16
16
  export function isPeriodWindow(window) {
17
17
  return PERIOD_WINDOW_PATTERN.test(window);
18
18
  }
19
+ export function isOverageWindow(window) {
20
+ return /overage/i.test(window);
21
+ }
19
22
  export function isWarningStatus(status) {
20
23
  return /warning/i.test(status);
21
24
  }
22
25
  export const MAX_RESET_HORIZON_MS = 8 * 24 * 60 * 60 * 1000;
26
+ export const PERIOD_RESET_LESS_BLOCK_MS = 6 * 60 * 60 * 1000;
27
+ export function resetLessBlockMs(window) {
28
+ return isShortWindow(window) ? MODEL_REJECTED_TTL_MS : PERIOD_RESET_LESS_BLOCK_MS;
29
+ }
30
+ function windowAppliesToModel(w, requestedWindowKey) {
31
+ if (w.model === undefined)
32
+ return true;
33
+ if (requestedWindowKey === undefined)
34
+ return false;
35
+ return modelWindowKey(w.model) === requestedWindowKey;
36
+ }
23
37
  export function credentialFailureFor(state, nowMs) {
24
38
  const credential = state?.credential;
25
39
  if (!credential || credential.status !== "failed")
@@ -43,6 +57,8 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
43
57
  const requestedWindowKey = requestedModel !== undefined ? modelWindowKey(requestedModel) : undefined;
44
58
  let worst = { verdict: "ok" };
45
59
  let hasLiveEvidence = false;
60
+ let overageUtilization;
61
+ let quotaUtilization;
46
62
  for (const [window, w] of Object.entries(state.windows)) {
47
63
  const resetMs = typeof w.resetsAt === "number" ? w.resetsAt * 1000 : undefined;
48
64
  const resetBearing = resetMs !== undefined && resetMs > nowMs;
@@ -78,13 +94,17 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
78
94
  reason: `${requestedModel} limit hit ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; TTL block)`,
79
95
  };
80
96
  }
81
- const fresh = resetBearing || nowMs - w.seenAt <= staleAfterMs;
97
+ const ownBlockMs = w.status === "rejected" && resetMs === undefined && isPeriodWindow(window)
98
+ ? resetLessBlockMs(window)
99
+ : 0;
100
+ const fresh = resetBearing || nowMs - w.seenAt <= Math.max(staleAfterMs, ownBlockMs);
82
101
  if (!fresh)
83
102
  continue;
84
103
  hasLiveEvidence = true;
85
104
  if (w.status === "rejected" &&
86
105
  resetBearing &&
87
106
  isPeriodWindow(window) &&
107
+ windowAppliesToModel(w, requestedWindowKey) &&
88
108
  rejectionStillAssertable(w.seenAt, nowMs)) {
89
109
  return {
90
110
  verdict: "exhausted",
@@ -92,21 +112,32 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
92
112
  reason: `${window} rejected until ${new Date(resetMs).toISOString()}`,
93
113
  };
94
114
  }
95
- if (w.status === "rejected" && resetMs === undefined && isPeriodWindow(window)) {
115
+ if (w.status === "rejected" &&
116
+ resetMs === undefined &&
117
+ isPeriodWindow(window) &&
118
+ windowAppliesToModel(w, requestedWindowKey) &&
119
+ nowMs - w.seenAt <= resetLessBlockMs(window)) {
96
120
  return {
97
121
  verdict: "exhausted",
98
- resumeAt: w.seenAt + MODEL_REJECTED_TTL_MS,
99
- reason: `${window} rejected ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; TTL block)`,
122
+ resumeAt: w.seenAt + resetLessBlockMs(window),
123
+ reason: `${window} rejected ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; ${Math.round(resetLessBlockMs(window) / 3600000)}h block)`,
100
124
  };
101
125
  }
102
- if (worst.verdict === "ok" &&
103
- typeof w.utilization === "number" &&
126
+ if (typeof w.utilization === "number" &&
104
127
  w.utilization >= threshold &&
105
128
  (resetBearing || resetMs === undefined)) {
106
- worst = {
107
- verdict: "near_limit",
108
- reason: `${window} utilization ${w.utilization} >= ${threshold}`,
109
- };
129
+ if (isOverageWindow(window) && !options.rotateOnOverage) {
130
+ overageUtilization = Math.max(overageUtilization ?? 0, w.utilization);
131
+ }
132
+ else if (worst.verdict === "ok") {
133
+ worst = {
134
+ verdict: "near_limit",
135
+ reason: `${window} utilization ${w.utilization} >= ${threshold}`,
136
+ };
137
+ }
138
+ }
139
+ if (!isOverageWindow(window) && typeof w.utilization === "number") {
140
+ quotaUtilization = Math.max(quotaUtilization ?? 0, w.utilization);
110
141
  }
111
142
  if (worst.verdict === "ok" &&
112
143
  typeof w.utilization !== "number" &&
@@ -121,6 +152,16 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
121
152
  }
122
153
  if (worst.verdict === "ok" && !hasLiveEvidence)
123
154
  return { verdict: "no_data" };
155
+ if (worst.verdict === "ok" && overageUtilization !== undefined) {
156
+ const pct = (n) => `${Math.round(n * 100)}%`;
157
+ worst = {
158
+ ...worst,
159
+ overageAdvisory: `paid overage is ${pct(overageUtilization)} spent while real quota is at ` +
160
+ `${quotaUtilization !== undefined ? pct(quotaUtilization) : "an unreported level"} — ` +
161
+ `not rotating on spill-over alone. Set pool.rotateOnOverage: true to rotate on it, ` +
162
+ `or leave it to keep using the overage you have paid for.`,
163
+ };
164
+ }
124
165
  return worst;
125
166
  }
126
167
  export function summarizeWindowUsage(state, options, nowMs) {
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { decideStickySelection } from "./sticky.js";
14
14
  import { clearCredentialFailure, mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
15
15
  import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
16
16
  import { resolveSecretRefValues } from "openclaw/plugin-sdk/secret-ref-runtime";
17
- import { addAlert, clearAlert, pendingAlertText } from "./alerts.js";
17
+ import { addAlert, alertKeysWithPrefix, clearAlert, pendingAlertText, } from "./alerts.js";
18
18
  import { buildAccountChildEnv, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
19
19
  import { diffCatalogModels, formatNewModelNotice, } from "./model-currency.js";
20
20
  import { checkAccountCredential, createRefProbeTracker, } from "./login-health.js";
@@ -69,6 +69,10 @@ let loginProbeTimer;
69
69
  function raiseAlert(alert) {
70
70
  alertState = addAlert(alertState, alert, Date.now());
71
71
  }
72
+ export function pendingOperatorAlerts(nowMs) {
73
+ ingestAlertSpool();
74
+ return pendingAlertText(alertState, nowMs);
75
+ }
72
76
  function ingestAlertSpool() {
73
77
  const spool = join(homedir(), ".openclaw", "state", "multi-clawd", "alerts-spool.jsonl");
74
78
  let raw;
@@ -476,8 +480,7 @@ export default definePluginEntry({
476
480
  const logger = api.logger;
477
481
  try {
478
482
  api.on("heartbeat_prompt_contribution", () => {
479
- ingestAlertSpool();
480
- const text = pendingAlertText(alertState, Date.now());
483
+ const text = pendingOperatorAlerts(Date.now());
481
484
  return text ? { appendContext: text } : undefined;
482
485
  });
483
486
  }
@@ -576,6 +579,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
576
579
  const options = {
577
580
  utilizationThreshold: pool.utilizationThreshold,
578
581
  staleAfterMs: pool.staleAfterMs,
582
+ rotateOnOverage: pool.rotateOnOverage,
579
583
  };
580
584
  const poolAccount = {
581
585
  id: poolId,
@@ -589,9 +593,10 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
589
593
  backend.prepareExecution = async (ctx) => {
590
594
  const now = Date.now();
591
595
  const requestedModel = canonicalModelId(ctx.modelId) ?? ctx.modelId;
592
- const verdicts = members.map((a) => ({
593
- id: a.id,
594
- health: classifyAccountHealth(readHealthState(a.id), options, now, requestedModel),
596
+ const states = members.map((a) => ({ id: a.id, state: readHealthState(a.id) }));
597
+ const verdicts = states.map(({ id, state }) => ({
598
+ id,
599
+ health: classifyAccountHealth(state, options, now, requestedModel),
595
600
  }));
596
601
  if (allCredentialFailed(verdicts.map((v) => ({ id: v.id, verdict: v.health.verdict })))) {
597
602
  const detail = verdicts
@@ -621,13 +626,33 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
621
626
  logger.info(`[multi-clawd] ${line}`);
622
627
  raiseAlert({ key: `rotation:${poolId}`, severity: "info", text: line });
623
628
  }
629
+ const exhaustionPrefix = `pool-exhausted:${poolId}:`;
630
+ for (const key of alertKeysWithPrefix(alertState, exhaustionPrefix)) {
631
+ const alertedModel = key.slice(exhaustionPrefix.length);
632
+ const stillExhausted = states.every(({ state }) => classifyAccountHealth(state, options, now, alertedModel).verdict === "exhausted");
633
+ if (!stillExhausted)
634
+ alertState = clearAlert(alertState, key);
635
+ }
624
636
  if (verdicts.every((v) => v.health.verdict === "exhausted")) {
625
637
  raiseAlert({
626
- key: `pool-exhausted:${poolId}:${requestedModel}`,
638
+ key: `${exhaustionPrefix}${requestedModel}`,
627
639
  severity: "error",
628
640
  text: `pool ${poolId}: every account is exhausted for ${requestedModel} — turns are degrading or falling through the chain`,
629
641
  });
630
642
  }
643
+ for (const v of verdicts) {
644
+ const key = `overage:${poolId}:${v.id}`;
645
+ if (v.health.overageAdvisory) {
646
+ raiseAlert({
647
+ key,
648
+ severity: "info",
649
+ text: `pool ${poolId}: account "${v.id}" — ${v.health.overageAdvisory}`,
650
+ });
651
+ }
652
+ else {
653
+ alertState = clearAlert(alertState, key);
654
+ }
655
+ }
631
656
  for (const v of verdicts) {
632
657
  const key = `credential:${poolId}:${v.id}`;
633
658
  if (v.health.verdict === "credential_failed") {
package/dist/shim-core.js CHANGED
@@ -89,6 +89,7 @@ export function parseStoredState(raw) {
89
89
  isUsingOverage: typeof w.isUsingOverage === "boolean" ? w.isUsingOverage : undefined,
90
90
  seenAt: w.seenAt,
91
91
  rawInfo: typeof w.rawInfo === "string" ? w.rawInfo : undefined,
92
+ model: typeof w.model === "string" ? w.model : undefined,
92
93
  };
93
94
  }
94
95
  let credential;
@@ -291,7 +292,7 @@ export function clearCredentialFailure(state, now) {
291
292
  credential: { status: "ok", seenAt: now },
292
293
  };
293
294
  }
294
- export function updateHealthState(state, event, now) {
295
+ export function updateHealthState(state, event, now, model) {
295
296
  const key = event.rateLimitType ?? "unknown";
296
297
  return {
297
298
  ...state,
@@ -305,6 +306,7 @@ export function updateHealthState(state, event, now) {
305
306
  isUsingOverage: event.isUsingOverage,
306
307
  seenAt: now,
307
308
  rawInfo: event.rawInfo,
309
+ model: model === undefined ? undefined : canonicalizeModelIdForWindow(model),
308
310
  },
309
311
  },
310
312
  };
package/dist/shim.js CHANGED
@@ -113,7 +113,7 @@ const scanner = createLineScanner((line) => {
113
113
  try {
114
114
  const event = parseRateLimitEvent(line);
115
115
  if (event) {
116
- state = updateHealthState(state, event, Date.now());
116
+ state = updateHealthState(state, event, Date.now(), effectiveModelId());
117
117
  persistState();
118
118
  }
119
119
  const limitHit = parseModelLimitError(line);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.7.4",
4
+ "version": "1.8.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",
@@ -51,6 +51,10 @@
51
51
  "type": "number",
52
52
  "description": "Ignore health data older than this. Default 21600000 (6h)."
53
53
  },
54
+ "rotateOnOverage": {
55
+ "type": "boolean",
56
+ "description": "Rotate when the seven_day_overage_included window (quota PLUS purchased spill-over) crosses utilizationThreshold. Default false: high utilization there means little paid overage left, not little quota left, so the pool keeps using the overage you have paid for and raises an operator alert instead of deciding for you."
57
+ },
54
58
  "minDwellMs": {
55
59
  "type": "number",
56
60
  "description": "After rotating away from home, stay on the rotated-to account at least this long before returning home (anti-flap hysteresis; health always overrides). Default 600000 (10min)."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/multi-clawd",
3
- "version": "1.7.4",
3
+ "version": "1.8.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",