@drakon-systems/multi-clawd 1.9.8 → 2.0.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/dist/shim.js CHANGED
@@ -5,7 +5,7 @@ import { classifyStateReadFailure, clearCredentialFailure, clearModelRejection,
5
5
  import { rewriteModelArg } from "./degrade.js";
6
6
  import { parseModelLimitError, recordModelLimit } from "./shim-core.js";
7
7
  import { canonicalModelId } from "./models.js";
8
- import { buildRetryEnv, chooseRetryAccount, parseRetryRoster, retryArming, RETRY_ROSTER_ENV, } from "./retry-plan.js";
8
+ import { buildRetryEnv, chooseLaunchableRetryAccount, materializeRetryAccount, parseRetryRoster, retryArming, RETRY_ROSTER_ENV, } from "./retry-plan.js";
9
9
  import { effectiveConfigDir, handoverForLaunch, handoverSnapshotTo, parseSessionDirs, SESSION_DIRS_ENV, snapshotResumeTranscript, } from "./session-handover.js";
10
10
  function resolveClaudeCommand() {
11
11
  const override = process.env.MULTI_CLAWD_CLAUDE_BIN;
@@ -95,6 +95,7 @@ if (modelOverride) {
95
95
  process.stderr.write(`[multi-clawd shim] ${note}\n`);
96
96
  }
97
97
  const retryRoster = parseRetryRoster(process.env[RETRY_ROSTER_ENV]);
98
+ delete process.env[RETRY_ROSTER_ENV];
98
99
  const resumeSnapshot = retryRoster.length > 0 && parseSessionDirs(process.env[SESSION_DIRS_ENV]).length > 0
99
100
  ? snapshotResumeTranscript(childArgs, process.env)
100
101
  : undefined;
@@ -233,11 +234,13 @@ function attemptRetry() {
233
234
  if (!retryArmed || retryUsed)
234
235
  return false;
235
236
  const model = effectiveModelId();
236
- const target = chooseRetryAccount({
237
+ const target = chooseLaunchableRetryAccount({
237
238
  roster: retryRoster,
238
239
  readState: readSiblingState,
239
240
  modelId: model,
240
241
  nowMs: Date.now(),
242
+ materialize: (account) => materializeRetryAccount(account, (path) => readFileSync(path, "utf8")),
243
+ onSkip: (id, reason) => process.stderr.write(`[multi-clawd shim] not retrying onto ${id}: its declared credential did not resolve (${reason})\n`),
241
244
  });
242
245
  if (!target) {
243
246
  process.stderr.write(`[multi-clawd shim] model limit on ${accountId} with no healthy sibling to retry onto — ` +
@@ -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": "2.0.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",
@@ -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": "2.0.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
@@ -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 = "setup-token login — not polled: the usage endpoint needs the user:profile scope, setup-tokens carry only user:inference (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;