@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 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.9.8/SETUP-AGENT.md
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).** The threshold rule needs a
578
- utilization number, and Anthropic does not send one for the 5-hour session
579
- window โ€” across every observation we hold from two accounts it arrives as a
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**
@@ -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;
@@ -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
- export { healthStateFile, clearAccountCredentialFailure };
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(healthStateFile(accountId), "utf8"));
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
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.9.8",
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.9.8",
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;