@drakon-systems/multi-clawd 1.7.3 → 1.8.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 +1 -1
- package/dist/alerts.js +3 -0
- package/dist/explain-core.js +1 -0
- package/dist/health.js +82 -12
- package/dist/index.js +205 -54
- package/dist/login-health.js +2 -0
- package/dist/shim-core.js +87 -1
- package/dist/shim.js +25 -2
- package/dist/sticky.js +2 -2
- package/openclaw.plugin.json +5 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +13 -1
- package/scripts/doctor.mjs +14 -0
package/README.md
CHANGED
|
@@ -364,7 +364,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
364
364
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
365
365
|
Code on the target machine already? Paste it this and go make coffee:
|
|
366
366
|
|
|
367
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.
|
|
367
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.0/SETUP-AGENT.md
|
|
368
368
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
369
369
|
> Claude account — ask me when you need me to log in.
|
|
370
370
|
|
package/dist/alerts.js
CHANGED
|
@@ -13,6 +13,9 @@ export function addAlert(state, alert, nowMs) {
|
|
|
13
13
|
export function clearAlert(state, key) {
|
|
14
14
|
return { alerts: state.alerts.filter((a) => a.key !== key) };
|
|
15
15
|
}
|
|
16
|
+
export function alertKeysWithPrefix(state, prefix) {
|
|
17
|
+
return state.alerts.filter((a) => a.key.startsWith(prefix)).map((a) => a.key);
|
|
18
|
+
}
|
|
16
19
|
function isLive(alert, nowMs) {
|
|
17
20
|
const ttl = alert.ttlMs ?? DEFAULT_TTL_MS[alert.severity];
|
|
18
21
|
return nowMs - alert.at <= ttl;
|
package/dist/explain-core.js
CHANGED
|
@@ -66,6 +66,7 @@ const VERDICT_WORDS = {
|
|
|
66
66
|
no_data: "no recent telemetry — treated as healthy",
|
|
67
67
|
near_limit: "NEAR ITS LIMIT — the pool will hand over before it hard-fails",
|
|
68
68
|
exhausted: "EXHAUSTED",
|
|
69
|
+
credential_failed: "LOGIN REJECTED — excluded from the pool until it is re-authenticated",
|
|
69
70
|
};
|
|
70
71
|
export function renderExplanation(model) {
|
|
71
72
|
const lines = [];
|
package/dist/health.js
CHANGED
|
@@ -2,6 +2,11 @@ import { modelWindowKey } from "./shim-core.js";
|
|
|
2
2
|
const DEFAULT_UTILIZATION_THRESHOLD = 0.85;
|
|
3
3
|
const DEFAULT_STALE_AFTER_MS = 6 * 60 * 60 * 1000;
|
|
4
4
|
export const MODEL_REJECTED_TTL_MS = 60 * 60 * 1000;
|
|
5
|
+
export const CREDENTIAL_FAILED_TTL_MS = 15 * 60 * 1000;
|
|
6
|
+
export const REJECTION_REVALIDATE_AFTER_MS = 60 * 60 * 1000;
|
|
7
|
+
function rejectionStillAssertable(seenAt, nowMs) {
|
|
8
|
+
return nowMs - seenAt <= REJECTION_REVALIDATE_AFTER_MS;
|
|
9
|
+
}
|
|
5
10
|
const MODEL_WINDOW_PREFIX = "model:";
|
|
6
11
|
const SHORT_WINDOW_PATTERN = /(^|_)hours?(_|$)/;
|
|
7
12
|
export function isShortWindow(window) {
|
|
@@ -11,18 +16,49 @@ const PERIOD_WINDOW_PATTERN = /(^|_)(minutes?|hours?|days?|weeks?|months?)(_|$)/
|
|
|
11
16
|
export function isPeriodWindow(window) {
|
|
12
17
|
return PERIOD_WINDOW_PATTERN.test(window);
|
|
13
18
|
}
|
|
19
|
+
export function isOverageWindow(window) {
|
|
20
|
+
return /overage/i.test(window);
|
|
21
|
+
}
|
|
14
22
|
export function isWarningStatus(status) {
|
|
15
23
|
return /warning/i.test(status);
|
|
16
24
|
}
|
|
17
25
|
export const MAX_RESET_HORIZON_MS = 8 * 24 * 60 * 60 * 1000;
|
|
26
|
+
export const PERIOD_RESET_LESS_BLOCK_MS = 6 * 60 * 60 * 1000;
|
|
27
|
+
export function resetLessBlockMs(window) {
|
|
28
|
+
return isShortWindow(window) ? MODEL_REJECTED_TTL_MS : PERIOD_RESET_LESS_BLOCK_MS;
|
|
29
|
+
}
|
|
30
|
+
function windowAppliesToModel(w, requestedWindowKey) {
|
|
31
|
+
if (w.model === undefined)
|
|
32
|
+
return true;
|
|
33
|
+
if (requestedWindowKey === undefined)
|
|
34
|
+
return false;
|
|
35
|
+
return modelWindowKey(w.model) === requestedWindowKey;
|
|
36
|
+
}
|
|
37
|
+
export function credentialFailureFor(state, nowMs) {
|
|
38
|
+
const credential = state?.credential;
|
|
39
|
+
if (!credential || credential.status !== "failed")
|
|
40
|
+
return undefined;
|
|
41
|
+
if (nowMs - credential.seenAt > CREDENTIAL_FAILED_TTL_MS)
|
|
42
|
+
return undefined;
|
|
43
|
+
return {
|
|
44
|
+
verdict: "credential_failed",
|
|
45
|
+
resumeAt: credential.seenAt + CREDENTIAL_FAILED_TTL_MS,
|
|
46
|
+
reason: `login rejected by the Claude CLI ${Math.round((nowMs - credential.seenAt) / 60000)}m ago${credential.reason ? `: ${credential.reason}` : ""} — re-authenticate this account`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
18
49
|
export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
19
50
|
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
20
51
|
const threshold = options.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
|
|
21
52
|
if (!state)
|
|
22
53
|
return { verdict: "no_data" };
|
|
54
|
+
const credentialFailure = credentialFailureFor(state, nowMs);
|
|
55
|
+
if (credentialFailure)
|
|
56
|
+
return credentialFailure;
|
|
23
57
|
const requestedWindowKey = requestedModel !== undefined ? modelWindowKey(requestedModel) : undefined;
|
|
24
58
|
let worst = { verdict: "ok" };
|
|
25
59
|
let hasLiveEvidence = false;
|
|
60
|
+
let overageUtilization;
|
|
61
|
+
let quotaUtilization;
|
|
26
62
|
for (const [window, w] of Object.entries(state.windows)) {
|
|
27
63
|
const resetMs = typeof w.resetsAt === "number" ? w.resetsAt * 1000 : undefined;
|
|
28
64
|
const resetBearing = resetMs !== undefined && resetMs > nowMs;
|
|
@@ -58,32 +94,50 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
58
94
|
reason: `${requestedModel} limit hit ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; TTL block)`,
|
|
59
95
|
};
|
|
60
96
|
}
|
|
61
|
-
const
|
|
97
|
+
const ownBlockMs = w.status === "rejected" && resetMs === undefined && isPeriodWindow(window)
|
|
98
|
+
? resetLessBlockMs(window)
|
|
99
|
+
: 0;
|
|
100
|
+
const fresh = resetBearing || nowMs - w.seenAt <= Math.max(staleAfterMs, ownBlockMs);
|
|
62
101
|
if (!fresh)
|
|
63
102
|
continue;
|
|
64
103
|
hasLiveEvidence = true;
|
|
65
|
-
if (w.status === "rejected" &&
|
|
104
|
+
if (w.status === "rejected" &&
|
|
105
|
+
resetBearing &&
|
|
106
|
+
isPeriodWindow(window) &&
|
|
107
|
+
windowAppliesToModel(w, requestedWindowKey) &&
|
|
108
|
+
rejectionStillAssertable(w.seenAt, nowMs)) {
|
|
66
109
|
return {
|
|
67
110
|
verdict: "exhausted",
|
|
68
111
|
resumeAt: resetMs,
|
|
69
112
|
reason: `${window} rejected until ${new Date(resetMs).toISOString()}`,
|
|
70
113
|
};
|
|
71
114
|
}
|
|
72
|
-
if (w.status === "rejected" &&
|
|
115
|
+
if (w.status === "rejected" &&
|
|
116
|
+
resetMs === undefined &&
|
|
117
|
+
isPeriodWindow(window) &&
|
|
118
|
+
windowAppliesToModel(w, requestedWindowKey) &&
|
|
119
|
+
nowMs - w.seenAt <= resetLessBlockMs(window)) {
|
|
73
120
|
return {
|
|
74
121
|
verdict: "exhausted",
|
|
75
|
-
resumeAt: w.seenAt +
|
|
76
|
-
reason: `${window} rejected ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time;
|
|
122
|
+
resumeAt: w.seenAt + resetLessBlockMs(window),
|
|
123
|
+
reason: `${window} rejected ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; ${Math.round(resetLessBlockMs(window) / 3600000)}h block)`,
|
|
77
124
|
};
|
|
78
125
|
}
|
|
79
|
-
if (
|
|
80
|
-
typeof w.utilization === "number" &&
|
|
126
|
+
if (typeof w.utilization === "number" &&
|
|
81
127
|
w.utilization >= threshold &&
|
|
82
128
|
(resetBearing || resetMs === undefined)) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
129
|
+
if (isOverageWindow(window) && !options.rotateOnOverage) {
|
|
130
|
+
overageUtilization = Math.max(overageUtilization ?? 0, w.utilization);
|
|
131
|
+
}
|
|
132
|
+
else if (worst.verdict === "ok") {
|
|
133
|
+
worst = {
|
|
134
|
+
verdict: "near_limit",
|
|
135
|
+
reason: `${window} utilization ${w.utilization} >= ${threshold}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!isOverageWindow(window) && typeof w.utilization === "number") {
|
|
140
|
+
quotaUtilization = Math.max(quotaUtilization ?? 0, w.utilization);
|
|
87
141
|
}
|
|
88
142
|
if (worst.verdict === "ok" &&
|
|
89
143
|
typeof w.utilization !== "number" &&
|
|
@@ -98,6 +152,16 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
98
152
|
}
|
|
99
153
|
if (worst.verdict === "ok" && !hasLiveEvidence)
|
|
100
154
|
return { verdict: "no_data" };
|
|
155
|
+
if (worst.verdict === "ok" && overageUtilization !== undefined) {
|
|
156
|
+
const pct = (n) => `${Math.round(n * 100)}%`;
|
|
157
|
+
worst = {
|
|
158
|
+
...worst,
|
|
159
|
+
overageAdvisory: `paid overage is ${pct(overageUtilization)} spent while real quota is at ` +
|
|
160
|
+
`${quotaUtilization !== undefined ? pct(quotaUtilization) : "an unreported level"} — ` +
|
|
161
|
+
`not rotating on spill-over alone. Set pool.rotateOnOverage: true to rotate on it, ` +
|
|
162
|
+
`or leave it to keep using the overage you have paid for.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
101
165
|
return worst;
|
|
102
166
|
}
|
|
103
167
|
export function summarizeWindowUsage(state, options, nowMs) {
|
|
@@ -128,6 +192,12 @@ export function choosePoolAccount(pool) {
|
|
|
128
192
|
return usable.id;
|
|
129
193
|
return pool.find((a) => a.verdict === "near_limit")?.id;
|
|
130
194
|
}
|
|
195
|
+
export function allCredentialFailed(pool) {
|
|
196
|
+
return pool.length > 0 && pool.every((a) => a.verdict === "credential_failed");
|
|
197
|
+
}
|
|
198
|
+
export function fallbackPoolAccount(pool) {
|
|
199
|
+
return (pool.find((a) => a.verdict !== "credential_failed") ?? pool[0]).id;
|
|
200
|
+
}
|
|
131
201
|
export function pickPoolAccountForLaunch(pool) {
|
|
132
|
-
return choosePoolAccount(pool) ?? pool
|
|
202
|
+
return choosePoolAccount(pool) ?? fallbackPoolAccount(pool);
|
|
133
203
|
}
|
package/dist/index.js
CHANGED
|
@@ -9,11 +9,12 @@ import { MODEL_ALIASES, buildCatalogEntries, canonicalModelId, isModernClaudeMod
|
|
|
9
9
|
import { decideDegradation, matchesPin } from "./degrade.js";
|
|
10
10
|
import { resolveExecMode, permissionModeArgs } from "./exec-policy.js";
|
|
11
11
|
import { resolveBaseModelIds } from "./catalog-source.js";
|
|
12
|
-
import { classifyAccountHealth } from "./health.js";
|
|
12
|
+
import { allCredentialFailed, classifyAccountHealth } from "./health.js";
|
|
13
13
|
import { decideStickySelection } from "./sticky.js";
|
|
14
|
+
import { clearCredentialFailure, mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
|
|
14
15
|
import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
|
|
15
16
|
import { resolveSecretRefValues } from "openclaw/plugin-sdk/secret-ref-runtime";
|
|
16
|
-
import { addAlert, clearAlert, pendingAlertText } from "./alerts.js";
|
|
17
|
+
import { addAlert, alertKeysWithPrefix, clearAlert, pendingAlertText, } from "./alerts.js";
|
|
17
18
|
import { buildAccountChildEnv, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
|
|
18
19
|
import { diffCatalogModels, formatNewModelNotice, } from "./model-currency.js";
|
|
19
20
|
import { checkAccountCredential, createRefProbeTracker, } from "./login-health.js";
|
|
@@ -68,6 +69,10 @@ let loginProbeTimer;
|
|
|
68
69
|
function raiseAlert(alert) {
|
|
69
70
|
alertState = addAlert(alertState, alert, Date.now());
|
|
70
71
|
}
|
|
72
|
+
export function pendingOperatorAlerts(nowMs) {
|
|
73
|
+
ingestAlertSpool();
|
|
74
|
+
return pendingAlertText(alertState, nowMs);
|
|
75
|
+
}
|
|
71
76
|
function ingestAlertSpool() {
|
|
72
77
|
const spool = join(homedir(), ".openclaw", "state", "multi-clawd", "alerts-spool.jsonl");
|
|
73
78
|
let raw;
|
|
@@ -116,53 +121,104 @@ const realCredentialIo = {
|
|
|
116
121
|
},
|
|
117
122
|
platform: process.platform,
|
|
118
123
|
};
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
124
|
+
const refProbeTrackers = new Map();
|
|
125
|
+
const lastProbeStatus = new Map();
|
|
126
|
+
function recordAccountCredentialFailure(accountId, reason, nowMs) {
|
|
127
|
+
const file = healthStateFile(accountId);
|
|
128
|
+
let state;
|
|
129
|
+
try {
|
|
130
|
+
state = parseStoredState(readFileSync(file, "utf8")) ?? { accountId, windows: {} };
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
state = { accountId, windows: {} };
|
|
134
|
+
}
|
|
135
|
+
const next = mergeHealthStates(state, recordCredentialFailure(state, reason, nowMs), nowMs);
|
|
136
|
+
try {
|
|
137
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
138
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
139
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
140
|
+
renameSync(tmp, file);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export async function runLoginHealthProbe(accounts, logger, deps = {}) {
|
|
146
|
+
const now = deps.nowMs ?? Date.now();
|
|
147
|
+
const resolver = deps.resolver ?? activeTokenResolver;
|
|
148
|
+
const io = deps.io ?? realCredentialIo;
|
|
149
|
+
for (const account of accounts) {
|
|
150
|
+
let status;
|
|
151
|
+
let reason;
|
|
152
|
+
let cause;
|
|
153
|
+
if (isSecretRefShape(account.oauthTokenRef) && !account.oauthTokenFile && !account.native) {
|
|
154
|
+
let tracker = refProbeTrackers.get(account.id);
|
|
155
|
+
if (!tracker) {
|
|
156
|
+
tracker = createRefProbeTracker();
|
|
157
|
+
refProbeTrackers.set(account.id, tracker);
|
|
146
158
|
}
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
const result = (await resolver?.resolveDetailed(account.oauthTokenRef)) ?? {
|
|
160
|
+
failure: "provider_error",
|
|
161
|
+
};
|
|
162
|
+
const outcome = tracker.observe(result, now);
|
|
163
|
+
status = outcome.status;
|
|
164
|
+
reason = outcome.reason;
|
|
165
|
+
cause = outcome.cause;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const check = checkAccountCredential(account, io);
|
|
169
|
+
status = check.status;
|
|
170
|
+
reason = check.reason;
|
|
171
|
+
}
|
|
172
|
+
const previous = lastProbeStatus.get(account.id);
|
|
173
|
+
lastProbeStatus.set(account.id, status);
|
|
174
|
+
if (status === "broken" && cause === "credential") {
|
|
175
|
+
recordAccountCredentialFailure(account.id, reason ?? "login probe found no credential", now);
|
|
176
|
+
if (previous !== "broken") {
|
|
177
|
+
const text = `account "${account.id}" login looks dead (${reason ?? "unknown"}) — excluded from pool selection until it is fixed`;
|
|
151
178
|
logger.error(`[multi-clawd] ${text}`);
|
|
152
179
|
raiseAlert({ key: `login:${account.id}`, severity: "error", text });
|
|
153
180
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
181
|
+
}
|
|
182
|
+
else if (status === "broken" && cause === "provider") {
|
|
183
|
+
if (previous !== "broken") {
|
|
184
|
+
const text = `account "${account.id}" credential resolver is unreachable (${reason ?? "unknown"}) — ` +
|
|
185
|
+
`this looks like a host or network problem rather than a broken login, so account ` +
|
|
186
|
+
`selection is unchanged. Check connectivity to the secret provider.`;
|
|
187
|
+
logger.error(`[multi-clawd] ${text}`);
|
|
188
|
+
raiseAlert({ key: `login-resolver:${account.id}`, severity: "error", text });
|
|
160
189
|
}
|
|
161
190
|
}
|
|
162
|
-
|
|
163
|
-
|
|
191
|
+
else if (status === "broken" && previous !== "broken") {
|
|
192
|
+
const text = `account "${account.id}" login looks dead (${reason ?? "unknown"}) — turns on it will fail until fixed`;
|
|
193
|
+
logger.error(`[multi-clawd] ${text}`);
|
|
194
|
+
raiseAlert({ key: `login:${account.id}`, severity: "error", text });
|
|
195
|
+
}
|
|
196
|
+
else if (status === "degraded" && previous !== "degraded") {
|
|
197
|
+
logger.info(`[multi-clawd] account "${account.id}" login degraded: ${reason ?? "resolver error"}`);
|
|
198
|
+
}
|
|
199
|
+
else if (status === "ok" && (previous === "broken" || previous === "degraded")) {
|
|
200
|
+
logger.info(`[multi-clawd] account "${account.id}" login recovered`);
|
|
201
|
+
alertState = clearAlert(alertState, `login:${account.id}`);
|
|
202
|
+
alertState = clearAlert(alertState, `login-resolver:${account.id}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export function startLoginHealthProbe(accounts, logger) {
|
|
207
|
+
if (loginProbeTimer)
|
|
208
|
+
clearInterval(loginProbeTimer);
|
|
209
|
+
const live = new Set(accounts.map((a) => a.id));
|
|
210
|
+
for (const id of [...refProbeTrackers.keys()]) {
|
|
211
|
+
if (!live.has(id))
|
|
212
|
+
refProbeTrackers.delete(id);
|
|
213
|
+
}
|
|
214
|
+
for (const id of [...lastProbeStatus.keys()]) {
|
|
215
|
+
if (!live.has(id))
|
|
216
|
+
lastProbeStatus.delete(id);
|
|
217
|
+
}
|
|
218
|
+
const probe = () => void runLoginHealthProbe(accounts, logger).catch(() => { });
|
|
219
|
+
const initial = setTimeout(probe, LOGIN_PROBE_INITIAL_DELAY_MS);
|
|
164
220
|
initial.unref?.();
|
|
165
|
-
loginProbeTimer = setInterval(
|
|
221
|
+
loginProbeTimer = setInterval(probe, LOGIN_PROBE_INTERVAL_MS);
|
|
166
222
|
loginProbeTimer.unref?.();
|
|
167
223
|
}
|
|
168
224
|
const warnedTokenFileModes = new Set();
|
|
@@ -193,9 +249,9 @@ function peekToken(account) {
|
|
|
193
249
|
return undefined;
|
|
194
250
|
throw new Error(`[multi-clawd] account "${account.id}" needs oauthTokenFile, oauthTokenRef, or configDir`);
|
|
195
251
|
}
|
|
196
|
-
async function resolveTokenAsync(account) {
|
|
252
|
+
async function resolveTokenAsync(account, resolver) {
|
|
197
253
|
if (isSecretRefShape(account.oauthTokenRef) && !account.native && !account.oauthTokenFile) {
|
|
198
|
-
return activeTokenResolver?.resolve(account.oauthTokenRef);
|
|
254
|
+
return (resolver ?? activeTokenResolver)?.resolve(account.oauthTokenRef);
|
|
199
255
|
}
|
|
200
256
|
return peekToken(account);
|
|
201
257
|
}
|
|
@@ -330,8 +386,8 @@ export function buildBackend(account, execMode) {
|
|
|
330
386
|
},
|
|
331
387
|
};
|
|
332
388
|
}
|
|
333
|
-
async function buildAccountEnv(account) {
|
|
334
|
-
const token = await resolveTokenAsync(account);
|
|
389
|
+
async function buildAccountEnv(account, resolver) {
|
|
390
|
+
const token = await resolveTokenAsync(account, resolver);
|
|
335
391
|
return buildAccountChildEnv(account, token, healthStateFile(account.id));
|
|
336
392
|
}
|
|
337
393
|
function buildCatalogProvider(account) {
|
|
@@ -424,8 +480,7 @@ export default definePluginEntry({
|
|
|
424
480
|
const logger = api.logger;
|
|
425
481
|
try {
|
|
426
482
|
api.on("heartbeat_prompt_contribution", () => {
|
|
427
|
-
|
|
428
|
-
const text = pendingAlertText(alertState, Date.now());
|
|
483
|
+
const text = pendingOperatorAlerts(Date.now());
|
|
429
484
|
return text ? { appendContext: text } : undefined;
|
|
430
485
|
});
|
|
431
486
|
}
|
|
@@ -445,6 +500,32 @@ function readHealthState(accountId) {
|
|
|
445
500
|
return undefined;
|
|
446
501
|
}
|
|
447
502
|
}
|
|
503
|
+
export function clearAccountCredentialFailure(accountId) {
|
|
504
|
+
const file = healthStateFile(accountId);
|
|
505
|
+
let state;
|
|
506
|
+
try {
|
|
507
|
+
state = parseStoredState(readFileSync(file, "utf8")) ?? {
|
|
508
|
+
accountId,
|
|
509
|
+
windows: {},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
if (state.credential?.status !== "failed")
|
|
516
|
+
return false;
|
|
517
|
+
const cleared = mergeHealthStates(state, clearCredentialFailure(state, Date.now()), Date.now());
|
|
518
|
+
try {
|
|
519
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
520
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
521
|
+
writeFileSync(tmp, JSON.stringify(cleared, null, 2), { mode: 0o600 });
|
|
522
|
+
renameSync(tmp, file);
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
448
529
|
function readStickyEntry(file) {
|
|
449
530
|
try {
|
|
450
531
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -471,7 +552,7 @@ function writeStickyEntry(file, entry, logger) {
|
|
|
471
552
|
logger.warn(`[multi-clawd] sticky state write failed: ${String(err)}`);
|
|
472
553
|
}
|
|
473
554
|
}
|
|
474
|
-
export function registerPoolBackend(api, pool, accounts, registeredIds, execMode) {
|
|
555
|
+
export function registerPoolBackend(api, pool, accounts, registeredIds, execMode, deps) {
|
|
475
556
|
const logger = api.logger;
|
|
476
557
|
if (!pool)
|
|
477
558
|
return;
|
|
@@ -498,6 +579,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
498
579
|
const options = {
|
|
499
580
|
utilizationThreshold: pool.utilizationThreshold,
|
|
500
581
|
staleAfterMs: pool.staleAfterMs,
|
|
582
|
+
rotateOnOverage: pool.rotateOnOverage,
|
|
501
583
|
};
|
|
502
584
|
const poolAccount = {
|
|
503
585
|
id: poolId,
|
|
@@ -511,10 +593,22 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
511
593
|
backend.prepareExecution = async (ctx) => {
|
|
512
594
|
const now = Date.now();
|
|
513
595
|
const requestedModel = canonicalModelId(ctx.modelId) ?? ctx.modelId;
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
596
|
+
const states = members.map((a) => ({ id: a.id, state: readHealthState(a.id) }));
|
|
597
|
+
const verdicts = states.map(({ id, state }) => ({
|
|
598
|
+
id,
|
|
599
|
+
health: classifyAccountHealth(state, options, now, requestedModel),
|
|
517
600
|
}));
|
|
601
|
+
if (allCredentialFailed(verdicts.map((v) => ({ id: v.id, verdict: v.health.verdict })))) {
|
|
602
|
+
const detail = verdicts
|
|
603
|
+
.map((v) => `${v.id} (${v.health.reason ?? "credential rejected"})`)
|
|
604
|
+
.join("; ");
|
|
605
|
+
const text = `pool ${poolId}: every account's login is rejected by the Claude CLI — ` +
|
|
606
|
+
`re-authenticate with \`multi-clawd login <account>\`. ${detail}`;
|
|
607
|
+
logger.error(`[multi-clawd] ${text}`);
|
|
608
|
+
raiseAlert({ key: `pool-credentials:${poolId}`, severity: "error", text });
|
|
609
|
+
writeStickyEntry(stickyFile, undefined, logger);
|
|
610
|
+
throw new Error(`[multi-clawd] ${text}`);
|
|
611
|
+
}
|
|
518
612
|
const previousSticky = readStickyEntry(stickyFile);
|
|
519
613
|
const decision = decideStickySelection({
|
|
520
614
|
verdicts: verdicts.map((v) => ({ id: v.id, verdict: v.health.verdict })),
|
|
@@ -532,15 +626,72 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
532
626
|
logger.info(`[multi-clawd] ${line}`);
|
|
533
627
|
raiseAlert({ key: `rotation:${poolId}`, severity: "info", text: line });
|
|
534
628
|
}
|
|
629
|
+
const exhaustionPrefix = `pool-exhausted:${poolId}:`;
|
|
630
|
+
for (const key of alertKeysWithPrefix(alertState, exhaustionPrefix)) {
|
|
631
|
+
const alertedModel = key.slice(exhaustionPrefix.length);
|
|
632
|
+
const stillExhausted = states.every(({ state }) => classifyAccountHealth(state, options, now, alertedModel).verdict === "exhausted");
|
|
633
|
+
if (!stillExhausted)
|
|
634
|
+
alertState = clearAlert(alertState, key);
|
|
635
|
+
}
|
|
535
636
|
if (verdicts.every((v) => v.health.verdict === "exhausted")) {
|
|
536
637
|
raiseAlert({
|
|
537
|
-
key:
|
|
638
|
+
key: `${exhaustionPrefix}${requestedModel}`,
|
|
538
639
|
severity: "error",
|
|
539
640
|
text: `pool ${poolId}: every account is exhausted for ${requestedModel} — turns are degrading or falling through the chain`,
|
|
540
641
|
});
|
|
541
642
|
}
|
|
643
|
+
for (const v of verdicts) {
|
|
644
|
+
const key = `overage:${poolId}:${v.id}`;
|
|
645
|
+
if (v.health.overageAdvisory) {
|
|
646
|
+
raiseAlert({
|
|
647
|
+
key,
|
|
648
|
+
severity: "info",
|
|
649
|
+
text: `pool ${poolId}: account "${v.id}" — ${v.health.overageAdvisory}`,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
alertState = clearAlert(alertState, key);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
for (const v of verdicts) {
|
|
657
|
+
const key = `credential:${poolId}:${v.id}`;
|
|
658
|
+
if (v.health.verdict === "credential_failed") {
|
|
659
|
+
raiseAlert({
|
|
660
|
+
key,
|
|
661
|
+
severity: "error",
|
|
662
|
+
text: `pool ${poolId}: account "${v.id}" is excluded — ${v.health.reason ?? "its login was rejected by the Claude CLI"}. Fix with \`multi-clawd login ${v.id}\`.`,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
alertState = clearAlert(alertState, key);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
alertState = clearAlert(alertState, `pool-credentials:${poolId}`);
|
|
542
670
|
writeStickyEntry(stickyFile, decision.sticky, logger);
|
|
543
|
-
const
|
|
671
|
+
const order = [chosen, ...members.filter((m) => m.id !== chosen.id)];
|
|
672
|
+
let env;
|
|
673
|
+
const unresolved = [];
|
|
674
|
+
for (const candidate of order) {
|
|
675
|
+
try {
|
|
676
|
+
env = await buildAccountEnv(candidate, deps?.resolver);
|
|
677
|
+
if (candidate.id !== chosen.id) {
|
|
678
|
+
logger.warn(`[multi-clawd] pool ${poolId}: ${chosen.id}'s credential did not resolve — ` +
|
|
679
|
+
`launching on ${candidate.id} instead (account not benched; secret provider may be down)`);
|
|
680
|
+
}
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
unresolved.push(`${candidate.id} (${err.message})`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (!env) {
|
|
688
|
+
const text = `pool ${poolId}: no account's credential could be resolved — ` +
|
|
689
|
+
`the secret provider is unreachable or every reference is empty. ${unresolved.join("; ")}`;
|
|
690
|
+
logger.error(`[multi-clawd] ${text}`);
|
|
691
|
+
raiseAlert({ key: `pool-unresolvable:${poolId}`, severity: "error", text });
|
|
692
|
+
throw new Error(`[multi-clawd] ${text}`);
|
|
693
|
+
}
|
|
694
|
+
alertState = clearAlert(alertState, `pool-unresolvable:${poolId}`);
|
|
544
695
|
if (ladder.length > 0) {
|
|
545
696
|
const pinned = matchesPin(pins, {
|
|
546
697
|
agentDir: ctx.agentDir ?? "",
|
package/dist/login-health.js
CHANGED
|
@@ -15,6 +15,7 @@ export function createRefProbeTracker(options = {}) {
|
|
|
15
15
|
firstFailureAt = undefined;
|
|
16
16
|
return {
|
|
17
17
|
status: "broken",
|
|
18
|
+
cause: "credential",
|
|
18
19
|
reason: "oauthTokenRef resolved to nothing (credential problem)",
|
|
19
20
|
};
|
|
20
21
|
}
|
|
@@ -25,6 +26,7 @@ export function createRefProbeTracker(options = {}) {
|
|
|
25
26
|
if (consecutive >= deadAfterConsecutive && elapsed >= deadAfterMs) {
|
|
26
27
|
return {
|
|
27
28
|
status: "broken",
|
|
29
|
+
cause: "provider",
|
|
28
30
|
reason: `resolver failing ${deadAfterConsecutive}+ consecutive probes over ${Math.round(deadAfterMs / 60000)}m`,
|
|
29
31
|
};
|
|
30
32
|
}
|
package/dist/shim-core.js
CHANGED
|
@@ -89,12 +89,25 @@ export function parseStoredState(raw) {
|
|
|
89
89
|
isUsingOverage: typeof w.isUsingOverage === "boolean" ? w.isUsingOverage : undefined,
|
|
90
90
|
seenAt: w.seenAt,
|
|
91
91
|
rawInfo: typeof w.rawInfo === "string" ? w.rawInfo : undefined,
|
|
92
|
+
model: typeof w.model === "string" ? w.model : undefined,
|
|
92
93
|
};
|
|
93
94
|
}
|
|
95
|
+
let credential;
|
|
96
|
+
if (typeof p.credential === "object" && p.credential !== null) {
|
|
97
|
+
const c = p.credential;
|
|
98
|
+
if ((c.status === "failed" || c.status === "ok") && typeof c.seenAt === "number") {
|
|
99
|
+
credential = {
|
|
100
|
+
status: c.status,
|
|
101
|
+
reason: typeof c.reason === "string" ? c.reason : undefined,
|
|
102
|
+
seenAt: c.seenAt,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
94
106
|
return {
|
|
95
107
|
accountId: typeof p.accountId === "string" ? p.accountId : "unknown",
|
|
96
108
|
updatedAt: typeof p.updatedAt === "number" ? p.updatedAt : undefined,
|
|
97
109
|
windows,
|
|
110
|
+
credential,
|
|
98
111
|
};
|
|
99
112
|
}
|
|
100
113
|
export function mergeHealthStates(disk, live, now, pruneAfterMs = PRUNE_AFTER_MS) {
|
|
@@ -119,11 +132,19 @@ export function mergeHealthStates(disk, live, now, pruneAfterMs = PRUNE_AFTER_MS
|
|
|
119
132
|
}
|
|
120
133
|
}
|
|
121
134
|
}
|
|
135
|
+
let credential = disk.credential;
|
|
136
|
+
if (live.credential && (!credential || live.credential.seenAt >= credential.seenAt)) {
|
|
137
|
+
credential = live.credential;
|
|
138
|
+
}
|
|
139
|
+
if (now !== undefined && credential && now - credential.seenAt > pruneAfterMs) {
|
|
140
|
+
credential = undefined;
|
|
141
|
+
}
|
|
122
142
|
const updatedAt = Math.max(disk.updatedAt ?? 0, live.updatedAt ?? 0);
|
|
123
143
|
return {
|
|
124
144
|
accountId: live.accountId,
|
|
125
145
|
updatedAt: updatedAt > 0 ? updatedAt : undefined,
|
|
126
146
|
windows,
|
|
147
|
+
credential,
|
|
127
148
|
};
|
|
128
149
|
}
|
|
129
150
|
const MODEL_ID_PROVIDER_PREFIXES = [
|
|
@@ -207,7 +228,71 @@ export function recordModelLimit(state, modelId, now, resetsAt) {
|
|
|
207
228
|
},
|
|
208
229
|
};
|
|
209
230
|
}
|
|
210
|
-
|
|
231
|
+
const AUTH_FAILURE_PATTERNS = [
|
|
232
|
+
/oauth (session|token) (has )?(expired|been revoked)/i,
|
|
233
|
+
/failed to authenticate/i,
|
|
234
|
+
/invalid (api key|bearer token|access token)/i,
|
|
235
|
+
/authentication[_ ]?error/i,
|
|
236
|
+
/please run \/login/i,
|
|
237
|
+
/not logged in/i,
|
|
238
|
+
/unauthori[sz]ed/i,
|
|
239
|
+
];
|
|
240
|
+
const AUTH_REASON_MAX_CHARS = 200;
|
|
241
|
+
export function parseAuthFailure(line) {
|
|
242
|
+
if (!/(authenticat|oauth|logged in|\/login|unauthori|api key|access token|bearer token)/i.test(line)) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
let record;
|
|
246
|
+
try {
|
|
247
|
+
record = JSON.parse(line);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
if (typeof record !== "object" || record === null)
|
|
253
|
+
return undefined;
|
|
254
|
+
const r = record;
|
|
255
|
+
const isErrorRecord = r.type === "error" ||
|
|
256
|
+
r.is_error === true ||
|
|
257
|
+
(typeof r.subtype === "string" && r.subtype.startsWith("error"));
|
|
258
|
+
if (!isErrorRecord)
|
|
259
|
+
return undefined;
|
|
260
|
+
const texts = [];
|
|
261
|
+
if (typeof r.result === "string")
|
|
262
|
+
texts.push(r.result);
|
|
263
|
+
if (typeof r.error === "string")
|
|
264
|
+
texts.push(r.error);
|
|
265
|
+
if (typeof r.error === "object" && r.error !== null) {
|
|
266
|
+
const msg = r.error.message;
|
|
267
|
+
if (typeof msg === "string")
|
|
268
|
+
texts.push(msg);
|
|
269
|
+
}
|
|
270
|
+
for (const text of texts) {
|
|
271
|
+
if (AUTH_FAILURE_PATTERNS.some((p) => p.test(text))) {
|
|
272
|
+
return { reason: text.trim().slice(0, AUTH_REASON_MAX_CHARS) };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
export function recordCredentialFailure(state, reason, now) {
|
|
278
|
+
return {
|
|
279
|
+
...state,
|
|
280
|
+
updatedAt: now,
|
|
281
|
+
credential: {
|
|
282
|
+
status: "failed",
|
|
283
|
+
reason: reason.slice(0, AUTH_REASON_MAX_CHARS),
|
|
284
|
+
seenAt: now,
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
export function clearCredentialFailure(state, now) {
|
|
289
|
+
return {
|
|
290
|
+
...state,
|
|
291
|
+
updatedAt: now,
|
|
292
|
+
credential: { status: "ok", seenAt: now },
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
export function updateHealthState(state, event, now, model) {
|
|
211
296
|
const key = event.rateLimitType ?? "unknown";
|
|
212
297
|
return {
|
|
213
298
|
...state,
|
|
@@ -221,6 +306,7 @@ export function updateHealthState(state, event, now) {
|
|
|
221
306
|
isUsingOverage: event.isUsingOverage,
|
|
222
307
|
seenAt: now,
|
|
223
308
|
rawInfo: event.rawInfo,
|
|
309
|
+
model: model === undefined ? undefined : canonicalizeModelIdForWindow(model),
|
|
224
310
|
},
|
|
225
311
|
},
|
|
226
312
|
};
|
package/dist/shim.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
import { classifyStateReadFailure, createLineScanner, mergeHealthStates, parseRateLimitEvent, parseStoredState, updateHealthState, } from "./shim-core.js";
|
|
4
|
+
import { classifyStateReadFailure, clearCredentialFailure, createLineScanner, mergeHealthStates, parseAuthFailure, parseRateLimitEvent, parseStoredState, recordCredentialFailure, updateHealthState, } from "./shim-core.js";
|
|
5
5
|
import { rewriteModelArg } from "./degrade.js";
|
|
6
6
|
import { parseModelLimitError, recordModelLimit } from "./shim-core.js";
|
|
7
7
|
import { canonicalModelId } from "./models.js";
|
|
@@ -108,11 +108,12 @@ function guessLimitResetsAt() {
|
|
|
108
108
|
}
|
|
109
109
|
return undefined;
|
|
110
110
|
}
|
|
111
|
+
let sawAuthFailure = false;
|
|
111
112
|
const scanner = createLineScanner((line) => {
|
|
112
113
|
try {
|
|
113
114
|
const event = parseRateLimitEvent(line);
|
|
114
115
|
if (event) {
|
|
115
|
-
state = updateHealthState(state, event, Date.now());
|
|
116
|
+
state = updateHealthState(state, event, Date.now(), effectiveModelId());
|
|
116
117
|
persistState();
|
|
117
118
|
}
|
|
118
119
|
const limitHit = parseModelLimitError(line);
|
|
@@ -124,6 +125,13 @@ const scanner = createLineScanner((line) => {
|
|
|
124
125
|
process.stderr.write(`[multi-clawd shim] model limit hit recorded: ${model} (reported as "${limitHit.displayName}")\n`);
|
|
125
126
|
}
|
|
126
127
|
}
|
|
128
|
+
const authFailure = parseAuthFailure(line);
|
|
129
|
+
if (authFailure && !sawAuthFailure) {
|
|
130
|
+
sawAuthFailure = true;
|
|
131
|
+
state = recordCredentialFailure(state, authFailure.reason, Date.now());
|
|
132
|
+
persistState();
|
|
133
|
+
process.stderr.write(`[multi-clawd shim] auth failure recorded for ${accountId}: ${authFailure.reason}\n`);
|
|
134
|
+
}
|
|
127
135
|
}
|
|
128
136
|
catch {
|
|
129
137
|
}
|
|
@@ -138,11 +146,26 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
|
138
146
|
child.kill(signal);
|
|
139
147
|
});
|
|
140
148
|
}
|
|
149
|
+
function clearRecordedAuthFailureOnSuccess() {
|
|
150
|
+
if (!stateFile || sawAuthFailure)
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
if (readPersistedState()?.credential?.status !== "failed")
|
|
154
|
+
return;
|
|
155
|
+
state = clearCredentialFailure(state, Date.now());
|
|
156
|
+
persistState();
|
|
157
|
+
process.stderr.write(`[multi-clawd shim] auth recovered for ${accountId} — credential exclusion cleared\n`);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
}
|
|
161
|
+
}
|
|
141
162
|
child.on("close", (code, signal) => {
|
|
142
163
|
if (signal) {
|
|
143
164
|
process.kill(process.pid, signal);
|
|
144
165
|
return;
|
|
145
166
|
}
|
|
167
|
+
if ((code ?? 0) === 0)
|
|
168
|
+
clearRecordedAuthFailureOnSuccess();
|
|
146
169
|
process.exit(code ?? 0);
|
|
147
170
|
});
|
|
148
171
|
child.on("error", (err) => {
|
package/dist/sticky.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { choosePoolAccount } from "./health.js";
|
|
1
|
+
import { choosePoolAccount, fallbackPoolAccount } from "./health.js";
|
|
2
2
|
export const DEFAULT_MIN_DWELL_MS = 10 * 60 * 1000;
|
|
3
3
|
export function decideStickySelection(params) {
|
|
4
4
|
const { verdicts, sticky, nowMs } = params;
|
|
@@ -6,7 +6,7 @@ export function decideStickySelection(params) {
|
|
|
6
6
|
const home = verdicts[0];
|
|
7
7
|
const healthChoice = choosePoolAccount(verdicts);
|
|
8
8
|
if (!healthChoice)
|
|
9
|
-
return { account:
|
|
9
|
+
return { account: fallbackPoolAccount(verdicts) };
|
|
10
10
|
const stickyVerdict = sticky
|
|
11
11
|
? verdicts.find((v) => v.id === sticky.account)?.verdict
|
|
12
12
|
: undefined;
|
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.8.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",
|
|
@@ -51,6 +51,10 @@
|
|
|
51
51
|
"type": "number",
|
|
52
52
|
"description": "Ignore health data older than this. Default 21600000 (6h)."
|
|
53
53
|
},
|
|
54
|
+
"rotateOnOverage": {
|
|
55
|
+
"type": "boolean",
|
|
56
|
+
"description": "Rotate when the seven_day_overage_included window (quota PLUS purchased spill-over) crosses utilizationThreshold. Default false: high utilization there means little paid overage left, not little quota left, so the pool keeps using the overage you have paid for and raises an operator alert instead of deciding for you."
|
|
57
|
+
},
|
|
54
58
|
"minDwellMs": {
|
|
55
59
|
"type": "number",
|
|
56
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)."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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
|
@@ -461,10 +461,11 @@ async function login() {
|
|
|
461
461
|
const { readFileSync: rf, existsSync, mkdirSync, chmodSync, statSync, mkdtempSync, rmSync } =
|
|
462
462
|
await import("node:fs");
|
|
463
463
|
const { homedir, tmpdir } = await import("node:os");
|
|
464
|
-
let lp, ec;
|
|
464
|
+
let lp, ec, idx;
|
|
465
465
|
try {
|
|
466
466
|
lp = await import(resolve(__dirname, "..", "dist", "login-plan.js"));
|
|
467
467
|
ec = await import(resolve(__dirname, "..", "dist", "explain-core.js"));
|
|
468
|
+
idx = await import(resolve(__dirname, "..", "dist", "index.js"));
|
|
468
469
|
} catch {
|
|
469
470
|
console.error("login: built dist/ is missing — reinstall the package.");
|
|
470
471
|
process.exit(1);
|
|
@@ -513,6 +514,17 @@ async function login() {
|
|
|
513
514
|
console.error(`\n ❌ ${plan.command.join(" ")} exited with ${r.status ?? "an error"}.`);
|
|
514
515
|
process.exit(1);
|
|
515
516
|
}
|
|
517
|
+
// Explicit re-authentication ends any recorded runtime credential failure
|
|
518
|
+
// immediately (#8) — without this the freshly re-authed account stays
|
|
519
|
+
// benched until the 15-minute TTL expires, which reads to the operator as
|
|
520
|
+
// "logging back in did nothing".
|
|
521
|
+
try {
|
|
522
|
+
if (idx.clearAccountCredentialFailure?.(acc.id)) {
|
|
523
|
+
console.log(`\n ↻ cleared ${acc.id}'s recorded login failure — the pool can use it again.`);
|
|
524
|
+
}
|
|
525
|
+
} catch {
|
|
526
|
+
/* clearing is a courtesy; never fail a login over it */
|
|
527
|
+
}
|
|
516
528
|
if (plan.verify === "auth-status") {
|
|
517
529
|
try {
|
|
518
530
|
const out = spawnSync("claude", ["auth", "status"], { encoding: "utf8", env }).stdout ?? "";
|
package/scripts/doctor.mjs
CHANGED
|
@@ -263,6 +263,20 @@ const io = {
|
|
|
263
263
|
const accounts = pluginConfig.accounts ?? [];
|
|
264
264
|
if (accounts.length === 0) warn("no accounts configured");
|
|
265
265
|
for (const account of accounts) {
|
|
266
|
+
// RUNTIME credential health first: the source check below only proves a
|
|
267
|
+
// credential EXISTS, and #8 is exactly the case where a present credential
|
|
268
|
+
// is a session the Claude CLI has already rejected. A recorded runtime
|
|
269
|
+
// failure is the stronger evidence, so it is reported as such.
|
|
270
|
+
const recorded = readJson(join(STATE_DIR, `${account.id}.json`))?.credential;
|
|
271
|
+
if (recorded?.status === "failed") {
|
|
272
|
+
const ageMin = Math.round((Date.now() - recorded.seenAt) / 60000);
|
|
273
|
+
bad(
|
|
274
|
+
`${account.id}: the Claude CLI rejected this login ${ageMin}m ago${
|
|
275
|
+
recorded.reason ? ` (${recorded.reason})` : ""
|
|
276
|
+
} — excluded from the pool; fix with \`multi-clawd login ${account.id}\``,
|
|
277
|
+
);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
266
280
|
if (account.oauthTokenRef) {
|
|
267
281
|
warn(`${account.id}: oauthTokenRef — validated by the gateway's async probe, not doctor`);
|
|
268
282
|
continue;
|