@drakon-systems/multi-clawd 1.9.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
- export { healthStateFile, clearAccountCredentialFailure };
21
- import { accountConfigDir, buildAccountChildEnv, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
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 { checkAccountCredential, keychainServiceForConfigDir, createRefProbeTracker, } from "./login-health.js";
24
- import { execFileSync } from "node:child_process";
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
- const realCredentialIo = {
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 = checkAccountCredential(account, io);
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
- return readFileSync(path, "utf8").trim();
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
- if (member.oauthTokenFile || member.oauthTokenRef)
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(healthStateFile(accountId), "utf8"));
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
- unresolved.push(`${candidate.id} (${err.message})`);
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 };
@@ -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 looksLikeSetupToken(value) {
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(`${dir}/.credentials.json`);
138
+ raw = io.readFile(path);
52
139
  }
53
140
  catch {
54
- return { status: "broken", reason: `${dir}/.credentials.json unreadable` };
141
+ return undefined;
55
142
  }
56
- try {
57
- const parsed = JSON.parse(raw);
58
- const token = parsed?.claudeAiOauth?.accessToken;
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
- catch {
64
- return { status: "broken", reason: `${dir}/.credentials.json is not valid JSON` };
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 checkAccountCredential(account, io) {
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
- if (looksLikeSetupToken(raw))
77
- return { status: "ok" };
78
- return {
79
- status: "broken",
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
- if (account.native) {
87
- if (io.platform === "darwin") {
88
- return io.keychainHasClaudeCredentials()
89
- ? { status: "ok" }
90
- : { status: "broken", reason: "keychain has no Claude Code credentials" };
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: `keychain has no Claude Code credentials for ${account.configDir} and ${file.reason}`,
104
- };
192
+ reason: `${path} unreadable`,
193
+ source: path,
194
+ });
105
195
  }
106
- return { status: "unknown" };
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
  }
@@ -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
- out.push({ id: e.id, stateFile: e.stateFile, env });
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 secret-free sibling account to retry onto" };
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 && credential && now - credential.seenAt > pruneAfterMs) {
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);