@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 +16 -2
- package/dist/explain-core.js +7 -3
- package/dist/health.js +8 -2
- package/dist/index.js +4 -1
- package/openclaw.plugin.json +7 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +12 -3
- package/scripts/doctor.mjs +6 -0
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.
|
|
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
|
|
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
|
package/dist/explain-core.js
CHANGED
|
@@ -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
|
|
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
|
|
90
|
-
lines.push(`
|
|
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
|
|
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)}
|
|
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() {
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "2.
|
|
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.
|
|
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) =>
|
|
801
|
-
|
|
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)}`);
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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
|
},
|