@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 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.7.3/SETUP-AGENT.md
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;
@@ -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 fresh = resetBearing || nowMs - w.seenAt <= staleAfterMs;
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" && resetBearing && isPeriodWindow(window)) {
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" && resetMs === undefined && isPeriodWindow(window)) {
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 + MODEL_REJECTED_TTL_MS,
76
- reason: `${window} rejected ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; TTL block)`,
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 (worst.verdict === "ok" &&
80
- typeof w.utilization === "number" &&
126
+ if (typeof w.utilization === "number" &&
81
127
  w.utilization >= threshold &&
82
128
  (resetBearing || resetMs === undefined)) {
83
- worst = {
84
- verdict: "near_limit",
85
- reason: `${window} utilization ${w.utilization} >= ${threshold}`,
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[0].id;
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
- function startLoginHealthProbe(accounts, logger) {
120
- if (loginProbeTimer)
121
- clearInterval(loginProbeTimer);
122
- const lastStatus = new Map();
123
- const refTrackers = new Map();
124
- const probe = async () => {
125
- const now = Date.now();
126
- for (const account of accounts) {
127
- let status;
128
- let reason;
129
- if (isSecretRefShape(account.oauthTokenRef) && !account.oauthTokenFile && !account.native) {
130
- let tracker = refTrackers.get(account.id);
131
- if (!tracker) {
132
- tracker = createRefProbeTracker();
133
- refTrackers.set(account.id, tracker);
134
- }
135
- const result = (await activeTokenResolver?.resolveDetailed(account.oauthTokenRef)) ?? {
136
- failure: "provider_error",
137
- };
138
- const outcome = tracker.observe(result, now);
139
- status = outcome.status;
140
- reason = outcome.reason;
141
- }
142
- else {
143
- const check = checkAccountCredential(account, realCredentialIo);
144
- status = check.status;
145
- reason = check.reason;
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 previous = lastStatus.get(account.id);
148
- lastStatus.set(account.id, status);
149
- if (status === "broken" && previous !== "broken") {
150
- const text = `account "${account.id}" login looks dead (${reason ?? "unknown"}) — turns on it will fail until fixed`;
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
- else if (status === "degraded" && previous !== "degraded") {
155
- logger.info(`[multi-clawd] account "${account.id}" login degraded: ${reason ?? "resolver error"}`);
156
- }
157
- else if (status === "ok" && (previous === "broken" || previous === "degraded")) {
158
- logger.info(`[multi-clawd] account "${account.id}" login recovered`);
159
- alertState = clearAlert(alertState, `login:${account.id}`);
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
- const initial = setTimeout(() => void probe().catch(() => { }), LOGIN_PROBE_INITIAL_DELAY_MS);
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(() => void probe().catch(() => { }), LOGIN_PROBE_INTERVAL_MS);
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
- ingestAlertSpool();
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 verdicts = members.map((a) => ({
515
- id: a.id,
516
- health: classifyAccountHealth(readHealthState(a.id), options, now, requestedModel),
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: `pool-exhausted:${poolId}:${requestedModel}`,
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 env = await buildAccountEnv(chosen);
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 ?? "",
@@ -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
- export function updateHealthState(state, event, now) {
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: home.id };
9
+ return { account: fallbackPoolAccount(verdicts) };
10
10
  const stickyVerdict = sticky
11
11
  ? verdicts.find((v) => v.id === sticky.account)?.verdict
12
12
  : undefined;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.7.3",
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.7.3",
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 ?? "";
@@ -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;