@drakon-systems/multi-clawd 1.9.8 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -17
- package/dist/account-env.js +41 -3
- package/dist/account-identity.js +5 -0
- package/dist/credential-state.js +3 -0
- package/dist/credential-store.js +222 -0
- package/dist/direct-route.js +2 -1
- package/dist/explain-core.js +3 -0
- package/dist/health.js +30 -5
- package/dist/index.js +218 -30
- package/dist/login-health.js +137 -40
- package/dist/retry-plan.js +38 -3
- package/dist/shim-core.js +3 -1
- package/dist/shim.js +5 -2
- package/dist/usage-poll.js +372 -0
- package/openclaw.plugin.json +20 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +113 -0
- package/scripts/doctor.mjs +116 -49
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { MODEL_ALIASES, buildCatalogEntries, canonicalModelId, isModernClaudeMod
|
|
|
7
7
|
import { decideDegradation, matchesPin } from "./degrade.js";
|
|
8
8
|
import { resolveExecMode, permissionModeArgs } from "./exec-policy.js";
|
|
9
9
|
import { resolveBaseModelIds } from "./catalog-source.js";
|
|
10
|
-
import { allCredentialFailed, classifyAccountHealth, overdueProbeAccount, } from "./health.js";
|
|
10
|
+
import { allCredentialFailed, classifyAccountHealth, lastAuthSuccessAt, overdueProbeAccount, } from "./health.js";
|
|
11
11
|
import { decideStickySelection } from "./sticky.js";
|
|
12
12
|
import { mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
|
|
13
13
|
import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
|
|
@@ -16,12 +16,13 @@ import { resolvePoolExecutionArgs } from "./tool-cap.js";
|
|
|
16
16
|
import { RETRY_ROSTER_ENV } from "./retry-plan.js";
|
|
17
17
|
import { SESSION_DIRS_ENV } from "./session-handover.js";
|
|
18
18
|
import { addAlert, alertKeysWithPrefix, clearAlert, pendingAlertText, } from "./alerts.js";
|
|
19
|
-
import { healthStateFile, clearAccountCredentialFailure } from "./credential-state.js";
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
import { healthStateFile, usageStateFile, clearAccountCredentialFailure } from "./credential-state.js";
|
|
20
|
+
import { createUsagePollController, effectiveUsagePollInterval, effectiveUsageWarnThreshold, usageAlertPrefix, usagePoolAlertKey, } from "./usage-poll.js";
|
|
21
|
+
export { healthStateFile, usageStateFile, clearAccountCredentialFailure };
|
|
22
|
+
import { accountConfigDir, AccountCredentialError, buildAccountChildEnv, credentialFailureText, parseSetupTokenFile, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
|
|
22
23
|
import { diffCatalogModels, formatNewModelNotice, } from "./model-currency.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
24
|
+
import { assessAccountCredential, createRefProbeTracker, } from "./login-health.js";
|
|
25
|
+
import { createSystemCredentialIo } from "./credential-store.js";
|
|
25
26
|
import { collectDirectMembers } from "./direct-route.js";
|
|
26
27
|
import { createDirectOrderController, isValidAgentId } from "./direct-sync.js";
|
|
27
28
|
import { createOpenclawRunner } from "./openclaw-runner.js";
|
|
@@ -155,21 +156,7 @@ function ingestAlertSpool() {
|
|
|
155
156
|
}
|
|
156
157
|
const LOGIN_PROBE_INTERVAL_MS = 15 * 60 * 1000;
|
|
157
158
|
const LOGIN_PROBE_INITIAL_DELAY_MS = 45 * 1000;
|
|
158
|
-
|
|
159
|
-
readFile: (p) => readFileSync(expandHome(p), "utf8"),
|
|
160
|
-
keychainHasClaudeCredentials: () => keychainHasService("Claude Code-credentials"),
|
|
161
|
-
keychainHasClaudeCredentialsForDir: (dir) => keychainHasService(keychainServiceForConfigDir(expandHome(dir))),
|
|
162
|
-
platform: process.platform,
|
|
163
|
-
};
|
|
164
|
-
function keychainHasService(service) {
|
|
165
|
-
try {
|
|
166
|
-
execFileSync("security", ["find-generic-password", "-s", service], { stdio: "ignore" });
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
catch {
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
159
|
+
let realCredentialIo;
|
|
173
160
|
const refProbeTrackers = new Map();
|
|
174
161
|
const lastProbeStatus = new Map();
|
|
175
162
|
function recordAccountCredentialFailure(accountId, reason, nowMs) {
|
|
@@ -194,7 +181,8 @@ function recordAccountCredentialFailure(accountId, reason, nowMs) {
|
|
|
194
181
|
export async function runLoginHealthProbe(accounts, logger, deps = {}) {
|
|
195
182
|
const now = deps.nowMs ?? Date.now();
|
|
196
183
|
const resolver = deps.resolver ?? activeTokenResolver;
|
|
197
|
-
const io = deps.io ?? realCredentialIo;
|
|
184
|
+
const io = deps.io ?? (realCredentialIo ??= createSystemCredentialIo());
|
|
185
|
+
const readHealth = deps.readHealth ?? readHealthState;
|
|
198
186
|
for (const account of accounts) {
|
|
199
187
|
let status;
|
|
200
188
|
let reason;
|
|
@@ -214,9 +202,12 @@ export async function runLoginHealthProbe(accounts, logger, deps = {}) {
|
|
|
214
202
|
cause = outcome.cause;
|
|
215
203
|
}
|
|
216
204
|
else {
|
|
217
|
-
const check =
|
|
205
|
+
const check = assessAccountCredential(account, io, {
|
|
206
|
+
proof: { lastSuccessAt: lastAuthSuccessAt(readHealth(account.id)) },
|
|
207
|
+
nowMs: now,
|
|
208
|
+
});
|
|
218
209
|
status = check.status;
|
|
219
|
-
reason = check.reason;
|
|
210
|
+
reason = check.status === "ok" ? check.detail : check.reason;
|
|
220
211
|
}
|
|
221
212
|
const previous = lastProbeStatus.get(account.id);
|
|
222
213
|
lastProbeStatus.set(account.id, status);
|
|
@@ -242,14 +233,30 @@ export async function runLoginHealthProbe(accounts, logger, deps = {}) {
|
|
|
242
233
|
logger.error(`[multi-clawd] ${text}`);
|
|
243
234
|
raiseAlert({ key: `login:${account.id}`, severity: "error", text });
|
|
244
235
|
}
|
|
236
|
+
else if (status === "unverified" && previous !== "unverified") {
|
|
237
|
+
logger.info(`[multi-clawd] account "${account.id}" login unverified: ${reason ?? "unproven"}`);
|
|
238
|
+
}
|
|
239
|
+
else if (status === "unknown" && previous !== "unknown" && reason) {
|
|
240
|
+
logger.info(`[multi-clawd] account "${account.id}" login could not be checked: ${reason}`);
|
|
241
|
+
}
|
|
245
242
|
else if (status === "degraded" && previous !== "degraded") {
|
|
246
243
|
logger.info(`[multi-clawd] account "${account.id}" login degraded: ${reason ?? "resolver error"}`);
|
|
247
244
|
}
|
|
245
|
+
else if (status === "ok" && previous === "unverified") {
|
|
246
|
+
logger.info(`[multi-clawd] account "${account.id}" login verified: ${reason ?? "ok"}`);
|
|
247
|
+
}
|
|
248
248
|
else if (status === "ok" && (previous === "broken" || previous === "degraded")) {
|
|
249
249
|
logger.info(`[multi-clawd] account "${account.id}" login recovered`);
|
|
250
250
|
alertState = clearAlert(alertState, `login:${account.id}`);
|
|
251
251
|
alertState = clearAlert(alertState, `login-resolver:${account.id}`);
|
|
252
252
|
}
|
|
253
|
+
if (status === "ok") {
|
|
254
|
+
alertState = clearAlert(alertState, `login:${account.id}`);
|
|
255
|
+
alertState = clearAlert(alertState, `login-resolver:${account.id}`);
|
|
256
|
+
}
|
|
257
|
+
else if ((status === "unverified" || status === "unknown") && cause === undefined) {
|
|
258
|
+
alertState = clearAlert(alertState, `login:${account.id}`);
|
|
259
|
+
}
|
|
253
260
|
}
|
|
254
261
|
}
|
|
255
262
|
export function startLoginHealthProbe(accounts, logger) {
|
|
@@ -289,7 +296,20 @@ function peekToken(account) {
|
|
|
289
296
|
if (account.oauthTokenFile) {
|
|
290
297
|
const path = expandHome(account.oauthTokenFile);
|
|
291
298
|
warnIfTokenFileExposed(path);
|
|
292
|
-
|
|
299
|
+
let raw;
|
|
300
|
+
try {
|
|
301
|
+
raw = readFileSync(path, "utf8");
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
const code = err.code;
|
|
305
|
+
throw new AccountCredentialError(`[multi-clawd] account "${account.id}": token file ${account.oauthTokenFile} unreadable` +
|
|
306
|
+
(typeof code === "string" ? ` (${code})` : ""));
|
|
307
|
+
}
|
|
308
|
+
const read = parseSetupTokenFile(raw, `token file ${account.oauthTokenFile}`);
|
|
309
|
+
if ("error" in read) {
|
|
310
|
+
throw new AccountCredentialError(`[multi-clawd] account "${account.id}": ${read.error}`);
|
|
311
|
+
}
|
|
312
|
+
return read.token;
|
|
293
313
|
}
|
|
294
314
|
if (isSecretRefShape(account.oauthTokenRef)) {
|
|
295
315
|
return activeTokenResolver?.peek(account.oauthTokenRef);
|
|
@@ -449,13 +469,16 @@ export function buildRetryRoster(members, launchedId) {
|
|
|
449
469
|
for (const member of members) {
|
|
450
470
|
if (member.id === launchedId)
|
|
451
471
|
continue;
|
|
452
|
-
|
|
472
|
+
const tokenFile = !member.native && member.oauthTokenFile ? expandHome(member.oauthTokenFile) : undefined;
|
|
473
|
+
if (!member.native && !tokenFile && member.oauthTokenRef)
|
|
474
|
+
continue;
|
|
475
|
+
if (!member.native && !tokenFile && !member.configDir)
|
|
453
476
|
continue;
|
|
454
477
|
const stateFile = healthStateFile(member.id);
|
|
455
|
-
const env = buildAccountChildEnv(member, undefined, stateFile);
|
|
478
|
+
const env = buildAccountChildEnv({ ...member, oauthTokenFile: undefined, oauthTokenRef: undefined }, undefined, stateFile);
|
|
456
479
|
delete env.MULTI_CLAWD_ACCOUNT_ID;
|
|
457
480
|
delete env.MULTI_CLAWD_STATE_FILE;
|
|
458
|
-
roster.push({ id: member.id, stateFile, env });
|
|
481
|
+
roster.push({ id: member.id, stateFile, env, ...(tokenFile ? { tokenFile } : {}) });
|
|
459
482
|
}
|
|
460
483
|
return roster;
|
|
461
484
|
}
|
|
@@ -582,18 +605,34 @@ export default definePluginEntry({
|
|
|
582
605
|
},
|
|
583
606
|
logger,
|
|
584
607
|
});
|
|
608
|
+
startUsagePoll({
|
|
609
|
+
accounts: accounts.filter((a) => seen.has(a.id.trim())),
|
|
610
|
+
pool: cfg.pool,
|
|
611
|
+
registrationMode: api.registrationMode,
|
|
612
|
+
logger,
|
|
613
|
+
});
|
|
585
614
|
}
|
|
586
615
|
api.logger.info(`[multi-clawd] registered ${seen.size} backend(s)+provider(s): ${[...seen].join(", ")}`);
|
|
587
616
|
},
|
|
588
617
|
});
|
|
589
618
|
function readHealthState(accountId) {
|
|
619
|
+
return mergeStoredHealth(readStateFileLenient(healthStateFile(accountId)), readStateFileLenient(usageStateFile(accountId)));
|
|
620
|
+
}
|
|
621
|
+
function readStateFileLenient(file) {
|
|
590
622
|
try {
|
|
591
|
-
return JSON.parse(readFileSync(
|
|
623
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
592
624
|
}
|
|
593
625
|
catch {
|
|
594
626
|
return undefined;
|
|
595
627
|
}
|
|
596
628
|
}
|
|
629
|
+
function mergeStoredHealth(shim, usage) {
|
|
630
|
+
if (!shim)
|
|
631
|
+
return usage;
|
|
632
|
+
if (!usage)
|
|
633
|
+
return shim;
|
|
634
|
+
return mergeHealthStates(shim, usage);
|
|
635
|
+
}
|
|
597
636
|
function readStickyEntry(file) {
|
|
598
637
|
try {
|
|
599
638
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -699,6 +738,10 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
699
738
|
`launching on ${decision.account} to re-test a rejection last seen ` +
|
|
700
739
|
`${Math.round((now - observedAt) / 3600000)}h ago`);
|
|
701
740
|
}
|
|
741
|
+
else if (poolVerdicts.find((v) => v.id === decision.account)?.verdict === "credential_unverified") {
|
|
742
|
+
logger.info(`[multi-clawd] pool ${poolId}: no proven account can serve ${requestedModel} — ` +
|
|
743
|
+
`launching on ${decision.account} to re-test a login last rejected by the Claude CLI`);
|
|
744
|
+
}
|
|
702
745
|
else if (decision.account !== previousAccount) {
|
|
703
746
|
const home = verdicts[0];
|
|
704
747
|
const line = decision.account === members[0].id
|
|
@@ -743,6 +786,13 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
743
786
|
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}\`.`,
|
|
744
787
|
});
|
|
745
788
|
}
|
|
789
|
+
else if (v.health.verdict === "credential_unverified") {
|
|
790
|
+
raiseAlert({
|
|
791
|
+
key,
|
|
792
|
+
severity: "error",
|
|
793
|
+
text: `pool ${poolId}: account "${v.id}" is unproven — ${v.health.reason ?? "its login was last rejected by the Claude CLI"}. Fix with \`multi-clawd login ${v.id}\`.`,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
746
796
|
else {
|
|
747
797
|
alertState = clearAlert(alertState, key);
|
|
748
798
|
}
|
|
@@ -757,6 +807,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
757
807
|
try {
|
|
758
808
|
env = await buildAccountEnv(candidate, deps?.resolver);
|
|
759
809
|
launched = candidate;
|
|
810
|
+
alertState = clearAlert(alertState, `credential-unresolved:${candidate.id}`);
|
|
760
811
|
if (candidate.id !== chosen.id) {
|
|
761
812
|
logger.warn(`[multi-clawd] pool ${poolId}: ${chosen.id}'s credential did not resolve — ` +
|
|
762
813
|
`launching on ${candidate.id} instead (account not benched; secret provider may be down)`);
|
|
@@ -764,7 +815,14 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
764
815
|
break;
|
|
765
816
|
}
|
|
766
817
|
catch (err) {
|
|
767
|
-
|
|
818
|
+
const why = credentialFailureText(err);
|
|
819
|
+
unresolved.push(`${candidate.id} (${why})`);
|
|
820
|
+
raiseAlert({
|
|
821
|
+
key: `credential-unresolved:${candidate.id}`,
|
|
822
|
+
severity: "error",
|
|
823
|
+
text: `pool ${poolId}: account "${candidate.id}" was skipped — its declared credential did ` +
|
|
824
|
+
`not resolve, so it will not launch on any other login (${why})`,
|
|
825
|
+
});
|
|
768
826
|
}
|
|
769
827
|
}
|
|
770
828
|
if (!env) {
|
|
@@ -898,3 +956,133 @@ export function startDirectOrderSync(params) {
|
|
|
898
956
|
export async function runDirectOrderTickNow() {
|
|
899
957
|
return directController?.tick();
|
|
900
958
|
}
|
|
959
|
+
let usageTimer;
|
|
960
|
+
let usageInitial;
|
|
961
|
+
let usageController;
|
|
962
|
+
export function stopUsagePoll() {
|
|
963
|
+
if (usageTimer)
|
|
964
|
+
clearInterval(usageTimer);
|
|
965
|
+
if (usageInitial)
|
|
966
|
+
clearTimeout(usageInitial);
|
|
967
|
+
usageTimer = undefined;
|
|
968
|
+
usageInitial = undefined;
|
|
969
|
+
usageController = undefined;
|
|
970
|
+
}
|
|
971
|
+
export const USAGE_TOKEN_ACCOUNT_REASON = "setup-token login — the usage endpoint needs the user:profile scope and setup-tokens carry only " +
|
|
972
|
+
"user:inference, so usage comes from stream telemetry only";
|
|
973
|
+
export function usagePollCredentialsFile(account) {
|
|
974
|
+
if (account.oauthTokenFile || account.oauthTokenRef) {
|
|
975
|
+
return { reason: USAGE_TOKEN_ACCOUNT_REASON };
|
|
976
|
+
}
|
|
977
|
+
if (!account.native && !account.configDir) {
|
|
978
|
+
return { reason: "no login dir to read credentials from" };
|
|
979
|
+
}
|
|
980
|
+
return { file: join(accountConfigDir(account), ".credentials.json") };
|
|
981
|
+
}
|
|
982
|
+
export function startUsagePoll(params) {
|
|
983
|
+
const { logger, pool } = params;
|
|
984
|
+
const mode = params.registrationMode;
|
|
985
|
+
if (mode !== undefined && mode !== "full")
|
|
986
|
+
return { active: false, members: [] };
|
|
987
|
+
if (!pool || pool.usagePoll?.enabled === false) {
|
|
988
|
+
stopUsagePoll();
|
|
989
|
+
return { active: false, members: [] };
|
|
990
|
+
}
|
|
991
|
+
const poolId = pool.id?.trim() || "clawd";
|
|
992
|
+
const members = [];
|
|
993
|
+
const skipped = [];
|
|
994
|
+
for (const id of pool.accounts ?? []) {
|
|
995
|
+
const account = params.accounts.find((a) => a.id.trim() === id);
|
|
996
|
+
if (!account)
|
|
997
|
+
continue;
|
|
998
|
+
const source = usagePollCredentialsFile(account);
|
|
999
|
+
if ("file" in source)
|
|
1000
|
+
members.push({ id: account.id.trim(), credentialsFile: source.file });
|
|
1001
|
+
else
|
|
1002
|
+
skipped.push(`${account.id.trim()} (${source.reason})`);
|
|
1003
|
+
}
|
|
1004
|
+
if (members.length === 0) {
|
|
1005
|
+
stopUsagePoll();
|
|
1006
|
+
if (skipped.length > 0)
|
|
1007
|
+
logger.info(`[multi-clawd] usage poll: no pollable account — ${skipped.join("; ")}`);
|
|
1008
|
+
return { active: false, members: [] };
|
|
1009
|
+
}
|
|
1010
|
+
const intervalMs = effectiveUsagePollInterval(pool.usagePoll);
|
|
1011
|
+
const warnThreshold = effectiveUsageWarnThreshold(pool.usagePoll);
|
|
1012
|
+
const healthOptions = {
|
|
1013
|
+
utilizationThreshold: pool.utilizationThreshold,
|
|
1014
|
+
staleAfterMs: pool.staleAfterMs,
|
|
1015
|
+
rotateOnOverage: pool.rotateOnOverage,
|
|
1016
|
+
};
|
|
1017
|
+
const signature = JSON.stringify({ poolId, members, intervalMs, warnThreshold, healthOptions });
|
|
1018
|
+
if (usageController?.signature === signature && usageController.fetchImpl === params.fetchImpl && usageTimer) {
|
|
1019
|
+
return { active: true, members: members.map((m) => m.id) };
|
|
1020
|
+
}
|
|
1021
|
+
stopUsagePoll();
|
|
1022
|
+
const fetchImpl = params.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
1023
|
+
const controller = createUsagePollController({
|
|
1024
|
+
poolId,
|
|
1025
|
+
members,
|
|
1026
|
+
healthOptions,
|
|
1027
|
+
warnThreshold,
|
|
1028
|
+
io: {
|
|
1029
|
+
readFile: (path) => {
|
|
1030
|
+
try {
|
|
1031
|
+
return readFileSync(path, "utf8");
|
|
1032
|
+
}
|
|
1033
|
+
catch (err) {
|
|
1034
|
+
if (err.code === "ENOENT")
|
|
1035
|
+
return undefined;
|
|
1036
|
+
throw err;
|
|
1037
|
+
}
|
|
1038
|
+
},
|
|
1039
|
+
readHealth: (id) => {
|
|
1040
|
+
let shim;
|
|
1041
|
+
try {
|
|
1042
|
+
shim = parseStoredState(readFileSync(healthStateFile(id), "utf8"));
|
|
1043
|
+
if (!shim)
|
|
1044
|
+
throw new Error("not valid health-state JSON");
|
|
1045
|
+
}
|
|
1046
|
+
catch (err) {
|
|
1047
|
+
if (err.code !== "ENOENT")
|
|
1048
|
+
throw err;
|
|
1049
|
+
shim = undefined;
|
|
1050
|
+
}
|
|
1051
|
+
let usage;
|
|
1052
|
+
try {
|
|
1053
|
+
usage = parseStoredState(readFileSync(usageStateFile(id), "utf8"));
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
usage = undefined;
|
|
1057
|
+
}
|
|
1058
|
+
return mergeStoredHealth(shim, usage);
|
|
1059
|
+
},
|
|
1060
|
+
writeUsage: (id, state) => {
|
|
1061
|
+
const file = usageStateFile(id);
|
|
1062
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1063
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
1064
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
1065
|
+
renameSync(tmp, file);
|
|
1066
|
+
},
|
|
1067
|
+
fetchImpl,
|
|
1068
|
+
raiseAlert: (alert) => raiseAlert(alert),
|
|
1069
|
+
clearAlert: (key) => {
|
|
1070
|
+
alertState = clearAlert(alertState, key);
|
|
1071
|
+
},
|
|
1072
|
+
alertKeysWithPrefix: (prefix) => alertKeysWithPrefix(alertState, prefix),
|
|
1073
|
+
logger,
|
|
1074
|
+
},
|
|
1075
|
+
});
|
|
1076
|
+
usageController = { signature, fetchImpl: params.fetchImpl, tick: controller.tick };
|
|
1077
|
+
const tick = () => void controller.tick().catch((err) => logger.warn(`[multi-clawd] usage poll tick failed: ${String(err)}`));
|
|
1078
|
+
usageInitial = setTimeout(tick, Math.min(20_000, intervalMs));
|
|
1079
|
+
usageInitial.unref?.();
|
|
1080
|
+
usageTimer = setInterval(tick, intervalMs);
|
|
1081
|
+
usageTimer.unref?.();
|
|
1082
|
+
logger.info(`[multi-clawd] usage poll: reading live usage for ${members.map((m) => m.id).join(", ")} every ${Math.round(intervalMs / 1000)}s — rotate at ${Math.round((pool.utilizationThreshold ?? 0.85) * 100)}%, warn at ${Math.round(warnThreshold * 100)}%${skipped.length > 0 ? `; not polled: ${skipped.join("; ")}` : ""}`);
|
|
1083
|
+
return { active: true, members: members.map((m) => m.id) };
|
|
1084
|
+
}
|
|
1085
|
+
export async function runUsagePollTickNow() {
|
|
1086
|
+
return usageController?.tick();
|
|
1087
|
+
}
|
|
1088
|
+
export { usageAlertPrefix, usagePoolAlertKey };
|
package/dist/login-health.js
CHANGED
|
@@ -1,8 +1,98 @@
|
|
|
1
|
+
import { parseSetupTokenFile } from "./account-env.js";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
export const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
4
|
+
export function claudeKeychainAccount(env, userInfo) {
|
|
5
|
+
try {
|
|
6
|
+
return env.USER || userInfo().username;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return "claude-code-user";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
2
12
|
export function keychainServiceForConfigDir(absDir) {
|
|
3
13
|
const hash = createHash("sha256").update(absDir).digest("hex").slice(0, 8);
|
|
4
14
|
return `Claude Code-credentials-${hash}`;
|
|
5
15
|
}
|
|
16
|
+
export const AUTH_PROOF_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
export const ACCESS_TOKEN_EXPIRY_SKEW_MS = 60 * 1000;
|
|
18
|
+
const MAX_PLAUSIBLE_EXPIRY_MS = 253402300799000;
|
|
19
|
+
export function parseOauthCredential(raw) {
|
|
20
|
+
let text = raw.trim();
|
|
21
|
+
if (/^(?:[0-9a-f]{2})+$/i.test(text)) {
|
|
22
|
+
text = Buffer.from(text, "hex").toString("utf8").trim();
|
|
23
|
+
}
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(text);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const oauth = parsed?.claudeAiOauth;
|
|
32
|
+
if (!oauth || typeof oauth !== "object")
|
|
33
|
+
return undefined;
|
|
34
|
+
const o = oauth;
|
|
35
|
+
const present = (v) => typeof v === "string" && v.trim().length > 0;
|
|
36
|
+
let expiresAt;
|
|
37
|
+
if (typeof o.expiresAt === "number" && Number.isFinite(o.expiresAt) && o.expiresAt > 0) {
|
|
38
|
+
const ms = o.expiresAt < 1e12 ? o.expiresAt * 1000 : o.expiresAt;
|
|
39
|
+
if (ms < MAX_PLAUSIBLE_EXPIRY_MS)
|
|
40
|
+
expiresAt = ms;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
hasAccessToken: present(o.accessToken),
|
|
44
|
+
hasRefreshToken: present(o.refreshToken),
|
|
45
|
+
expiresAt,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function ago(ms) {
|
|
49
|
+
const min = Math.max(0, Math.round(ms / 60000));
|
|
50
|
+
return min < 90 ? `${min}m` : `${Math.round(min / 60)}h`;
|
|
51
|
+
}
|
|
52
|
+
export function judgeOauthCredential(meta, source, proof, nowMs) {
|
|
53
|
+
const base = { source, expiresAt: meta.expiresAt };
|
|
54
|
+
if (!meta.hasAccessToken) {
|
|
55
|
+
return { ...base, status: "broken", reason: `${source}: access token is blank` };
|
|
56
|
+
}
|
|
57
|
+
if (meta.expiresAt !== undefined && meta.expiresAt - ACCESS_TOKEN_EXPIRY_SKEW_MS > nowMs) {
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
status: "ok",
|
|
61
|
+
detail: `access token valid until ${new Date(meta.expiresAt).toISOString()}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (meta.expiresAt === undefined) {
|
|
65
|
+
return {
|
|
66
|
+
...base,
|
|
67
|
+
status: "unverified",
|
|
68
|
+
reason: `${source}: records no access-token expiry, so it cannot be judged at rest`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const expiry = `access token expired ${new Date(meta.expiresAt).toISOString()}`;
|
|
72
|
+
const success = proof.lastSuccessAt;
|
|
73
|
+
if (success !== undefined &&
|
|
74
|
+
success <= nowMs &&
|
|
75
|
+
nowMs - success <= AUTH_PROOF_FRESH_MS &&
|
|
76
|
+
success >= meta.expiresAt) {
|
|
77
|
+
return {
|
|
78
|
+
...base,
|
|
79
|
+
status: "ok",
|
|
80
|
+
detail: `${expiry}; a successful turn ${ago(nowMs - success)} ago proves the refresh works`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!meta.hasRefreshToken) {
|
|
84
|
+
return {
|
|
85
|
+
...base,
|
|
86
|
+
status: "broken",
|
|
87
|
+
reason: `${source}: ${expiry} and no refresh token is stored`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
status: "unverified",
|
|
93
|
+
reason: `${source}: ${expiry} and no successful turn since proves the refresh works`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
6
96
|
export function createRefProbeTracker(options = {}) {
|
|
7
97
|
const deadAfterConsecutive = options.deadAfterConsecutive ?? 3;
|
|
8
98
|
const deadAfterMs = options.deadAfterMs ?? 10 * 60 * 1000;
|
|
@@ -42,29 +132,40 @@ export function createRefProbeTracker(options = {}) {
|
|
|
42
132
|
},
|
|
43
133
|
};
|
|
44
134
|
}
|
|
45
|
-
function
|
|
46
|
-
return /^sk-ant-[a-z0-9]+-/.test(value.trim());
|
|
47
|
-
}
|
|
48
|
-
function checkCredentialsJson(io, dir) {
|
|
135
|
+
function assessCredentialsJson(io, path, proof, nowMs) {
|
|
49
136
|
let raw;
|
|
50
137
|
try {
|
|
51
|
-
raw = io.readFile(
|
|
138
|
+
raw = io.readFile(path);
|
|
52
139
|
}
|
|
53
140
|
catch {
|
|
54
|
-
return
|
|
141
|
+
return undefined;
|
|
55
142
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (typeof token === "string" && token.trim().length > 0)
|
|
60
|
-
return { status: "ok" };
|
|
61
|
-
return { status: "broken", reason: `${dir}/.credentials.json access token is blank` };
|
|
143
|
+
const meta = parseOauthCredential(raw);
|
|
144
|
+
if (!meta) {
|
|
145
|
+
return { status: "broken", reason: `${path} holds no Claude OAuth credential`, source: path };
|
|
62
146
|
}
|
|
63
|
-
|
|
64
|
-
|
|
147
|
+
return judgeOauthCredential(meta, path, proof, nowMs);
|
|
148
|
+
}
|
|
149
|
+
function assessKeychain(io, service, proof, nowMs) {
|
|
150
|
+
const read = io.readKeychainItem(service, io.keychainAccount);
|
|
151
|
+
if (read.status === "absent")
|
|
152
|
+
return undefined;
|
|
153
|
+
const source = `keychain "${service}"`;
|
|
154
|
+
if (read.status === "unreadable") {
|
|
155
|
+
return {
|
|
156
|
+
status: "unknown",
|
|
157
|
+
reason: `${source} could not be read (Keychain locked, access refused or timed out) — not judged`,
|
|
158
|
+
source,
|
|
159
|
+
};
|
|
65
160
|
}
|
|
161
|
+
const meta = parseOauthCredential(read.data);
|
|
162
|
+
if (!meta)
|
|
163
|
+
return { status: "broken", reason: `${source} holds no Claude OAuth credential`, source };
|
|
164
|
+
return judgeOauthCredential(meta, source, proof, nowMs);
|
|
66
165
|
}
|
|
67
|
-
export function
|
|
166
|
+
export function assessAccountCredential(account, io, options = {}) {
|
|
167
|
+
const proof = options.proof ?? {};
|
|
168
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
68
169
|
if (account.oauthTokenFile) {
|
|
69
170
|
let raw;
|
|
70
171
|
try {
|
|
@@ -73,35 +174,31 @@ export function checkAccountCredential(account, io) {
|
|
|
73
174
|
catch {
|
|
74
175
|
return { status: "broken", reason: `${account.oauthTokenFile} unreadable` };
|
|
75
176
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
reason: `${account.oauthTokenFile} does not contain a setup-token`,
|
|
81
|
-
};
|
|
177
|
+
const read = parseSetupTokenFile(raw, account.oauthTokenFile);
|
|
178
|
+
if ("token" in read)
|
|
179
|
+
return { status: "ok", detail: "setup-token present" };
|
|
180
|
+
return { status: "broken", reason: read.error };
|
|
82
181
|
}
|
|
83
182
|
if (account.oauthTokenRef) {
|
|
84
183
|
return { status: "unknown" };
|
|
85
184
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return checkCredentialsJson(io, "~/.claude");
|
|
93
|
-
}
|
|
94
|
-
if (account.configDir) {
|
|
95
|
-
if (io.platform === "darwin" && io.keychainHasClaudeCredentialsForDir(account.configDir)) {
|
|
96
|
-
return { status: "ok" };
|
|
97
|
-
}
|
|
98
|
-
const file = checkCredentialsJson(io, account.configDir);
|
|
99
|
-
if (file.status === "ok" || io.platform !== "darwin")
|
|
100
|
-
return file;
|
|
101
|
-
return {
|
|
185
|
+
const dir = account.native ? "~/.claude" : account.configDir;
|
|
186
|
+
if (!dir)
|
|
187
|
+
return { status: "unknown" };
|
|
188
|
+
const path = `${dir.replace(/\/+$/, "")}/.credentials.json`;
|
|
189
|
+
if (io.platform !== "darwin") {
|
|
190
|
+
return (assessCredentialsJson(io, path, proof, nowMs) ?? {
|
|
102
191
|
status: "broken",
|
|
103
|
-
reason:
|
|
104
|
-
|
|
192
|
+
reason: `${path} unreadable`,
|
|
193
|
+
source: path,
|
|
194
|
+
});
|
|
105
195
|
}
|
|
106
|
-
|
|
196
|
+
const service = account.native
|
|
197
|
+
? CLAUDE_KEYCHAIN_SERVICE
|
|
198
|
+
: keychainServiceForConfigDir(io.expandHome(dir));
|
|
199
|
+
return (assessKeychain(io, service, proof, nowMs) ??
|
|
200
|
+
assessCredentialsJson(io, path, proof, nowMs) ?? {
|
|
201
|
+
status: "broken",
|
|
202
|
+
reason: `keychain has no Claude Code credentials for ${account.native ? "the default login" : dir} and ${path} unreadable`,
|
|
203
|
+
});
|
|
107
204
|
}
|
package/dist/retry-plan.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { classifyAccountHealth, overdueProbeAccount, } from "./health.js";
|
|
2
|
+
import { parseSetupTokenFile } from "./account-env.js";
|
|
2
3
|
import { resumeSessionId } from "./session-handover.js";
|
|
3
4
|
export const RETRY_ROSTER_ENV = "MULTI_CLAWD_RETRY_ACCOUNTS";
|
|
4
5
|
const CREDENTIAL_ENV_KEYS = ["CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN"];
|
|
6
|
+
const ROSTER_ENV_KEYS = ["CLAUDE_CONFIG_DIR"];
|
|
5
7
|
export function parseRetryRoster(raw) {
|
|
6
8
|
if (!raw)
|
|
7
9
|
return [];
|
|
@@ -26,11 +28,12 @@ export function parseRetryRoster(raw) {
|
|
|
26
28
|
const env = {};
|
|
27
29
|
if (e.env && typeof e.env === "object") {
|
|
28
30
|
for (const [k, v] of Object.entries(e.env)) {
|
|
29
|
-
if (typeof v === "string")
|
|
31
|
+
if (typeof v === "string" && ROSTER_ENV_KEYS.includes(k))
|
|
30
32
|
env[k] = v;
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
|
-
|
|
35
|
+
const tokenFile = typeof e.tokenFile === "string" && e.tokenFile ? e.tokenFile : undefined;
|
|
36
|
+
out.push({ id: e.id, stateFile: e.stateFile, env, ...(tokenFile ? { tokenFile } : {}) });
|
|
34
37
|
}
|
|
35
38
|
return out;
|
|
36
39
|
}
|
|
@@ -40,7 +43,7 @@ export function isResumeLaunch(argv) {
|
|
|
40
43
|
}
|
|
41
44
|
export function retryArming(argv, roster, opts = {}) {
|
|
42
45
|
if (roster.length === 0) {
|
|
43
|
-
return { armed: false, reason: "no
|
|
46
|
+
return { armed: false, reason: "no sibling account the shim can retry onto" };
|
|
44
47
|
}
|
|
45
48
|
if (isResumeLaunch(argv) && !opts.resumeReady) {
|
|
46
49
|
return {
|
|
@@ -77,3 +80,35 @@ export function buildRetryEnv(base, account) {
|
|
|
77
80
|
env[RETRY_ROSTER_ENV] = "";
|
|
78
81
|
return env;
|
|
79
82
|
}
|
|
83
|
+
export function materializeRetryAccount(account, readFile) {
|
|
84
|
+
if (!account.tokenFile)
|
|
85
|
+
return { account };
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = readFile(account.tokenFile);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
const code = err.code;
|
|
92
|
+
return { error: `token file unreadable${typeof code === "string" && /^E[A-Z]+$/.test(code) ? ` (${code})` : ""}` };
|
|
93
|
+
}
|
|
94
|
+
const read = parseSetupTokenFile(raw, "token file");
|
|
95
|
+
if ("error" in read)
|
|
96
|
+
return { error: read.error };
|
|
97
|
+
return {
|
|
98
|
+
account: { ...account, env: { ...account.env, CLAUDE_CODE_OAUTH_TOKEN: read.token } },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function chooseLaunchableRetryAccount(params) {
|
|
102
|
+
let remaining = params.roster;
|
|
103
|
+
while (remaining.length > 0) {
|
|
104
|
+
const pick = chooseRetryAccount({ ...params, roster: remaining });
|
|
105
|
+
if (!pick)
|
|
106
|
+
return undefined;
|
|
107
|
+
const ready = params.materialize(pick);
|
|
108
|
+
if ("account" in ready)
|
|
109
|
+
return ready.account;
|
|
110
|
+
params.onSkip?.(pick.id, ready.error);
|
|
111
|
+
remaining = remaining.filter((a) => a !== pick);
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
package/dist/shim-core.js
CHANGED
|
@@ -137,7 +137,9 @@ export function mergeHealthStates(disk, live, now, pruneAfterMs = PRUNE_AFTER_MS
|
|
|
137
137
|
if (live.credential && (!credential || live.credential.seenAt >= credential.seenAt)) {
|
|
138
138
|
credential = live.credential;
|
|
139
139
|
}
|
|
140
|
-
if (now !== undefined &&
|
|
140
|
+
if (now !== undefined &&
|
|
141
|
+
credential?.status === "ok" &&
|
|
142
|
+
now - credential.seenAt > pruneAfterMs) {
|
|
141
143
|
credential = undefined;
|
|
142
144
|
}
|
|
143
145
|
const updatedAt = Math.max(disk.updatedAt ?? 0, live.updatedAt ?? 0);
|