@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 +1 -1
- package/dist/alerts.js +3 -0
- package/dist/health.js +51 -10
- package/dist/index.js +32 -7
- package/dist/shim-core.js +3 -1
- package/dist/shim.js +1 -1
- package/openclaw.plugin.json +5 -1
- package/package.json +1 -1
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.
|
|
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
|
|
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" &&
|
|
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 +
|
|
99
|
-
reason: `${window} rejected ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time;
|
|
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 (
|
|
103
|
-
typeof w.utilization === "number" &&
|
|
126
|
+
if (typeof w.utilization === "number" &&
|
|
104
127
|
w.utilization >= threshold &&
|
|
105
128
|
(resetBearing || resetMs === undefined)) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
593
|
-
|
|
594
|
-
|
|
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:
|
|
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);
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "1.
|
|
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.
|
|
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",
|