@drakon-systems/multi-clawd 1.9.8 โ 1.10.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 +83 -4
- package/dist/credential-state.js +3 -0
- package/dist/explain-core.js +2 -0
- package/dist/index.js +148 -3
- package/dist/usage-poll.js +372 -0
- package/openclaw.plugin.json +20 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +113 -0
package/README.md
CHANGED
|
@@ -223,6 +223,12 @@ claude-cli/claude-fable-5 # main login
|
|
|
223
223
|
stream-json and records per-window health to
|
|
224
224
|
`~/.openclaw/state/multi-clawd/<account>.json`. Passthrough-first: a state
|
|
225
225
|
write can never break a live turn.
|
|
226
|
+
- ๐ก **Live usage (v1.10)** โ the pool also *asks* the provider what is left,
|
|
227
|
+
on a timer, using the login each account already holds. Both windows come
|
|
228
|
+
back as real percentages with reset times โ including the 5-hour window the
|
|
229
|
+
stream never puts a number on โ so hand-over happens at 85% of the session
|
|
230
|
+
window, not at the error. Passing 95% raises an operator alert through the
|
|
231
|
+
heartbeat; `multi-clawd usage` shows it on demand.
|
|
226
232
|
- ๐งฐ **Full harness on every hop** โ each backend is a genuine Claude Code
|
|
227
233
|
subprocess: native tools, skills, MCP bridge, and native compaction all
|
|
228
234
|
stay intact when failover steps across accounts.
|
|
@@ -376,7 +382,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
376
382
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
377
383
|
Code on the target machine already? Paste it this and go make coffee:
|
|
378
384
|
|
|
379
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.
|
|
385
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.10.1/SETUP-AGENT.md
|
|
380
386
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
381
387
|
> Claude account โ ask me when you need me to log in.
|
|
382
388
|
|
|
@@ -574,9 +580,11 @@ How it decides, per launch (all data from each account's live
|
|
|
574
580
|
| no data / stale data | used โ never rotate on missing evidence |
|
|
575
581
|
| whole pool exhausted | home account anyway โ real limit error โ your chain drops provider |
|
|
576
582
|
|
|
577
|
-
**Why the short-window rule exists (v1.7.2).**
|
|
578
|
-
|
|
579
|
-
|
|
583
|
+
**Why the short-window rule exists (v1.7.2).** (v1.10's live usage poll now
|
|
584
|
+
supplies the missing number โ see below โ so this rule is the fallback for
|
|
585
|
+
hosts where polling is off or the account cannot be polled.) The threshold
|
|
586
|
+
rule needs a utilization number, and Anthropic does not send one for the
|
|
587
|
+
5-hour session window โ across every observation we hold from two accounts it arrives as a
|
|
580
588
|
bare status plus a reset time, while the weekly windows carry percentages. A
|
|
581
589
|
rule that waits for a number therefore could never pre-empt the session
|
|
582
590
|
limit: the pool would take the hit and rotate afterwards. So on hour-scoped
|
|
@@ -596,6 +604,77 @@ Notes:
|
|
|
596
604
|
which runs on every launch on every turn path. Details in
|
|
597
605
|
[`DESIGN.md`](./DESIGN.md).
|
|
598
606
|
|
|
607
|
+
## Live usage: ask, don't wait to be told (v1.10)
|
|
608
|
+
|
|
609
|
+
Everything above learns about quota from the stream โ the `rate_limit_event`
|
|
610
|
+
records the CLI emits at the top of a turn. That is reactive by construction:
|
|
611
|
+
nothing is observed between turns, and the 5-hour session window arrives with
|
|
612
|
+
a status but no percentage, so the pool could see "warning" but never "how
|
|
613
|
+
close". v1.10 closes both gaps by reading each pooled account's **live usage
|
|
614
|
+
from the provider** on a timer โ the same figures the Claude CLI's own
|
|
615
|
+
`/usage` command shows โ with the OAuth session the CLI already holds.
|
|
616
|
+
|
|
617
|
+
```jsonc
|
|
618
|
+
"pool": {
|
|
619
|
+
"id": "clawd",
|
|
620
|
+
"accounts": ["claw1", "claw2"],
|
|
621
|
+
"utilizationThreshold": 0.85, // hand over here (unchanged)
|
|
622
|
+
"usagePoll": { // v1.10 โ on by default
|
|
623
|
+
"intervalMs": 120000, // every 2 min (minimum 60000)
|
|
624
|
+
"warnThreshold": 0.95 // operator alert from here
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
What a poll does, per account:
|
|
630
|
+
|
|
631
|
+
| Provider says | Health file gets | Pool does |
|
|
632
|
+
|---|---|---|
|
|
633
|
+
| 5-hour at 40%, weekly at 20% | `usage:five_hour` / `usage:seven_day`, `allowed` with the number and reset | nothing โ home account serves |
|
|
634
|
+
| 5-hour at 86% | the same, `utilization 0.86` | **next launch rotates** to the spare (threshold rule, now with a real number on the session window) |
|
|
635
|
+
| 5-hour at 96% | the same | rotates, **and raises an alert**: account, window, reset time, where launches now go |
|
|
636
|
+
| 5-hour at 100% | `rejected` with the provider's reset | `exhausted` until that reset โ for every model, before any turn is refused |
|
|
637
|
+
| every account past 95% | โ | **pool-wide alert**: at 100% the ladder or the host's chain takes over; soonest reset named |
|
|
638
|
+
| model-scoped limit (e.g. one family at 97%) | โ | alert naming the model; the shim's reactive capture still handles the refusal |
|
|
639
|
+
|
|
640
|
+
Polled records live under their own keys (`usage:<window>`) so they never race
|
|
641
|
+
the shim's `five_hour` / `seven_day` for the same slot โ the shim's next bare
|
|
642
|
+
`allowed` cannot erase the number the poll just wrote, and `explain` shows
|
|
643
|
+
both ("5-hour (live) 40%"). The health rules are the existing ones; the poll
|
|
644
|
+
only improves what they can see.
|
|
645
|
+
|
|
646
|
+
Boundaries:
|
|
647
|
+
|
|
648
|
+
- **Read-only on credentials.** The poll reads the access token from the
|
|
649
|
+
account's `.credentials.json` and sends it to the usage endpoint โ nowhere
|
|
650
|
+
else. It never refreshes, rewrites, or copies a token; an expired one is a
|
|
651
|
+
skipped tick (the next CLI launch refreshes it, as it always has).
|
|
652
|
+
- **Best-effort.** A failed poll changes nothing: the health file keeps what
|
|
653
|
+
the shim wrote, selection keeps working from it, and the failure is logged
|
|
654
|
+
once per transition, not per tick.
|
|
655
|
+
- **Native and `configDir` accounts only.** A token-based account
|
|
656
|
+
(`oauthTokenFile` / `oauthTokenRef`) runs in the default login dir on a
|
|
657
|
+
token of its own, so the credentials file there belongs to a different
|
|
658
|
+
account; those accounts keep stream telemetry only and `explain` says so.
|
|
659
|
+
macOS native logins kept in the keychain (no `.credentials.json`) are
|
|
660
|
+
likewise not polled.
|
|
661
|
+
- **Off switch:** `"usagePoll": { "enabled": false }`.
|
|
662
|
+
|
|
663
|
+
On demand:
|
|
664
|
+
|
|
665
|
+
```
|
|
666
|
+
multi-clawd usage # live figures per account + what the next launch would do
|
|
667
|
+
multi-clawd usage --json
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
**What "seamless" means here.** Rotation is decided per launch, so a turn that
|
|
671
|
+
starts after the poll has seen 85% simply runs on the other account โ the user
|
|
672
|
+
sees nothing. A turn already in flight when its account runs out is covered by
|
|
673
|
+
the shim's in-turn retry (v1.9): the refusal is held back, the sibling is
|
|
674
|
+
spawned, the conversation is handed over, and the turn completes there. The
|
|
675
|
+
poll's job is to make that second path rare by moving work off an account
|
|
676
|
+
before it reaches the cliff, and to tell you when every account is nearing one.
|
|
677
|
+
|
|
599
678
|
## Direct route: the pool for `anthropic/*` too (v1.9)
|
|
600
679
|
|
|
601
680
|
OpenClaw reaches Claude two ways. The pool above drives **Claude Code CLI**
|
package/dist/credential-state.js
CHANGED
|
@@ -5,6 +5,9 @@ import { parseStoredState, mergeHealthStates, clearCredentialFailure, } from "./
|
|
|
5
5
|
export function healthStateFile(accountId) {
|
|
6
6
|
return join(homedir(), ".openclaw", "state", "multi-clawd", `${accountId}.json`);
|
|
7
7
|
}
|
|
8
|
+
export function usageStateFile(accountId) {
|
|
9
|
+
return join(homedir(), ".openclaw", "state", "multi-clawd", `${accountId}.usage.json`);
|
|
10
|
+
}
|
|
8
11
|
export function clearAccountCredentialFailure(accountId) {
|
|
9
12
|
const file = healthStateFile(accountId);
|
|
10
13
|
let state;
|
package/dist/explain-core.js
CHANGED
|
@@ -2,6 +2,8 @@ const WINDOW_LABELS = {
|
|
|
2
2
|
five_hour: "5-hour",
|
|
3
3
|
seven_day: "weekly",
|
|
4
4
|
seven_day_overage_included: "weekly incl. overage",
|
|
5
|
+
"usage:five_hour": "5-hour (live)",
|
|
6
|
+
"usage:seven_day": "weekly (live)",
|
|
5
7
|
};
|
|
6
8
|
export function relativeUntil(ms, nowMs) {
|
|
7
9
|
const mins = Math.max(0, Math.round((ms - nowMs) / 60000));
|
package/dist/index.js
CHANGED
|
@@ -16,8 +16,9 @@ import { resolvePoolExecutionArgs } from "./tool-cap.js";
|
|
|
16
16
|
import { RETRY_ROSTER_ENV } from "./retry-plan.js";
|
|
17
17
|
import { SESSION_DIRS_ENV } from "./session-handover.js";
|
|
18
18
|
import { addAlert, alertKeysWithPrefix, clearAlert, pendingAlertText, } from "./alerts.js";
|
|
19
|
-
import { healthStateFile, clearAccountCredentialFailure } from "./credential-state.js";
|
|
20
|
-
|
|
19
|
+
import { healthStateFile, usageStateFile, clearAccountCredentialFailure } from "./credential-state.js";
|
|
20
|
+
import { createUsagePollController, effectiveUsagePollInterval, effectiveUsageWarnThreshold, usageAlertPrefix, usagePoolAlertKey, } from "./usage-poll.js";
|
|
21
|
+
export { healthStateFile, usageStateFile, clearAccountCredentialFailure };
|
|
21
22
|
import { accountConfigDir, buildAccountChildEnv, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
|
|
22
23
|
import { diffCatalogModels, formatNewModelNotice, } from "./model-currency.js";
|
|
23
24
|
import { checkAccountCredential, keychainServiceForConfigDir, createRefProbeTracker, } from "./login-health.js";
|
|
@@ -582,18 +583,34 @@ export default definePluginEntry({
|
|
|
582
583
|
},
|
|
583
584
|
logger,
|
|
584
585
|
});
|
|
586
|
+
startUsagePoll({
|
|
587
|
+
accounts: accounts.filter((a) => seen.has(a.id.trim())),
|
|
588
|
+
pool: cfg.pool,
|
|
589
|
+
registrationMode: api.registrationMode,
|
|
590
|
+
logger,
|
|
591
|
+
});
|
|
585
592
|
}
|
|
586
593
|
api.logger.info(`[multi-clawd] registered ${seen.size} backend(s)+provider(s): ${[...seen].join(", ")}`);
|
|
587
594
|
},
|
|
588
595
|
});
|
|
589
596
|
function readHealthState(accountId) {
|
|
597
|
+
return mergeStoredHealth(readStateFileLenient(healthStateFile(accountId)), readStateFileLenient(usageStateFile(accountId)));
|
|
598
|
+
}
|
|
599
|
+
function readStateFileLenient(file) {
|
|
590
600
|
try {
|
|
591
|
-
return JSON.parse(readFileSync(
|
|
601
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
592
602
|
}
|
|
593
603
|
catch {
|
|
594
604
|
return undefined;
|
|
595
605
|
}
|
|
596
606
|
}
|
|
607
|
+
function mergeStoredHealth(shim, usage) {
|
|
608
|
+
if (!shim)
|
|
609
|
+
return usage;
|
|
610
|
+
if (!usage)
|
|
611
|
+
return shim;
|
|
612
|
+
return mergeHealthStates(shim, usage);
|
|
613
|
+
}
|
|
597
614
|
function readStickyEntry(file) {
|
|
598
615
|
try {
|
|
599
616
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -898,3 +915,131 @@ export function startDirectOrderSync(params) {
|
|
|
898
915
|
export async function runDirectOrderTickNow() {
|
|
899
916
|
return directController?.tick();
|
|
900
917
|
}
|
|
918
|
+
let usageTimer;
|
|
919
|
+
let usageInitial;
|
|
920
|
+
let usageController;
|
|
921
|
+
export function stopUsagePoll() {
|
|
922
|
+
if (usageTimer)
|
|
923
|
+
clearInterval(usageTimer);
|
|
924
|
+
if (usageInitial)
|
|
925
|
+
clearTimeout(usageInitial);
|
|
926
|
+
usageTimer = undefined;
|
|
927
|
+
usageInitial = undefined;
|
|
928
|
+
usageController = undefined;
|
|
929
|
+
}
|
|
930
|
+
export function usagePollCredentialsFile(account) {
|
|
931
|
+
if (account.oauthTokenFile || account.oauthTokenRef) {
|
|
932
|
+
return { reason: "token-based login โ usage is read from its own stream telemetry only" };
|
|
933
|
+
}
|
|
934
|
+
if (!account.native && !account.configDir) {
|
|
935
|
+
return { reason: "no login dir to read credentials from" };
|
|
936
|
+
}
|
|
937
|
+
return { file: join(accountConfigDir(account), ".credentials.json") };
|
|
938
|
+
}
|
|
939
|
+
export function startUsagePoll(params) {
|
|
940
|
+
const { logger, pool } = params;
|
|
941
|
+
const mode = params.registrationMode;
|
|
942
|
+
if (mode !== undefined && mode !== "full")
|
|
943
|
+
return { active: false, members: [] };
|
|
944
|
+
if (!pool || pool.usagePoll?.enabled === false) {
|
|
945
|
+
stopUsagePoll();
|
|
946
|
+
return { active: false, members: [] };
|
|
947
|
+
}
|
|
948
|
+
const poolId = pool.id?.trim() || "clawd";
|
|
949
|
+
const members = [];
|
|
950
|
+
const skipped = [];
|
|
951
|
+
for (const id of pool.accounts ?? []) {
|
|
952
|
+
const account = params.accounts.find((a) => a.id.trim() === id);
|
|
953
|
+
if (!account)
|
|
954
|
+
continue;
|
|
955
|
+
const source = usagePollCredentialsFile(account);
|
|
956
|
+
if ("file" in source)
|
|
957
|
+
members.push({ id: account.id.trim(), credentialsFile: source.file });
|
|
958
|
+
else
|
|
959
|
+
skipped.push(`${account.id.trim()} (${source.reason})`);
|
|
960
|
+
}
|
|
961
|
+
if (members.length === 0) {
|
|
962
|
+
stopUsagePoll();
|
|
963
|
+
if (skipped.length > 0)
|
|
964
|
+
logger.info(`[multi-clawd] usage poll: no pollable account โ ${skipped.join("; ")}`);
|
|
965
|
+
return { active: false, members: [] };
|
|
966
|
+
}
|
|
967
|
+
const intervalMs = effectiveUsagePollInterval(pool.usagePoll);
|
|
968
|
+
const warnThreshold = effectiveUsageWarnThreshold(pool.usagePoll);
|
|
969
|
+
const healthOptions = {
|
|
970
|
+
utilizationThreshold: pool.utilizationThreshold,
|
|
971
|
+
staleAfterMs: pool.staleAfterMs,
|
|
972
|
+
rotateOnOverage: pool.rotateOnOverage,
|
|
973
|
+
};
|
|
974
|
+
const signature = JSON.stringify({ poolId, members, intervalMs, warnThreshold, healthOptions });
|
|
975
|
+
if (usageController?.signature === signature && usageController.fetchImpl === params.fetchImpl && usageTimer) {
|
|
976
|
+
return { active: true, members: members.map((m) => m.id) };
|
|
977
|
+
}
|
|
978
|
+
stopUsagePoll();
|
|
979
|
+
const fetchImpl = params.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
980
|
+
const controller = createUsagePollController({
|
|
981
|
+
poolId,
|
|
982
|
+
members,
|
|
983
|
+
healthOptions,
|
|
984
|
+
warnThreshold,
|
|
985
|
+
io: {
|
|
986
|
+
readFile: (path) => {
|
|
987
|
+
try {
|
|
988
|
+
return readFileSync(path, "utf8");
|
|
989
|
+
}
|
|
990
|
+
catch (err) {
|
|
991
|
+
if (err.code === "ENOENT")
|
|
992
|
+
return undefined;
|
|
993
|
+
throw err;
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
readHealth: (id) => {
|
|
997
|
+
let shim;
|
|
998
|
+
try {
|
|
999
|
+
shim = parseStoredState(readFileSync(healthStateFile(id), "utf8"));
|
|
1000
|
+
if (!shim)
|
|
1001
|
+
throw new Error("not valid health-state JSON");
|
|
1002
|
+
}
|
|
1003
|
+
catch (err) {
|
|
1004
|
+
if (err.code !== "ENOENT")
|
|
1005
|
+
throw err;
|
|
1006
|
+
shim = undefined;
|
|
1007
|
+
}
|
|
1008
|
+
let usage;
|
|
1009
|
+
try {
|
|
1010
|
+
usage = parseStoredState(readFileSync(usageStateFile(id), "utf8"));
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
usage = undefined;
|
|
1014
|
+
}
|
|
1015
|
+
return mergeStoredHealth(shim, usage);
|
|
1016
|
+
},
|
|
1017
|
+
writeUsage: (id, state) => {
|
|
1018
|
+
const file = usageStateFile(id);
|
|
1019
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1020
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
1021
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
1022
|
+
renameSync(tmp, file);
|
|
1023
|
+
},
|
|
1024
|
+
fetchImpl,
|
|
1025
|
+
raiseAlert: (alert) => raiseAlert(alert),
|
|
1026
|
+
clearAlert: (key) => {
|
|
1027
|
+
alertState = clearAlert(alertState, key);
|
|
1028
|
+
},
|
|
1029
|
+
alertKeysWithPrefix: (prefix) => alertKeysWithPrefix(alertState, prefix),
|
|
1030
|
+
logger,
|
|
1031
|
+
},
|
|
1032
|
+
});
|
|
1033
|
+
usageController = { signature, fetchImpl: params.fetchImpl, tick: controller.tick };
|
|
1034
|
+
const tick = () => void controller.tick().catch((err) => logger.warn(`[multi-clawd] usage poll tick failed: ${String(err)}`));
|
|
1035
|
+
usageInitial = setTimeout(tick, Math.min(20_000, intervalMs));
|
|
1036
|
+
usageInitial.unref?.();
|
|
1037
|
+
usageTimer = setInterval(tick, intervalMs);
|
|
1038
|
+
usageTimer.unref?.();
|
|
1039
|
+
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("; ")}` : ""}`);
|
|
1040
|
+
return { active: true, members: members.map((m) => m.id) };
|
|
1041
|
+
}
|
|
1042
|
+
export async function runUsagePollTickNow() {
|
|
1043
|
+
return usageController?.tick();
|
|
1044
|
+
}
|
|
1045
|
+
export { usageAlertPrefix, usagePoolAlertKey };
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { classifyAccountHealth } from "./health.js";
|
|
2
|
+
import { mergeHealthStates } from "./shim-core.js";
|
|
3
|
+
export const USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage";
|
|
4
|
+
export const USAGE_WINDOW_PREFIX = "usage:";
|
|
5
|
+
export const DEFAULT_USAGE_POLL_INTERVAL_MS = 2 * 60 * 1000;
|
|
6
|
+
export const MIN_USAGE_POLL_INTERVAL_MS = 60 * 1000;
|
|
7
|
+
export const MAX_USAGE_POLL_INTERVAL_MS = 30 * 60 * 1000;
|
|
8
|
+
export const DEFAULT_USAGE_WARN_THRESHOLD = 0.95;
|
|
9
|
+
export const USAGE_FETCH_TIMEOUT_MS = 15_000;
|
|
10
|
+
export function effectiveUsagePollInterval(cfg) {
|
|
11
|
+
const requested = cfg?.intervalMs ?? DEFAULT_USAGE_POLL_INTERVAL_MS;
|
|
12
|
+
if (!Number.isFinite(requested))
|
|
13
|
+
return DEFAULT_USAGE_POLL_INTERVAL_MS;
|
|
14
|
+
return Math.min(MAX_USAGE_POLL_INTERVAL_MS, Math.max(MIN_USAGE_POLL_INTERVAL_MS, requested));
|
|
15
|
+
}
|
|
16
|
+
export function effectiveUsageWarnThreshold(cfg) {
|
|
17
|
+
const t = cfg?.warnThreshold;
|
|
18
|
+
return typeof t === "number" && t > 0 && t <= 1 ? t : DEFAULT_USAGE_WARN_THRESHOLD;
|
|
19
|
+
}
|
|
20
|
+
function parseResetsAt(value) {
|
|
21
|
+
if (typeof value !== "string")
|
|
22
|
+
return undefined;
|
|
23
|
+
const ms = Date.parse(value);
|
|
24
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
|
25
|
+
}
|
|
26
|
+
function parsePercent(value) {
|
|
27
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
return value / 100;
|
|
30
|
+
}
|
|
31
|
+
const WINDOW_KEY_RE = /^(five_hour|seven_day(_[a-z0-9_]+)?)$/;
|
|
32
|
+
export function parseUsageResponse(body) {
|
|
33
|
+
if (typeof body !== "object" || body === null)
|
|
34
|
+
return undefined;
|
|
35
|
+
const b = body;
|
|
36
|
+
const windows = {};
|
|
37
|
+
for (const [key, value] of Object.entries(b)) {
|
|
38
|
+
if (!WINDOW_KEY_RE.test(key))
|
|
39
|
+
continue;
|
|
40
|
+
if (typeof value !== "object" || value === null)
|
|
41
|
+
continue;
|
|
42
|
+
const w = value;
|
|
43
|
+
const utilization = parsePercent(w.utilization);
|
|
44
|
+
if (utilization === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
windows[key] = { utilization, resetsAt: parseResetsAt(w.resets_at) };
|
|
47
|
+
}
|
|
48
|
+
const scoped = [];
|
|
49
|
+
if (Array.isArray(b.limits)) {
|
|
50
|
+
for (const entry of b.limits) {
|
|
51
|
+
if (typeof entry !== "object" || entry === null)
|
|
52
|
+
continue;
|
|
53
|
+
const e = entry;
|
|
54
|
+
const scope = e.scope;
|
|
55
|
+
const model = scope?.model;
|
|
56
|
+
const label = model?.display_name;
|
|
57
|
+
const utilization = parsePercent(e.percent);
|
|
58
|
+
if (typeof label !== "string" || !label.trim() || utilization === undefined)
|
|
59
|
+
continue;
|
|
60
|
+
scoped.push({ label: label.trim(), utilization, resetsAt: parseResetsAt(e.resets_at) });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (Object.keys(windows).length === 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
return { windows, scoped };
|
|
66
|
+
}
|
|
67
|
+
export const ACCOUNT_USAGE_WINDOWS = ["five_hour", "seven_day"];
|
|
68
|
+
export function usageHealthWindows(snapshot, nowMs) {
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const [key, w] of Object.entries(snapshot.windows)) {
|
|
71
|
+
if (!ACCOUNT_USAGE_WINDOWS.includes(key))
|
|
72
|
+
continue;
|
|
73
|
+
out[`${USAGE_WINDOW_PREFIX}${key}`] = {
|
|
74
|
+
status: w.utilization >= 1 ? "rejected" : "allowed",
|
|
75
|
+
utilization: w.utilization,
|
|
76
|
+
resetsAt: w.resetsAt,
|
|
77
|
+
seenAt: nowMs,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
export function readOAuthAccessToken(raw, nowMs) {
|
|
83
|
+
if (raw === undefined)
|
|
84
|
+
return { skip: "no .credentials.json (not signed in, or credentials held elsewhere)" };
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(raw);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { skip: ".credentials.json is not valid JSON" };
|
|
91
|
+
}
|
|
92
|
+
const oauth = parsed?.claudeAiOauth;
|
|
93
|
+
if (typeof oauth !== "object" || oauth === null)
|
|
94
|
+
return { skip: ".credentials.json has no OAuth session" };
|
|
95
|
+
const o = oauth;
|
|
96
|
+
if (typeof o.accessToken !== "string" || o.accessToken.trim() === "") {
|
|
97
|
+
return { skip: ".credentials.json has no access token" };
|
|
98
|
+
}
|
|
99
|
+
if (typeof o.expiresAt === "number" && o.expiresAt <= nowMs) {
|
|
100
|
+
return { skip: "access token expired โ the next CLI launch refreshes it" };
|
|
101
|
+
}
|
|
102
|
+
return { token: o.accessToken };
|
|
103
|
+
}
|
|
104
|
+
export async function fetchUsage(token, fetchImpl, timeoutMs = USAGE_FETCH_TIMEOUT_MS) {
|
|
105
|
+
const controller = typeof AbortController === "function" ? new AbortController() : undefined;
|
|
106
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetchImpl(USAGE_ENDPOINT, {
|
|
109
|
+
headers: {
|
|
110
|
+
Authorization: `Bearer ${token}`,
|
|
111
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
112
|
+
accept: "application/json",
|
|
113
|
+
},
|
|
114
|
+
signal: controller?.signal,
|
|
115
|
+
});
|
|
116
|
+
if (res.status === 401 || res.status === 403) {
|
|
117
|
+
return { ok: false, kind: "auth", reason: `usage endpoint refused the login (HTTP ${res.status})` };
|
|
118
|
+
}
|
|
119
|
+
if (res.status === 429)
|
|
120
|
+
return { ok: false, kind: "throttled", reason: "usage endpoint rate-limited the poll" };
|
|
121
|
+
if (res.status < 200 || res.status >= 300) {
|
|
122
|
+
return { ok: false, kind: "transient", reason: `usage endpoint returned HTTP ${res.status}` };
|
|
123
|
+
}
|
|
124
|
+
let body;
|
|
125
|
+
try {
|
|
126
|
+
body = await res.json();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { ok: false, kind: "parse", reason: "usage endpoint returned a non-JSON body" };
|
|
130
|
+
}
|
|
131
|
+
const snapshot = parseUsageResponse(body);
|
|
132
|
+
if (!snapshot)
|
|
133
|
+
return { ok: false, kind: "parse", reason: "usage endpoint body had no readable window" };
|
|
134
|
+
return { ok: true, snapshot };
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
const name = err instanceof Error ? err.name : typeof err;
|
|
138
|
+
return { ok: false, kind: "transient", reason: `usage request failed (${name})` };
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
if (timer)
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export function usageWindowLabel(window) {
|
|
146
|
+
const bare = window.startsWith(USAGE_WINDOW_PREFIX) ? window.slice(USAGE_WINDOW_PREFIX.length) : window;
|
|
147
|
+
if (bare === "five_hour")
|
|
148
|
+
return "5-hour";
|
|
149
|
+
if (bare === "seven_day")
|
|
150
|
+
return "weekly";
|
|
151
|
+
if (bare.startsWith("seven_day_"))
|
|
152
|
+
return `weekly ${bare.slice("seven_day_".length).replace(/_/g, " ")}`;
|
|
153
|
+
return bare;
|
|
154
|
+
}
|
|
155
|
+
function relative(resetsAt, nowMs) {
|
|
156
|
+
if (resetsAt === undefined)
|
|
157
|
+
return "";
|
|
158
|
+
const mins = Math.max(0, Math.round((resetsAt * 1000 - nowMs) / 60000));
|
|
159
|
+
if (mins < 90)
|
|
160
|
+
return ` (resets in ~${mins}m)`;
|
|
161
|
+
const hours = Math.round(mins / 60);
|
|
162
|
+
if (hours < 36)
|
|
163
|
+
return ` (resets in ~${hours}h)`;
|
|
164
|
+
return ` (resets in ~${Math.round(hours / 24)}d)`;
|
|
165
|
+
}
|
|
166
|
+
export function usageAlertPrefix(poolId) {
|
|
167
|
+
return `usage:${poolId}:`;
|
|
168
|
+
}
|
|
169
|
+
export function usagePoolAlertKey(poolId) {
|
|
170
|
+
return `usage-pool:${poolId}`;
|
|
171
|
+
}
|
|
172
|
+
export function decideUsageAlerts(params) {
|
|
173
|
+
const { poolId, accounts, warnThreshold, nowMs } = params;
|
|
174
|
+
const raise = [];
|
|
175
|
+
const keep = new Set();
|
|
176
|
+
const prefix = usageAlertPrefix(poolId);
|
|
177
|
+
const answered = accounts.filter((a) => a.snapshot !== undefined);
|
|
178
|
+
const hot = new Set();
|
|
179
|
+
let soonestReset;
|
|
180
|
+
for (const account of answered) {
|
|
181
|
+
const snapshot = account.snapshot;
|
|
182
|
+
const others = accounts.filter((a) => a.id !== account.id);
|
|
183
|
+
const sibling = others.find((a) => a.verdict === "ok" || a.verdict === "no_data" || a.verdict === undefined);
|
|
184
|
+
for (const [window, w] of Object.entries(snapshot.windows)) {
|
|
185
|
+
if (w.utilization < warnThreshold)
|
|
186
|
+
continue;
|
|
187
|
+
if (ACCOUNT_USAGE_WINDOWS.includes(window)) {
|
|
188
|
+
hot.add(account.id);
|
|
189
|
+
if (w.resetsAt !== undefined && (soonestReset === undefined || w.resetsAt < soonestReset)) {
|
|
190
|
+
soonestReset = w.resetsAt;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const key = `${prefix}${account.id}:${window}`;
|
|
194
|
+
keep.add(key);
|
|
195
|
+
const pct = Math.round(w.utilization * 100);
|
|
196
|
+
const where = !ACCOUNT_USAGE_WINDOWS.includes(window)
|
|
197
|
+
? w.utilization >= 1
|
|
198
|
+
? "that model is refused on this account until reset"
|
|
199
|
+
: "that model will be refused on this account at 100%"
|
|
200
|
+
: w.utilization >= 1
|
|
201
|
+
? "this account is exhausted on that window"
|
|
202
|
+
: others.length === 0
|
|
203
|
+
? "no sibling account to hand over to"
|
|
204
|
+
: sibling
|
|
205
|
+
? `new launches route to ${sibling.id}`
|
|
206
|
+
: "no healthy sibling to hand over to";
|
|
207
|
+
raise.push({
|
|
208
|
+
key,
|
|
209
|
+
severity: "error",
|
|
210
|
+
text: `pool ${poolId}: account "${account.id}" ${usageWindowLabel(window)} usage at ${pct}%${relative(w.resetsAt, nowMs)} โ ${where}`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
for (const limit of snapshot.scoped) {
|
|
214
|
+
if (limit.utilization < warnThreshold)
|
|
215
|
+
continue;
|
|
216
|
+
const key = `${prefix}${account.id}:model:${limit.label.toLowerCase().replace(/\s+/g, "-")}`;
|
|
217
|
+
keep.add(key);
|
|
218
|
+
const pct = Math.round(limit.utilization * 100);
|
|
219
|
+
raise.push({
|
|
220
|
+
key,
|
|
221
|
+
severity: "error",
|
|
222
|
+
text: `pool ${poolId}: account "${account.id}" ${limit.label} model limit at ${pct}%${relative(limit.resetsAt, nowMs)} โ ${limit.utilization >= 1 ? "that model is refused on this account until reset" : "that model will be refused on this account at 100%"}`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (accounts.length > 0 && hot.size === accounts.length) {
|
|
227
|
+
const key = usagePoolAlertKey(poolId);
|
|
228
|
+
keep.add(key);
|
|
229
|
+
const pct = Math.round(warnThreshold * 100);
|
|
230
|
+
raise.push({
|
|
231
|
+
key,
|
|
232
|
+
severity: "error",
|
|
233
|
+
text: `pool ${poolId}: EVERY account is above ${pct}% on at least one usage window โ ` +
|
|
234
|
+
`at 100% turns degrade down the ladder or fall through to the next provider` +
|
|
235
|
+
`${soonestReset !== undefined ? `; soonest reset${relative(soonestReset, nowMs)}` : ""}`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return { raise, keep };
|
|
239
|
+
}
|
|
240
|
+
export function createUsagePollController(params) {
|
|
241
|
+
const { poolId, members, healthOptions, warnThreshold, io } = params;
|
|
242
|
+
const now = io.now ?? (() => Date.now());
|
|
243
|
+
const lastProblem = new Map();
|
|
244
|
+
const lastVerdict = new Map();
|
|
245
|
+
const noteProblem = (id, problem) => {
|
|
246
|
+
const previous = lastProblem.get(id);
|
|
247
|
+
if (problem === previous)
|
|
248
|
+
return;
|
|
249
|
+
if (problem)
|
|
250
|
+
io.logger.warn(`[multi-clawd] usage poll: account "${id}" โ ${problem}`);
|
|
251
|
+
else if (previous)
|
|
252
|
+
io.logger.info(`[multi-clawd] usage poll: account "${id}" โ reading usage again`);
|
|
253
|
+
if (problem)
|
|
254
|
+
lastProblem.set(id, problem);
|
|
255
|
+
else
|
|
256
|
+
lastProblem.delete(id);
|
|
257
|
+
};
|
|
258
|
+
async function tick() {
|
|
259
|
+
const at = now();
|
|
260
|
+
const reports = [];
|
|
261
|
+
for (const member of members) {
|
|
262
|
+
let raw;
|
|
263
|
+
try {
|
|
264
|
+
raw = io.readFile(member.credentialsFile);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
raw = undefined;
|
|
268
|
+
noteProblem(member.id, `credentials unreadable (${err instanceof Error ? err.name : String(err)})`);
|
|
269
|
+
reports.push({ id: member.id, skipped: "credentials unreadable", verdict: verdictFor(member.id) });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const token = readOAuthAccessToken(raw, at);
|
|
273
|
+
if ("skip" in token) {
|
|
274
|
+
noteProblem(member.id, token.skip);
|
|
275
|
+
reports.push({ id: member.id, skipped: token.skip, verdict: verdictFor(member.id) });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const result = await fetchUsage(token.token, io.fetchImpl);
|
|
279
|
+
if (!result.ok) {
|
|
280
|
+
noteProblem(member.id, result.reason);
|
|
281
|
+
reports.push({ id: member.id, failure: { kind: result.kind, reason: result.reason }, verdict: verdictFor(member.id) });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const live = {
|
|
285
|
+
accountId: member.id,
|
|
286
|
+
updatedAt: at,
|
|
287
|
+
windows: usageHealthWindows(result.snapshot, at),
|
|
288
|
+
};
|
|
289
|
+
try {
|
|
290
|
+
io.writeUsage(member.id, live);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
io.logger.warn(`[multi-clawd] usage poll: usage write failed for "${member.id}": ${String(err)}`);
|
|
294
|
+
}
|
|
295
|
+
let disk;
|
|
296
|
+
try {
|
|
297
|
+
disk = io.readHealth(member.id) ?? { accountId: member.id, windows: {} };
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
noteProblem(member.id, `health file unreadable (${err instanceof Error ? err.message : String(err)})`);
|
|
301
|
+
reports.push({ id: member.id, snapshot: result.snapshot, skipped: "health file unreadable", verdict: "no_data" });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
noteProblem(member.id, undefined);
|
|
305
|
+
const merged = mergeHealthStates(disk, live, at);
|
|
306
|
+
const verdict = classifyAccountHealth(merged, healthOptions, at).verdict;
|
|
307
|
+
const previous = lastVerdict.get(member.id);
|
|
308
|
+
if (previous !== undefined && previous !== verdict) {
|
|
309
|
+
io.logger.info(`[multi-clawd] usage poll: account "${member.id}" ${previous} โ ${verdict} (${describeSnapshot(result.snapshot)})`);
|
|
310
|
+
}
|
|
311
|
+
else if (previous === undefined) {
|
|
312
|
+
io.logger.info(`[multi-clawd] usage poll: account "${member.id}" ${describeSnapshot(result.snapshot)} โ ${verdict}`);
|
|
313
|
+
}
|
|
314
|
+
lastVerdict.set(member.id, verdict);
|
|
315
|
+
reports.push({ id: member.id, snapshot: result.snapshot, verdict });
|
|
316
|
+
}
|
|
317
|
+
const known = reports.filter((r) => r.snapshot !== undefined && r.skipped === undefined);
|
|
318
|
+
const unknown = reports.filter((r) => !known.includes(r));
|
|
319
|
+
const decision = decideUsageAlerts({
|
|
320
|
+
poolId,
|
|
321
|
+
accounts: reports.map((r) => ({ id: r.id, snapshot: known.includes(r) ? r.snapshot : undefined, verdict: r.verdict })),
|
|
322
|
+
warnThreshold,
|
|
323
|
+
nowMs: at,
|
|
324
|
+
});
|
|
325
|
+
for (const r of unknown) {
|
|
326
|
+
for (const key of io.alertKeysWithPrefix(`${usageAlertPrefix(poolId)}${r.id}:`))
|
|
327
|
+
decision.keep.add(key);
|
|
328
|
+
}
|
|
329
|
+
if (unknown.length > 0) {
|
|
330
|
+
for (const key of io.alertKeysWithPrefix(usagePoolAlertKey(poolId)))
|
|
331
|
+
decision.keep.add(key);
|
|
332
|
+
}
|
|
333
|
+
const raised = [];
|
|
334
|
+
for (const alert of decision.raise) {
|
|
335
|
+
io.raiseAlert(alert);
|
|
336
|
+
raised.push(alert.key);
|
|
337
|
+
}
|
|
338
|
+
const cleared = [];
|
|
339
|
+
const family = [...io.alertKeysWithPrefix(usageAlertPrefix(poolId)), ...io.alertKeysWithPrefix(usagePoolAlertKey(poolId))];
|
|
340
|
+
for (const key of family) {
|
|
341
|
+
if (decision.keep.has(key))
|
|
342
|
+
continue;
|
|
343
|
+
io.clearAlert(key);
|
|
344
|
+
cleared.push(key);
|
|
345
|
+
}
|
|
346
|
+
return { at, accounts: reports, raised, cleared };
|
|
347
|
+
}
|
|
348
|
+
const lastReadProblem = new Map();
|
|
349
|
+
function verdictFor(id) {
|
|
350
|
+
let state;
|
|
351
|
+
try {
|
|
352
|
+
state = io.readHealth(id);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
const problem = `health file unreadable (${err instanceof Error ? err.message : String(err)})`;
|
|
356
|
+
if (lastReadProblem.get(id) !== problem) {
|
|
357
|
+
io.logger.warn(`[multi-clawd] usage poll: account "${id}" โ ${problem}`);
|
|
358
|
+
lastReadProblem.set(id, problem);
|
|
359
|
+
}
|
|
360
|
+
return "no_data";
|
|
361
|
+
}
|
|
362
|
+
lastReadProblem.delete(id);
|
|
363
|
+
return classifyAccountHealth(state, healthOptions, now()).verdict;
|
|
364
|
+
}
|
|
365
|
+
return { tick };
|
|
366
|
+
}
|
|
367
|
+
export function describeSnapshot(snapshot) {
|
|
368
|
+
const parts = Object.entries(snapshot.windows).map(([k, w]) => `${usageWindowLabel(k)} ${Math.round(w.utilization * 100)}%`);
|
|
369
|
+
for (const s of snapshot.scoped)
|
|
370
|
+
parts.push(`${s.label} ${Math.round(s.utilization * 100)}%`);
|
|
371
|
+
return parts.join(" ยท ");
|
|
372
|
+
}
|
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.10.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",
|
|
@@ -59,6 +59,25 @@
|
|
|
59
59
|
"type": "number",
|
|
60
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)."
|
|
61
61
|
},
|
|
62
|
+
"usagePoll": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"additionalProperties": false,
|
|
65
|
+
"description": "Live usage polling (v1.10): read each pooled account's current usage from the provider on a timer (the same figures the Claude CLI's /usage shows) and fold them into the pool's health state, so near-limit rotation fires on the 5-hour window with a real percentage behind it and a window at 100% is exhausted before a turn is refused. Raises an operator alert (via the heartbeat) when any window passes warnThreshold. Read-only on credentials: uses the login the CLI already holds, never refreshes or copies it. Applies to native and configDir accounts; token-based accounts keep stream telemetry only.",
|
|
66
|
+
"properties": {
|
|
67
|
+
"enabled": {
|
|
68
|
+
"type": "boolean",
|
|
69
|
+
"description": "Default true. Set false to turn polling off entirely."
|
|
70
|
+
},
|
|
71
|
+
"intervalMs": {
|
|
72
|
+
"type": "number",
|
|
73
|
+
"description": "Poll interval. Default 120000 (2min); minimum 60000."
|
|
74
|
+
},
|
|
75
|
+
"warnThreshold": {
|
|
76
|
+
"type": "number",
|
|
77
|
+
"description": "Raise an operator alert when any usage window reaches this fraction. Default 0.95."
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
62
81
|
"degrade": {
|
|
63
82
|
"type": "object",
|
|
64
83
|
"additionalProperties": false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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
|
@@ -39,6 +39,7 @@ ${BOLD}๐ฆ multi-clawd${RESET} โ multi-account Claude failover for OpenClaw
|
|
|
39
39
|
${BOLD}explain${RESET} your setup in plain English โ accounts, pool, fallback chain
|
|
40
40
|
${BOLD}chain${RESET} audit your model routing โ what actually serves each turn
|
|
41
41
|
${BOLD}direct${RESET} the direct anthropic/* route โ status, or \`direct sync\` to store profiles
|
|
42
|
+
${BOLD}usage${RESET} live usage per account from the provider โ what is left, and what the pool will do
|
|
42
43
|
${BOLD}update${RESET} update the plugin to the latest version
|
|
43
44
|
${BOLD}doctor${RESET} health check (add --probe for a live turn)
|
|
44
45
|
${BOLD}hermes${RESET} sync or diagnose Hermes Agent's Anthropic credential pool
|
|
@@ -711,6 +712,115 @@ async function explain() {
|
|
|
711
712
|
console.log(`\n${DIM}(health checks: multi-clawd doctor ยท change things: multi-clawd setup)${RESET}`);
|
|
712
713
|
}
|
|
713
714
|
|
|
715
|
+
/**
|
|
716
|
+
* `usage` โ ask the provider for each account's live usage (the figures the
|
|
717
|
+
* Claude CLI's own /usage shows), print them next to the pool's verdict, and
|
|
718
|
+
* say which account the next pooled launch would run on. Read-only: uses the
|
|
719
|
+
* login the CLI already holds and never refreshes or copies it. `--json` for
|
|
720
|
+
* machines.
|
|
721
|
+
*/
|
|
722
|
+
async function usageCommand(args) {
|
|
723
|
+
const { readFileSync: rf } = await import("node:fs");
|
|
724
|
+
const { homedir } = await import("node:os");
|
|
725
|
+
let up, health, shim, env;
|
|
726
|
+
try {
|
|
727
|
+
up = await import(resolve(__dirname, "..", "dist", "usage-poll.js"));
|
|
728
|
+
health = await import(resolve(__dirname, "..", "dist", "health.js"));
|
|
729
|
+
shim = await import(resolve(__dirname, "..", "dist", "shim-core.js"));
|
|
730
|
+
env = await import(resolve(__dirname, "..", "dist", "account-env.js"));
|
|
731
|
+
} catch (err) {
|
|
732
|
+
console.error(distFailure("usage", "usage-poll.js", err));
|
|
733
|
+
process.exit(1);
|
|
734
|
+
}
|
|
735
|
+
const config = readOpenclawConfigSync();
|
|
736
|
+
if (!config) {
|
|
737
|
+
console.error("usage: could not read ~/.openclaw/openclaw.json");
|
|
738
|
+
process.exit(1);
|
|
739
|
+
}
|
|
740
|
+
const pc = config?.plugins?.entries?.["multi-clawd"]?.config ?? {};
|
|
741
|
+
const accounts = Array.isArray(pc.accounts) ? pc.accounts : [];
|
|
742
|
+
const pool = pc.pool ? { ...pc.pool, id: pc.pool.id?.trim() || "clawd", accounts: pc.pool.accounts ?? [] } : undefined;
|
|
743
|
+
const healthOptions = {
|
|
744
|
+
utilizationThreshold: pool?.utilizationThreshold,
|
|
745
|
+
staleAfterMs: pool?.staleAfterMs,
|
|
746
|
+
rotateOnOverage: pool?.rotateOnOverage,
|
|
747
|
+
};
|
|
748
|
+
const warnThreshold = up.effectiveUsageWarnThreshold(pool?.usagePoll);
|
|
749
|
+
const rotateAt = pool?.utilizationThreshold ?? 0.85;
|
|
750
|
+
const stateDir = join(homedir(), ".openclaw", "state", "multi-clawd");
|
|
751
|
+
const now = Date.now();
|
|
752
|
+
const rows = [];
|
|
753
|
+
for (const a of accounts) {
|
|
754
|
+
const row = { id: a.id, label: a.label };
|
|
755
|
+
if (a.oauthTokenFile || a.oauthTokenRef) {
|
|
756
|
+
row.skipped = "token-based login โ not polled (stream telemetry only)";
|
|
757
|
+
} else if (!a.native && !a.configDir) {
|
|
758
|
+
row.skipped = "no login dir";
|
|
759
|
+
} else {
|
|
760
|
+
let raw;
|
|
761
|
+
try {
|
|
762
|
+
raw = rf(join(env.accountConfigDir(a), ".credentials.json"), "utf8");
|
|
763
|
+
} catch {
|
|
764
|
+
raw = undefined;
|
|
765
|
+
}
|
|
766
|
+
const token = up.readOAuthAccessToken(raw, now);
|
|
767
|
+
if ("skip" in token) row.skipped = token.skip;
|
|
768
|
+
else {
|
|
769
|
+
const r = await up.fetchUsage(token.token, (url, init) => fetch(url, init));
|
|
770
|
+
if (r.ok) row.snapshot = r.snapshot;
|
|
771
|
+
else row.failure = r.reason;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
let state;
|
|
775
|
+
try {
|
|
776
|
+
state = shim.parseStoredState(rf(join(stateDir, `${a.id}.json`), "utf8"));
|
|
777
|
+
} catch {
|
|
778
|
+
/* no telemetry yet */
|
|
779
|
+
}
|
|
780
|
+
const h = health.classifyAccountHealth(state, healthOptions, now);
|
|
781
|
+
row.verdict = h.verdict;
|
|
782
|
+
row.reason = h.reason;
|
|
783
|
+
rows.push(row);
|
|
784
|
+
}
|
|
785
|
+
let next;
|
|
786
|
+
if (pool) {
|
|
787
|
+
const verdicts = pool.accounts.map((id) => ({ id, verdict: rows.find((r) => r.id === id)?.verdict ?? "no_data" }));
|
|
788
|
+
next = health.choosePoolAccount(verdicts);
|
|
789
|
+
}
|
|
790
|
+
if (args.includes("--json")) {
|
|
791
|
+
console.log(JSON.stringify({ at: new Date(now).toISOString(), rotateAt, warnThreshold, pool: pool?.id, nextLaunch: next, accounts: rows }, null, 2));
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const pct = (u) => `${Math.round(u * 100)}%`;
|
|
795
|
+
const until = (resetsAt) => {
|
|
796
|
+
if (resetsAt === undefined) return "";
|
|
797
|
+
const mins = Math.max(0, Math.round((resetsAt * 1000 - now) / 60000));
|
|
798
|
+
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
|
+
};
|
|
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`);
|
|
802
|
+
for (const r of rows) {
|
|
803
|
+
console.log(` ${BOLD}${r.id}${RESET}${r.label ? ` ${DIM}โ ${r.label}${RESET}` : ""} pool verdict: ${r.verdict}${r.reason ? ` ${DIM}(${r.reason})${RESET}` : ""}`);
|
|
804
|
+
if (r.snapshot) {
|
|
805
|
+
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)}`);
|
|
807
|
+
}
|
|
808
|
+
for (const s of r.snapshot.scoped) {
|
|
809
|
+
console.log(` ${mark(s.utilization)} ${`${s.label} model`.padEnd(12)} ${pct(s.utilization).padStart(4)}${until(s.resetsAt)}`);
|
|
810
|
+
}
|
|
811
|
+
} else if (r.failure) {
|
|
812
|
+
console.log(` โ ๏ธ ${r.failure}`);
|
|
813
|
+
} else {
|
|
814
|
+
console.log(` ${DIM}not polled: ${r.skipped}${RESET}`);
|
|
815
|
+
}
|
|
816
|
+
console.log("");
|
|
817
|
+
}
|
|
818
|
+
if (pool) {
|
|
819
|
+
console.log(next ? ` next pooled launch (${pool.id}/โฆ) runs on ${BOLD}${next}${RESET}` : ` ${BOLD}every pooled account is exhausted${RESET} โ turns degrade or fall through the chain`);
|
|
820
|
+
}
|
|
821
|
+
console.log(`\n${DIM}(the gateway polls this every ${Math.round(up.effectiveUsagePollInterval(pool?.usagePoll) / 1000)}s and alerts via the heartbeat; see "Live usage" in the README)${RESET}`);
|
|
822
|
+
}
|
|
823
|
+
|
|
714
824
|
/**
|
|
715
825
|
* `login <account>` โ launch the RIGHT Claude login flow for a configured
|
|
716
826
|
* account: correct config-dir environment, dir created if missing, verified
|
|
@@ -831,6 +941,9 @@ switch (cmd) {
|
|
|
831
941
|
case "direct":
|
|
832
942
|
await direct(rest);
|
|
833
943
|
break;
|
|
944
|
+
case "usage":
|
|
945
|
+
await usageCommand(rest);
|
|
946
|
+
break;
|
|
834
947
|
case "doctor":
|
|
835
948
|
runSibling("doctor.mjs", rest);
|
|
836
949
|
break;
|