@drakon-systems/multi-clawd 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/dist/direct-report.js +3 -1
- package/dist/direct-route.js +5 -1
- package/dist/direct-sync.js +3 -1
- package/dist/explain-core.js +65 -1
- package/dist/health.js +36 -1
- package/dist/index.js +1 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +36 -4
- package/scripts/doctor.mjs +2 -1
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ multi-clawd setup # guided wizard: accounts, isolated second login, pool, wa
|
|
|
31
31
|
multi-clawd login claw2 # launch the right Claude sign-in for an account (or re-auth it)
|
|
32
32
|
multi-clawd explain # your whole setup in plain English — accounts, chain, live health
|
|
33
33
|
multi-clawd explain --history --since 7d # per-account launches, retries, rotations
|
|
34
|
+
multi-clawd explain --json # live health plus a rough weekly pace estimate, machine-readable
|
|
34
35
|
multi-clawd account disable claw2 --dry-run # preview benching an account
|
|
35
36
|
multi-clawd chain # audit your model routing — what actually serves each turn
|
|
36
37
|
multi-clawd direct # the direct anthropic/* route: status, or `direct sync` (v1.9, optional)
|
|
@@ -415,7 +416,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
415
416
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
416
417
|
Code on the target machine already? Paste it this and go make coffee:
|
|
417
418
|
|
|
418
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.
|
|
419
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.6.0/SETUP-AGENT.md
|
|
419
420
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
420
421
|
> Claude account — ask me when you need me to log in.
|
|
421
422
|
|
|
@@ -633,7 +634,14 @@ a gateway reload. A turn already admitted or running continues on its selected
|
|
|
633
634
|
account; the command does not interrupt it.
|
|
634
635
|
|
|
635
636
|
`pool.strategy` defaults to `"ordered"`, which retains the configured account
|
|
636
|
-
order.
|
|
637
|
+
order. In every strategy, when no `ok` or `no_data` account is left but some
|
|
638
|
+
are near their limit, the launch goes to the near-limit account with the most
|
|
639
|
+
headroom: the lowest fresh utilization, taking the newer of the stream and
|
|
640
|
+
usage-poll records for each window. Equal figures keep pool order, an account
|
|
641
|
+
with a figure outranks one without, and two without keep pool order.
|
|
642
|
+
Exhausted and credential-failed accounts are never chosen. `explain` says
|
|
643
|
+
"all near limit, picked X at N%" and the direct Anthropic route orders its
|
|
644
|
+
near-limit profiles the same way. Set it to `"reset-aware"` to use healthy weekly quota before its
|
|
637
645
|
`seven_day` window resets: among `ok` accounts with live seven-day reset data,
|
|
638
646
|
the soonest reset wins. Accounts without that data follow in pool order;
|
|
639
647
|
near-limit or exhausted accounts never outrank a healthy one. Equal reset
|
|
@@ -687,6 +695,7 @@ How it decides, per launch (all data from each account's live
|
|
|
687
695
|
| `allowed_warning` and **no** number, on a short window | skipped (v1.7.2) — see below |
|
|
688
696
|
| `allowed_warning` and no number, on the weekly window | still used — weekly warns from ~30% and would flap |
|
|
689
697
|
| no data / stale data | used — never rotate on missing evidence |
|
|
698
|
+
| every usable account near its limit | the near-limit account with the most headroom (lowest fresh utilization) serves; equal figures and accounts without a number keep pool order |
|
|
690
699
|
| whole pool exhausted | home account anyway → real limit error → your chain drops provider |
|
|
691
700
|
|
|
692
701
|
**Why the short-window rule exists (v1.7.2).** (v1.10's live usage poll now
|
package/dist/direct-report.js
CHANGED
|
@@ -27,11 +27,13 @@ export async function gatherDirectStatus(params) {
|
|
|
27
27
|
: undefined;
|
|
28
28
|
const verdicts = members.map((m) => {
|
|
29
29
|
const state = params.readHealth(m.accountId);
|
|
30
|
+
const health = classifyAccountHealth(state, params.healthOptions, params.nowMs);
|
|
30
31
|
return {
|
|
31
32
|
accountId: m.accountId,
|
|
32
33
|
profileId: m.profileId,
|
|
33
34
|
disabled: m.disabled,
|
|
34
|
-
verdict:
|
|
35
|
+
verdict: health.verdict,
|
|
36
|
+
utilization: health.utilization,
|
|
35
37
|
sevenDayResetsAt: params.strategy === "reset-aware"
|
|
36
38
|
? sevenDayReset(state, params.healthOptions, params.nowMs)
|
|
37
39
|
: undefined,
|
package/dist/direct-route.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isSecretRefShape } from "./token-resolution.js";
|
|
2
|
+
import { headroomBefore } from "./health.js";
|
|
2
3
|
import { decideStickySelection } from "./sticky.js";
|
|
3
4
|
import { spreadBefore } from "./spread.js";
|
|
4
5
|
export const DIRECT_PROVIDER = "anthropic";
|
|
@@ -135,7 +136,7 @@ export function planDirectOrder(params) {
|
|
|
135
136
|
if (members.length === 0)
|
|
136
137
|
throw new Error("pool.accounts[].disabled: every direct member is disabled");
|
|
137
138
|
const decision = decideStickySelection({
|
|
138
|
-
verdicts: members.map((m) => ({ id: m.accountId, verdict: m.verdict, sevenDayResetsAt: m.sevenDayResetsAt, servedInWindow: m.servedInWindow, lastServedAt: m.lastServedAt })),
|
|
139
|
+
verdicts: members.map((m) => ({ id: m.accountId, verdict: m.verdict, sevenDayResetsAt: m.sevenDayResetsAt, utilization: m.utilization, servedInWindow: m.servedInWindow, lastServedAt: m.lastServedAt })),
|
|
139
140
|
sticky: params.sticky,
|
|
140
141
|
nowMs: params.nowMs,
|
|
141
142
|
minDwellMs: params.minDwellMs,
|
|
@@ -151,6 +152,9 @@ export function planDirectOrder(params) {
|
|
|
151
152
|
: 0) ||
|
|
152
153
|
(params.strategy === "spread" && VERDICT_RANK[a.m.verdict] === 0
|
|
153
154
|
? (spreadBefore(a.m, b.m) ? -1 : spreadBefore(b.m, a.m) ? 1 : 0)
|
|
155
|
+
: 0) ||
|
|
156
|
+
(a.m.verdict === "near_limit" && b.m.verdict === "near_limit"
|
|
157
|
+
? (headroomBefore(a.m, b.m) ? -1 : headroomBefore(b.m, a.m) ? 1 : 0)
|
|
154
158
|
: 0) || a.index - b.index)
|
|
155
159
|
.map(({ m }) => m.profileId);
|
|
156
160
|
const managed = new Set(params.members.map((m) => m.profileId));
|
package/dist/direct-sync.js
CHANGED
|
@@ -253,11 +253,13 @@ export function createDirectOrderController(deps) {
|
|
|
253
253
|
: undefined;
|
|
254
254
|
const verdicts = deps.members.map((m) => {
|
|
255
255
|
const state = deps.readHealth(m.accountId);
|
|
256
|
+
const health = classifyAccountHealth(state, deps.healthOptions, nowMs);
|
|
256
257
|
return {
|
|
257
258
|
accountId: m.accountId,
|
|
258
259
|
profileId: m.profileId,
|
|
259
260
|
disabled: m.disabled,
|
|
260
|
-
verdict:
|
|
261
|
+
verdict: health.verdict,
|
|
262
|
+
utilization: health.utilization,
|
|
261
263
|
sevenDayResetsAt: deps.strategy === "reset-aware"
|
|
262
264
|
? sevenDayReset(state, deps.healthOptions, nowMs)
|
|
263
265
|
: undefined,
|
package/dist/explain-core.js
CHANGED
|
@@ -30,6 +30,49 @@ export function renderUsageLine(usage, nowMs) {
|
|
|
30
30
|
})
|
|
31
31
|
.join(" · ");
|
|
32
32
|
}
|
|
33
|
+
const WEEK_MS = 7 * 86_400_000;
|
|
34
|
+
const MIN_PACE_ELAPSED_MS = WEEK_MS / 10;
|
|
35
|
+
const PACE_RESET_SLACK_MS = 3_600_000;
|
|
36
|
+
export function computeWeeklyPace(usage, nowMs) {
|
|
37
|
+
const u = ["usage:seven_day", "seven_day"]
|
|
38
|
+
.map((w) => usage?.find((x) => x.window === w))
|
|
39
|
+
.find((x) => x !== undefined);
|
|
40
|
+
if (!u || u.resetsAt === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (!Number.isFinite(u.utilization) || u.utilization < 0 || u.utilization > 1)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (!Number.isFinite(u.resetsAt) || !Number.isFinite(nowMs))
|
|
45
|
+
return undefined;
|
|
46
|
+
const remaining = u.resetsAt - nowMs;
|
|
47
|
+
if (remaining <= 0 || remaining > WEEK_MS + PACE_RESET_SLACK_MS)
|
|
48
|
+
return undefined;
|
|
49
|
+
const elapsed = Math.max(0, WEEK_MS - remaining);
|
|
50
|
+
if (elapsed < MIN_PACE_ELAPSED_MS)
|
|
51
|
+
return undefined;
|
|
52
|
+
const expectedPct = elapsed / WEEK_MS;
|
|
53
|
+
const projectedExhaustionAt = u.utilization >= 1
|
|
54
|
+
? nowMs
|
|
55
|
+
: u.utilization > 0 ? nowMs + ((1 - u.utilization) * elapsed) / u.utilization : undefined;
|
|
56
|
+
return {
|
|
57
|
+
window: u.window,
|
|
58
|
+
expectedPct,
|
|
59
|
+
aheadOfPace: u.utilization - expectedPct,
|
|
60
|
+
projectedExhaustionAt,
|
|
61
|
+
willLastToReset: projectedExhaustionAt === undefined || projectedExhaustionAt >= u.resetsAt,
|
|
62
|
+
resetsAt: u.resetsAt,
|
|
63
|
+
estimate: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function renderPaceLine(pace, nowMs) {
|
|
67
|
+
if (!pace)
|
|
68
|
+
return undefined;
|
|
69
|
+
const pts = Math.round(pace.aheadOfPace * 100);
|
|
70
|
+
const where = pts === 0 ? "on pace" : pts > 0 ? `${pts} points ahead of pace` : `${-pts} points behind pace`;
|
|
71
|
+
const verdict = pace.willLastToReset
|
|
72
|
+
? "should last to the reset"
|
|
73
|
+
: `could run out in ${relativeUntil(pace.projectedExhaustionAt ?? nowMs, nowMs)}, before the reset in ${relativeUntil(pace.resetsAt, nowMs)}`;
|
|
74
|
+
return `weekly pace (rough estimate): ~${Math.round(pace.expectedPct * 100)}% of the week gone, ${where}; ${verdict}`;
|
|
75
|
+
}
|
|
33
76
|
export function describeAccount(acc) {
|
|
34
77
|
if (acc.native) {
|
|
35
78
|
return "the machine's main `claude` login (default config dir; OS keychain on macOS)";
|
|
@@ -145,6 +188,9 @@ export function renderExplanation(model) {
|
|
|
145
188
|
const word = VERDICT_WORDS[h.verdict] ?? h.verdict;
|
|
146
189
|
lines.push(` ${h.id}: ${model.accounts.find((a) => a.id === h.id)?.disabled ? "disabled by operator" : word}${h.detail ? ` — ${h.detail}` : ""}`);
|
|
147
190
|
lines.push(` usage: ${h.usage?.length ? renderUsageLine(h.usage, nowMs) : "no live telemetry"}`);
|
|
191
|
+
const paceLine = renderPaceLine(computeWeeklyPace(h.usage, nowMs), nowMs);
|
|
192
|
+
if (paceLine)
|
|
193
|
+
lines.push(` ${paceLine}`);
|
|
148
194
|
}
|
|
149
195
|
if (model.pool) {
|
|
150
196
|
const strategy = poolStrategy(model.pool.strategy);
|
|
@@ -155,6 +201,7 @@ export function renderExplanation(model) {
|
|
|
155
201
|
return {
|
|
156
202
|
id,
|
|
157
203
|
verdict: h?.verdict ?? "no_data",
|
|
204
|
+
utilization: h?.utilization,
|
|
158
205
|
sevenDayResetsAt: (h?.usage?.find((u) => u.window === "usage:seven_day" && u.resetsAt !== undefined && u.resetsAt > nowMs)
|
|
159
206
|
?? h?.usage?.find((u) => u.window === "seven_day" && u.resetsAt !== undefined && u.resetsAt > nowMs))?.resetsAt,
|
|
160
207
|
};
|
|
@@ -167,8 +214,10 @@ export function renderExplanation(model) {
|
|
|
167
214
|
let reason;
|
|
168
215
|
if (preferred && decision.account !== preferred)
|
|
169
216
|
reason = "sticky dwell keeps this usable account";
|
|
217
|
+
else if (picked?.verdict === "near_limit")
|
|
218
|
+
reason = describeNearLimitPick(verdicts, picked);
|
|
170
219
|
else if (strategy === "ordered")
|
|
171
|
-
reason =
|
|
220
|
+
reason = "first healthy member in pool order";
|
|
172
221
|
else if (strategy === "spread")
|
|
173
222
|
reason = picked && (picked.verdict === "ok" || picked.verdict === "no_data") ? describeSpreadPick(picked, windowMs) : "no healthy member; using the existing fallback";
|
|
174
223
|
else if (picked?.verdict === "ok" && picked.sevenDayResetsAt !== undefined)
|
|
@@ -201,6 +250,21 @@ export function renderExplanation(model) {
|
|
|
201
250
|
}
|
|
202
251
|
return lines.join("\n");
|
|
203
252
|
}
|
|
253
|
+
export function describeNearLimitPick(verdicts, picked) {
|
|
254
|
+
const nearLimit = verdicts.filter((v) => v.verdict === "near_limit");
|
|
255
|
+
const scope = nearLimit.length === verdicts.length ? "all near limit" : "no healthy member, the rest near limit";
|
|
256
|
+
const others = nearLimit.filter((v) => v.id !== picked.id);
|
|
257
|
+
if (picked.utilization === undefined) {
|
|
258
|
+
return `${scope}, picked ${picked.id} (no utilization number; pool order)`;
|
|
259
|
+
}
|
|
260
|
+
const pct = `${Math.round(picked.utilization * 100)}%`;
|
|
261
|
+
if (others.length === 0)
|
|
262
|
+
return `${scope}, picked ${picked.id} at ${pct}`;
|
|
263
|
+
const shown = others
|
|
264
|
+
.map((v) => `${v.id} ${v.utilization === undefined ? "no number" : `${Math.round(v.utilization * 100)}%`}`)
|
|
265
|
+
.join(", ");
|
|
266
|
+
return `${scope}, picked ${picked.id} at ${pct} — most headroom (${shown})`;
|
|
267
|
+
}
|
|
204
268
|
export function renderDirectSection(direct, nowMs) {
|
|
205
269
|
const lines = [];
|
|
206
270
|
lines.push("DIRECT ROUTE anthropic/* (gateway → Anthropic API with each account's setup-token)");
|
package/dist/health.js
CHANGED
|
@@ -20,6 +20,9 @@ export function isPeriodWindow(window) {
|
|
|
20
20
|
export function isOverageWindow(window) {
|
|
21
21
|
return /overage/i.test(window);
|
|
22
22
|
}
|
|
23
|
+
export function logicalWindowKey(window) {
|
|
24
|
+
return window.startsWith("usage:") ? window.slice("usage:".length) : window;
|
|
25
|
+
}
|
|
23
26
|
export function isLongQuotaWindow(window) {
|
|
24
27
|
return /(^|_)(days?|weeks?|months?)(_|$)/.test(window) && !isOverageWindow(window);
|
|
25
28
|
}
|
|
@@ -90,6 +93,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
90
93
|
let hasLiveEvidence = false;
|
|
91
94
|
let overageUtilization;
|
|
92
95
|
let quotaUtilization;
|
|
96
|
+
const freshestByWindow = new Map();
|
|
93
97
|
for (const [window, w] of Object.entries(state.windows)) {
|
|
94
98
|
const resetMs = typeof w.resetsAt === "number" ? w.resetsAt * 1000 : undefined;
|
|
95
99
|
const resetBearing = resetMs !== undefined && resetMs > nowMs;
|
|
@@ -176,6 +180,14 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
176
180
|
if (!isOverageWindow(window) && typeof w.utilization === "number") {
|
|
177
181
|
quotaUtilization = Math.max(quotaUtilization ?? 0, w.utilization);
|
|
178
182
|
}
|
|
183
|
+
if (typeof w.utilization === "number" &&
|
|
184
|
+
(resetBearing || resetMs === undefined) &&
|
|
185
|
+
(!isOverageWindow(window) || options.rotateOnOverage)) {
|
|
186
|
+
const logical = logicalWindowKey(window);
|
|
187
|
+
const seen = freshestByWindow.get(logical);
|
|
188
|
+
if (!seen || w.seenAt > seen.seenAt)
|
|
189
|
+
freshestByWindow.set(logical, { utilization: w.utilization, seenAt: w.seenAt });
|
|
190
|
+
}
|
|
179
191
|
if (worst.verdict === "ok" &&
|
|
180
192
|
typeof w.utilization !== "number" &&
|
|
181
193
|
isShortWindow(window) &&
|
|
@@ -189,6 +201,12 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
189
201
|
}
|
|
190
202
|
if (worst.verdict === "ok" && !hasLiveEvidence)
|
|
191
203
|
return { verdict: "no_data" };
|
|
204
|
+
let utilization;
|
|
205
|
+
for (const { utilization: u } of freshestByWindow.values()) {
|
|
206
|
+
utilization = utilization === undefined ? u : Math.max(utilization, u);
|
|
207
|
+
}
|
|
208
|
+
if (utilization !== undefined)
|
|
209
|
+
worst = { ...worst, utilization };
|
|
192
210
|
if (worst.verdict === "ok" && overageUtilization !== undefined) {
|
|
193
211
|
const pct = (n) => `${Math.round(n * 100)}%`;
|
|
194
212
|
worst = {
|
|
@@ -258,7 +276,24 @@ export function choosePoolAccount(pool, strategy = "ordered") {
|
|
|
258
276
|
const usable = pool.find((a) => a.verdict === "ok" || a.verdict === "no_data");
|
|
259
277
|
if (usable)
|
|
260
278
|
return usable.id;
|
|
261
|
-
return pool
|
|
279
|
+
return nearLimitWithMostHeadroom(pool)?.id;
|
|
280
|
+
}
|
|
281
|
+
export function headroomBefore(a, b) {
|
|
282
|
+
if (a.utilization === undefined)
|
|
283
|
+
return false;
|
|
284
|
+
if (b.utilization === undefined)
|
|
285
|
+
return true;
|
|
286
|
+
return a.utilization < b.utilization;
|
|
287
|
+
}
|
|
288
|
+
export function nearLimitWithMostHeadroom(pool) {
|
|
289
|
+
let best;
|
|
290
|
+
for (const member of pool) {
|
|
291
|
+
if (member.verdict !== "near_limit")
|
|
292
|
+
continue;
|
|
293
|
+
if (!best || headroomBefore(member, best))
|
|
294
|
+
best = member;
|
|
295
|
+
}
|
|
296
|
+
return best;
|
|
262
297
|
}
|
|
263
298
|
export function allCredentialFailed(pool) {
|
|
264
299
|
return pool.length > 0 && pool.every((a) => a.verdict === "credential_failed");
|
package/dist/index.js
CHANGED
|
@@ -850,6 +850,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
850
850
|
id: v.id,
|
|
851
851
|
verdict: v.health.verdict,
|
|
852
852
|
observedAt: v.health.observedAt,
|
|
853
|
+
utilization: v.health.utilization,
|
|
853
854
|
sevenDayResetsAt: strategy === "reset-aware"
|
|
854
855
|
? sevenDayReset(states.find((s) => s.id === v.id)?.state, options, now)
|
|
855
856
|
: undefined,
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.6.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",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.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
|
@@ -50,6 +50,27 @@ Run via npx (${DIM}npx ${PKG} <command>${RESET}) or install globally
|
|
|
50
50
|
`);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Per-command usage, printed for `<command> --help` / `-h` before anything is
|
|
54
|
+
// dispatched, so asking for help never reads config, runs checks, or starts the
|
|
55
|
+
// setup wizard. `hermes` is absent on purpose: it handles its own help.
|
|
56
|
+
const COMMAND_HELP = {
|
|
57
|
+
setup: "multi-clawd setup [--dry-run]\n\n Guided setup wizard (accounts, pool, watchdog).\n --dry-run preview the changes without writing anything",
|
|
58
|
+
login: "multi-clawd login [<id>]\n\n Log a configured account in (or re-auth it). Without <id>, lists the\n configured accounts to choose from.",
|
|
59
|
+
account: "multi-clawd account <enable|disable> <id> [--dry-run]\n\n Disable or enable a pool account.\n --dry-run preview the change without writing it",
|
|
60
|
+
explain: "multi-clawd explain [--json | --history [--since 7d|24h]]\n\n Your setup in plain English: accounts, pool, fallback chain.\n --json machine-readable output\n --history recent pool events; --since limits the window",
|
|
61
|
+
chain: "multi-clawd chain [--raw]\n\n Audit your model routing: what actually serves each turn.\n --raw include raw routing detail",
|
|
62
|
+
direct: "multi-clawd direct [status | sync [--dry-run] [--yes] [--resync]]\n\n The direct anthropic/* route. Default is status.\n sync store profiles for the direct route\n --dry-run preview without writing\n --yes skip the confirmation prompt\n --resync refresh profiles that are already stored",
|
|
63
|
+
usage: "multi-clawd usage [--json]\n\n Live usage per account from the provider: what is left, and what the\n pool will do.\n --json machine-readable output",
|
|
64
|
+
update: "multi-clawd update\n\n Update the plugin to the latest version.",
|
|
65
|
+
doctor: "multi-clawd doctor [--probe] [--probe-pool] [--verbose] [--raw]\n\n Health check.\n --probe run a live turn per account plus the pool\n --probe-pool with --probe: probe the pool ref only (one turn)\n --verbose show more detail\n --raw show raw output",
|
|
66
|
+
version: "multi-clawd version\n\n Show CLI and installed plugin versions.",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (Object.hasOwn(COMMAND_HELP, cmd) && rest.some((a) => a === "--help" || a === "-h")) {
|
|
70
|
+
console.log(`\n${COMMAND_HELP[cmd]}\n`);
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
const { resolveInstallDir } = await import(join(__dirname, "_shared.mjs"));
|
|
54
75
|
|
|
55
76
|
function installedVersion(installPath) {
|
|
@@ -677,11 +698,12 @@ async function healWatchdogUnit() {
|
|
|
677
698
|
/** `explain` — gather config + live health, render the plain-English view. */
|
|
678
699
|
async function explain(args = []) {
|
|
679
700
|
let historySince;
|
|
680
|
-
|
|
701
|
+
const asJson = args.length === 1 && args[0] === "--json";
|
|
702
|
+
if (args.length && !asJson) {
|
|
681
703
|
const allowed = args[0] === "--history" && (args.length === 1 ||
|
|
682
704
|
(args.length === 3 && args[1] === "--since"));
|
|
683
705
|
if (!allowed) {
|
|
684
|
-
console.error("explain: usage: multi-clawd explain [--history [--since 7d|24h]]");
|
|
706
|
+
console.error("explain: usage: multi-clawd explain [--json | --history [--since 7d|24h]]");
|
|
685
707
|
process.exitCode = 1;
|
|
686
708
|
return;
|
|
687
709
|
}
|
|
@@ -749,7 +771,8 @@ async function explain(args = []) {
|
|
|
749
771
|
weeklyUtilizationThreshold: pool?.weeklyUtilizationThreshold,
|
|
750
772
|
staleAfterMs: pool?.staleAfterMs,
|
|
751
773
|
}, now);
|
|
752
|
-
|
|
774
|
+
// #93: the headroom figure the pool ranks near-limit members by.
|
|
775
|
+
return { id: a.id, verdict: h.verdict, detail, usage, utilization: h.utilization };
|
|
753
776
|
});
|
|
754
777
|
let stickyAccount;
|
|
755
778
|
let stickyEntry;
|
|
@@ -774,6 +797,14 @@ async function explain(args = []) {
|
|
|
774
797
|
/* no journal yet: every account counts as never served */
|
|
775
798
|
}
|
|
776
799
|
}
|
|
800
|
+
if (asJson) {
|
|
801
|
+
// #94: machine-readable health with the display-only weekly pace estimate.
|
|
802
|
+
console.log(JSON.stringify({
|
|
803
|
+
at: new Date(now).toISOString(),
|
|
804
|
+
accounts: healthRows.map((r) => ({ ...r, weeklyPace: ec.computeWeeklyPace(r.usage, now) ?? null })),
|
|
805
|
+
}, null, 2));
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
777
808
|
// v1.9: the direct anthropic/* route, when any account opted in.
|
|
778
809
|
let direct;
|
|
779
810
|
const dctx = await loadDirect(config);
|
|
@@ -864,13 +895,14 @@ async function usageCommand(args) {
|
|
|
864
895
|
const h = health.classifyAccountHealth(state, healthOptions, now);
|
|
865
896
|
row.verdict = h.verdict;
|
|
866
897
|
row.reason = h.reason;
|
|
898
|
+
if (h.utilization !== undefined) row.utilization = h.utilization;
|
|
867
899
|
if (pool?.strategy === "reset-aware") resetById.set(a.id, health.sevenDayReset(state, healthOptions, now));
|
|
868
900
|
rows.push(row);
|
|
869
901
|
}
|
|
870
902
|
let next;
|
|
871
903
|
if (pool) {
|
|
872
904
|
const verdicts = pool.accounts.filter((id) => !accounts.find((a) => a.id === id)?.disabled).map((id) => ({ id, verdict: rows.find((r) => r.id === id)?.verdict ?? "no_data",
|
|
873
|
-
sevenDayResetsAt: resetById.get(id) }));
|
|
905
|
+
utilization: rows.find((r) => r.id === id)?.utilization, sevenDayResetsAt: resetById.get(id) }));
|
|
874
906
|
if (pool.strategy === "reset-aware" || pool.strategy === "spread") {
|
|
875
907
|
const stickySelection = await import(resolve(__dirname, "..", "dist", "sticky.js"));
|
|
876
908
|
let sticky;
|
package/scripts/doctor.mjs
CHANGED
|
@@ -635,7 +635,8 @@ else {
|
|
|
635
635
|
};
|
|
636
636
|
const rawVerdicts = members.map((id) => {
|
|
637
637
|
const state = readJson(join(STATE_DIR, `${id}.json`));
|
|
638
|
-
|
|
638
|
+
const health = classifyAccountHealth(state, healthOptions, now);
|
|
639
|
+
return { id, verdict: health.verdict, utilization: health.utilization,
|
|
639
640
|
sevenDayResetsAt: pool.strategy === "reset-aware" ? sevenDayReset(state, healthOptions, now) : undefined };
|
|
640
641
|
});
|
|
641
642
|
const verdicts = pool.strategy === "spread"
|