@eddyskywalker/dsh-chatgpt-subscription 0.10.11 → 0.10.12

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/lib/index.js CHANGED
@@ -905,6 +905,16 @@ function poolCredentialAccount$1(filePath) {
905
905
  return createHash("sha256").update(path.resolve(filePath)).digest("hex");
906
906
  }
907
907
  const poolOperations = /* @__PURE__ */ new Map();
908
+ const poolIdentityRevisions = /* @__PURE__ */ new Map();
909
+ const poolRefreshes = /* @__PURE__ */ new Map();
910
+ /**
911
+ * A selected row was deleted, or became unusable, while a caller awaited it.
912
+ *
913
+ * Distinct from every other credential error because a MODEL REQUEST can still
914
+ * be served by another account: routing excludes the row and tries the next
915
+ * one, while a caller that named the account still gets the plain refusal.
916
+ */
917
+ var PoolAccountUnavailableError = class extends LlmError {};
908
918
  /**
909
919
  * The shared multi-account pool: encrypted storage, eligibility filtering,
910
920
  * rotation strategy, 429 cooldowns and per-account auth failures.
@@ -926,11 +936,14 @@ var AccountPoolCore = class {
926
936
  * can therefore invalidate on identity change without paying a credential read
927
937
  * to discover it.
928
938
  */
929
- identityRevision = 0;
939
+ /** Pool-file identity of this instance: locks, revisions and flights key on it. */
940
+ operationKey;
930
941
  constructor(hooks) {
931
942
  this.hooks = hooks;
932
943
  this.filePath = hooks.poolFile;
933
944
  this.backend = hooks.backend ?? createPoolBackend(hooks);
945
+ const resolved = path.resolve(this.filePath);
946
+ this.operationKey = process.platform === "win32" ? resolved.toLowerCase() : resolved;
934
947
  }
935
948
  /**
936
949
  * A cheap, in-memory revision of the pool's serving identity.
@@ -946,15 +959,21 @@ var AccountPoolCore = class {
946
959
  * would report one account's usage under another account's name.
947
960
  */
948
961
  currentIdentityRevision() {
949
- return this.identityRevision;
962
+ return poolIdentityRevisions.get(this.operationKey) ?? 0;
950
963
  }
951
964
  /** Human description of where this pool's credentials live. */
952
965
  path() {
953
966
  if (process.platform === "win32") return `${this.filePath}.dpapi`;
954
967
  return `${process.platform === "darwin" ? "Keychain" : "Secret Service"}: ${this.hooks.keychainService}/${poolCredentialAccount$1(this.filePath)}`;
955
968
  }
969
+ /**
970
+ * Queue one storage transaction for this pool file.
971
+ *
972
+ * NOT reentrant: a transaction must use {@link loadPoolData} and
973
+ * {@link updatePool} rather than calling the public read/write methods.
974
+ */
956
975
  serialize(operation) {
957
- const key = path.resolve(this.filePath);
976
+ const key = this.operationKey;
958
977
  const result = (poolOperations.get(key) || Promise.resolve()).then(operation);
959
978
  const settled = result.then(() => void 0, () => void 0);
960
979
  poolOperations.set(key, settled);
@@ -978,31 +997,74 @@ var AccountPoolCore = class {
978
997
  * getter write to disk.
979
998
  */
980
999
  read() {
981
- return this.serialize(async () => {
982
- let current = null;
983
- try {
984
- current = await this.backend.load();
985
- } catch {}
986
- if (current !== null && Array.isArray(current.accounts) && current.accounts.length > 0) return current;
987
- try {
988
- const legacy = await this.hooks.legacyAccount?.();
989
- if (legacy) return {
990
- version: 1,
991
- activeAccountId: legacy.id,
992
- rotationStrategy: current?.rotationStrategy || "sequential",
993
- accounts: [legacy]
994
- };
995
- } catch {}
996
- return current || {
1000
+ return this.serialize(() => this.loadPoolData());
1001
+ }
1002
+ /**
1003
+ * Load the pool without holding the file lock.
1004
+ *
1005
+ * Providers override this to maintain their own in-memory indexes, so every
1006
+ * load — including one inside {@link updatePool} — refreshes them.
1007
+ */
1008
+ async loadPoolData() {
1009
+ let current = null;
1010
+ try {
1011
+ current = await this.backend.load();
1012
+ } catch {}
1013
+ if (current !== null && Array.isArray(current.accounts) && current.accounts.length > 0) return current;
1014
+ try {
1015
+ const legacy = await this.hooks.legacyAccount?.();
1016
+ if (legacy) return {
997
1017
  version: 1,
998
- rotationStrategy: "sequential",
999
- accounts: []
1018
+ activeAccountId: legacy.id,
1019
+ rotationStrategy: current?.rotationStrategy || "sequential",
1020
+ accounts: [legacy]
1000
1021
  };
1001
- });
1022
+ } catch {}
1023
+ return current || {
1024
+ version: 1,
1025
+ rotationStrategy: "sequential",
1026
+ accounts: []
1027
+ };
1028
+ }
1029
+ async persistPoolData(data) {
1030
+ poolIdentityRevisions.set(this.operationKey, this.currentIdentityRevision() + 1);
1031
+ await this.saveVerified(data);
1002
1032
  }
1033
+ /**
1034
+ * Replace the whole document (initialization, import), NOT a read-modify-write.
1035
+ * Prefer {@link updatePool} for mutations so the draft is read under the lock.
1036
+ */
1003
1037
  write(data) {
1004
- this.identityRevision += 1;
1005
- return this.serialize(() => this.saveVerified(this.hooks.parsePoolData(data)));
1038
+ return this.serialize(() => this.persistPoolData(data));
1039
+ }
1040
+ /**
1041
+ * Read the LATEST document, mutate it and persist it as one file transaction.
1042
+ *
1043
+ * This is the whole fix for the read-modify-write race: the old shape read a
1044
+ * snapshot, awaited (a token refresh, a settings write, an encrypted round
1045
+ * trip) and then wrote that whole stale snapshot back, erasing whatever landed
1046
+ * in between — including another account's freshly rotated token.
1047
+ *
1048
+ * The callback runs under the file lock, so it must not call read, write or
1049
+ * updatePool (the lock is not reentrant) and must not do network I/O.
1050
+ * Provider-local stores belong in `afterCommit`, which runs only once the
1051
+ * pool write is verified, and must not re-enter the pool either.
1052
+ */
1053
+ updatePool(mutate, afterCommit) {
1054
+ return this.serialize(async () => {
1055
+ const data = await this.loadPoolData();
1056
+ const before = structuredClone(data);
1057
+ const result = await mutate(data);
1058
+ if (!isDeepStrictEqual(before, data)) {
1059
+ await this.persistPoolData(data);
1060
+ await afterCommit?.(data, result).catch(() => void 0);
1061
+ for (const account of data.accounts) if (!isDeepStrictEqual(before.accounts.find((entry) => entry.id === account.id)?.credentials, account.credentials)) await this.hooks.credentialsCommitted?.(account).catch(() => void 0);
1062
+ const previousPrimary = before.accounts.find((account) => account.isPrimary);
1063
+ const primary = data.accounts.find((account) => account.isPrimary);
1064
+ if (previousPrimary?.id !== primary?.id || !isDeepStrictEqual(previousPrimary?.credentials, primary?.credentials)) await this.hooks.mirrorPrimary?.(primary?.credentials ?? null).catch(() => void 0);
1065
+ }
1066
+ return result;
1067
+ });
1006
1068
  }
1007
1069
  /** Public summaries, with cooldowns that already expired reported as absent. */
1008
1070
  async listAccounts() {
@@ -1031,8 +1093,17 @@ var AccountPoolCore = class {
1031
1093
  return this.hooks.extendSummary ? this.hooks.extendSummary(account, summary, now) : summary;
1032
1094
  }
1033
1095
  /** Add or re-authorize one account; an existing dedupe key updates in place. */
1034
- async addAccount(credentials, alias) {
1035
- const data = await this.read();
1096
+ addAccount(credentials, alias) {
1097
+ return this.updatePool((data) => this.addAccountToPool(data, credentials, alias));
1098
+ }
1099
+ /**
1100
+ * Add or re-authorize inside an existing transaction.
1101
+ *
1102
+ * Capacity, dedupe and the primary decision are all taken against the live
1103
+ * document, so two simultaneous sign-ins cannot each pass a check the other
1104
+ * has already invalidated.
1105
+ */
1106
+ addAccountToPool(data, credentials, alias) {
1036
1107
  const key = this.hooks.dedupeKey?.(credentials);
1037
1108
  const existingIndex = key === void 0 ? -1 : data.accounts.findIndex((account) => this.hooks.dedupeKey?.(account.credentials) === key);
1038
1109
  const maxAccounts = this.hooks.maxAccounts ?? 20;
@@ -1058,77 +1129,79 @@ var AccountPoolCore = class {
1058
1129
  if (existingIndex >= 0) data.accounts[existingIndex] = account;
1059
1130
  else data.accounts.push(account);
1060
1131
  if (!data.activeAccountId || account.isPrimary) data.activeAccountId = account.id;
1061
- await this.write(data);
1062
- if (account.isPrimary) this.hooks.mirrorPrimary?.(credentials).catch(() => void 0);
1063
1132
  return account;
1064
1133
  }
1065
- async setPrimary(accountId) {
1066
- const data = await this.read();
1067
- for (const account of data.accounts) account.isPrimary = account.id === accountId;
1068
- const primary = data.accounts.find((account) => account.isPrimary);
1069
- if (primary) {
1070
- data.activeAccountId = primary.id;
1071
- this.hooks.mirrorPrimary?.(primary.credentials).catch(() => void 0);
1072
- }
1073
- await this.write(data);
1134
+ setPrimary(accountId) {
1135
+ return this.updatePool((data) => {
1136
+ for (const account of data.accounts) account.isPrimary = account.id === accountId;
1137
+ const primary = data.accounts.find((account) => account.isPrimary);
1138
+ if (primary) data.activeAccountId = primary.id;
1139
+ });
1074
1140
  }
1075
1141
  /**
1076
1142
  * Persist a refreshed credential for one account.
1077
1143
  *
1144
+ * `expected` makes this a conditional write: when the stored credential is
1145
+ * no longer the one the caller started from, something newer won the race (a
1146
+ * re-login, a rotation committed while this call was in flight) and the
1147
+ * latest row is returned WITHOUT being overwritten. A deleted account is
1148
+ * never brought back.
1149
+ *
1078
1150
  * Used by a forced refresh (a 401 mid-request) rather than by the rotating
1079
- * getEffectiveAccount path, which already writes back what it refreshed.
1151
+ * getEffectiveAccount path, which refreshes through its own single-flight.
1080
1152
  */
1081
- async updateAccountCredentials(accountId, credentials) {
1082
- const data = await this.read();
1083
- const target = data.accounts.find((account) => account.id === accountId);
1084
- if (target === void 0) return void 0;
1085
- target.credentials = credentials;
1086
- await this.write(data);
1087
- if (target.isPrimary) this.hooks.mirrorPrimary?.(credentials).catch(() => void 0);
1088
- return target;
1153
+ updateAccountCredentials(accountId, credentials, expected) {
1154
+ return this.updatePool((data) => {
1155
+ const target = data.accounts.find((account) => account.id === accountId);
1156
+ if (target && (expected === void 0 || isDeepStrictEqual(target.credentials, expected))) target.credentials = credentials;
1157
+ return target;
1158
+ });
1089
1159
  }
1090
- async setAlias(accountId, alias) {
1091
- const data = await this.read();
1092
- const target = data.accounts.find((account) => account.id === accountId);
1093
- if (target && alias.trim()) {
1094
- target.alias = alias.trim();
1095
- await this.write(data);
1096
- }
1160
+ setAlias(accountId, alias) {
1161
+ return this.updatePool((data) => {
1162
+ const target = data.accounts.find((account) => account.id === accountId);
1163
+ if (target && alias.trim()) target.alias = alias.trim();
1164
+ });
1097
1165
  }
1098
- async deleteAccount(accountId) {
1099
- const data = await this.read();
1166
+ deleteAccount(accountId) {
1167
+ return this.updatePool((data) => this.deleteAccountFromPool(data, accountId));
1168
+ }
1169
+ /**
1170
+ * Remove one account inside a transaction.
1171
+ *
1172
+ * Provider refusal rules (a snapshot this plugin does not own, a desktop
1173
+ * account it may only hide) belong in an override of this, so the check and
1174
+ * the removal cannot be separated by a concurrent change.
1175
+ */
1176
+ deleteAccountFromPool(data, accountId) {
1100
1177
  const wasPrimary = data.accounts.find((account) => account.id === accountId)?.isPrimary;
1101
1178
  data.accounts = data.accounts.filter((account) => account.id !== accountId);
1102
- if (wasPrimary && data.accounts.length > 0) {
1103
- data.accounts[0].isPrimary = true;
1104
- this.hooks.mirrorPrimary?.(data.accounts[0].credentials).catch(() => void 0);
1105
- } else if (data.accounts.length === 0) this.hooks.mirrorPrimary?.(null).catch(() => void 0);
1179
+ if (wasPrimary && data.accounts.length > 0) data.accounts[0].isPrimary = true;
1106
1180
  if (data.activeAccountId === accountId) data.activeAccountId = data.accounts.find((account) => account.isPrimary)?.id ?? data.accounts[0]?.id;
1107
- await this.write(data);
1108
1181
  }
1109
- async setStrategy(strategy) {
1110
- const data = await this.read();
1111
- data.rotationStrategy = strategy;
1112
- await this.write(data);
1182
+ setStrategy(strategy) {
1183
+ return this.updatePool((data) => {
1184
+ data.rotationStrategy = strategy;
1185
+ });
1113
1186
  }
1114
1187
  /** Cool one account down after an upstream 429. */
1115
- async markCooldown(accountId, durationMs, reason) {
1116
- const data = await this.read();
1117
- const target = data.accounts.find((account) => account.id === accountId);
1118
- if (target) {
1119
- target.cooldownUntil = Date.now() + Math.max(MIN_COOLDOWN_MS, durationMs);
1120
- target.cooldownReason = reason;
1121
- await this.write(data);
1122
- }
1188
+ markCooldown(accountId, durationMs, reason) {
1189
+ return this.updatePool((data) => {
1190
+ const target = data.accounts.find((account) => account.id === accountId);
1191
+ if (target) {
1192
+ target.cooldownUntil = Date.now() + Math.max(MIN_COOLDOWN_MS, durationMs);
1193
+ target.cooldownReason = reason;
1194
+ }
1195
+ });
1123
1196
  }
1124
- async clearCooldown(accountId) {
1125
- const data = await this.read();
1126
- const target = data.accounts.find((account) => account.id === accountId);
1127
- if (target) {
1128
- target.cooldownUntil = void 0;
1129
- target.cooldownReason = void 0;
1130
- await this.write(data);
1131
- }
1197
+ clearCooldown(accountId) {
1198
+ return this.updatePool((data) => {
1199
+ const target = data.accounts.find((account) => account.id === accountId);
1200
+ if (target) {
1201
+ target.cooldownUntil = void 0;
1202
+ target.cooldownReason = void 0;
1203
+ }
1204
+ });
1132
1205
  }
1133
1206
  /**
1134
1207
  * Record that an account can no longer authenticate.
@@ -1136,23 +1209,23 @@ var AccountPoolCore = class {
1136
1209
  * The account stays in the pool: signing in again is what restores it, and a
1137
1210
  * deleted account would take the user's alias and ordering with it.
1138
1211
  */
1139
- async markAuthFailed(accountId, reason, status = "expired") {
1140
- const data = await this.read();
1141
- const target = data.accounts.find((account) => account.id === accountId);
1142
- if (target) {
1143
- target.authStatus = status;
1144
- target.authFailedReason = reason;
1145
- await this.write(data);
1146
- }
1212
+ markAuthFailed(accountId, reason, status = "expired") {
1213
+ return this.updatePool((data) => {
1214
+ const target = data.accounts.find((account) => account.id === accountId);
1215
+ if (target) {
1216
+ target.authStatus = status;
1217
+ target.authFailedReason = reason;
1218
+ }
1219
+ });
1147
1220
  }
1148
- async clearAuthFailed(accountId) {
1149
- const data = await this.read();
1150
- const target = data.accounts.find((account) => account.id === accountId);
1151
- if (target) {
1152
- target.authStatus = void 0;
1153
- target.authFailedReason = void 0;
1154
- await this.write(data);
1155
- }
1221
+ clearAuthFailed(accountId) {
1222
+ return this.updatePool((data) => {
1223
+ const target = data.accounts.find((account) => account.id === accountId);
1224
+ if (target) {
1225
+ target.authStatus = void 0;
1226
+ target.authFailedReason = void 0;
1227
+ }
1228
+ });
1156
1229
  }
1157
1230
  /** Whether an account may serve a request right now. */
1158
1231
  isEligible(account, now, triedAccountIds) {
@@ -1192,24 +1265,16 @@ var AccountPoolCore = class {
1192
1265
  *
1193
1266
  * @param fetchFn - fetch used if the credential is close to expiry.
1194
1267
  */
1195
- async getCredentialAccount(fetchFn = fetch) {
1268
+ async getCredentialAccount(fetchFn = fetch, forceRefresh = false) {
1196
1269
  const data = await this.read();
1197
1270
  if (data.accounts.length === 0) throw new Error(this.hooks.emptyMessage ?? `未登录 ${this.hooks.displayName} 账号,请先在设置页添加账号。`);
1198
- const now = Date.now();
1199
1271
  const usable = data.accounts.filter((account) => this.isCredentialUsable(account));
1200
1272
  if (usable.length === 0) throw new LlmError(`全部 ${data.accounts.length} 个 ${this.hooks.displayName} 账号均需要重新登录。`, "AUTH", { status: 401 });
1201
1273
  const selected = this.selectAccount(usable, data);
1202
- if (this.shouldRefreshCredential(selected, now) && this.hooks.refresh) {
1203
- const refreshed = await this.hooks.refresh(selected.credentials, fetchFn);
1204
- await this.updateAccountCredentials(selected.id, refreshed);
1205
- return {
1206
- account: selected,
1207
- credentials: refreshed
1208
- };
1209
- }
1274
+ const account = await this.freshAccount(selected, fetchFn, forceRefresh);
1210
1275
  return {
1211
- account: selected,
1212
- credentials: selected.credentials
1276
+ account,
1277
+ credentials: account.credentials
1213
1278
  };
1214
1279
  }
1215
1280
  /**
@@ -1244,8 +1309,8 @@ var AccountPoolCore = class {
1244
1309
  * looks unexpired, and only the service's own refusal says so. Renewing it is
1245
1310
  * what separates that from a credential that genuinely needs a new sign-in.
1246
1311
  */
1247
- async renewCredential(accountId, fetchFn = fetch) {
1248
- return this.credentialFor(accountId, fetchFn, true);
1312
+ async renewCredential(accountId, fetchFn = fetch, expected) {
1313
+ return this.credentialFor(accountId, fetchFn, true, expected);
1249
1314
  }
1250
1315
  /**
1251
1316
  * Resolve, and if needed renew, one account's credential.
@@ -1255,29 +1320,106 @@ var AccountPoolCore = class {
1255
1320
  * not name an account (the catalog loader) still has to present the
1256
1321
  * credential that is actually in play.
1257
1322
  */
1258
- async credentialFor(accountId, fetchFn, force) {
1323
+ async credentialFor(accountId, fetchFn, force, expected) {
1259
1324
  const data = await this.read();
1260
1325
  const target = accountId === void 0 ? data.accounts.find((account) => account.id === data.activeAccountId) ?? data.accounts.find((account) => account.isPrimary) ?? data.accounts[0] : data.accounts.find((account) => account.id === accountId);
1261
1326
  if (target === void 0) throw new LlmError(this.hooks.emptyMessage ?? `未登录 ${this.hooks.displayName} 账号,请先在设置页添加账号。`, "AUTH");
1262
1327
  if (!this.isCredentialUsable(target)) throw new LlmError(`${this.hooks.displayName} 账号的凭据已被拒绝,需要重新登录。`, "AUTH", { status: 401 });
1263
- if (!force && (!this.shouldRefreshCredential(target, Date.now()) || !this.hooks.refresh)) return target.credentials;
1264
- if (!this.hooks.refresh) return target.credentials;
1328
+ if (expected !== void 0 && !isDeepStrictEqual(expected, target.credentials)) return target.credentials;
1329
+ return (await this.freshAccount(target, fetchFn, force)).credentials;
1330
+ }
1331
+ /**
1332
+ * Return one account's current row, refreshing it at most once per generation.
1333
+ *
1334
+ * Shared by every entry point — routing, auxiliary tools, the settings card
1335
+ * and forced renewals — across all instances of the pool file, because a
1336
+ * rotating refresh token may be spent once: two parallel exchanges both fail,
1337
+ * and the losing one gets recorded as a dead sign-in.
1338
+ *
1339
+ * Reading the current generation and registering/joining its flight happen in
1340
+ * one short transaction, so a commit cannot slip between the two decisions.
1341
+ * The network call is fired AFTER the lock is released, and the plan returned
1342
+ * from the lock is a plain value: awaiting a flight while holding the lock
1343
+ * would deadlock that flight's own commit.
1344
+ */
1345
+ async freshAccount(target, fetchFn, force = false) {
1346
+ let launch;
1347
+ const plan = await this.serialize(async () => {
1348
+ const current = (await this.loadPoolData()).accounts.find((account) => account.id === target.id);
1349
+ if (current === void 0 || !this.isCredentialUsable(current)) throw new PoolAccountUnavailableError(this.hooks.emptyMessage ?? "未登录 " + this.hooks.displayName + " 账号,请先在设置页添加账号。", "AUTH");
1350
+ if (!isDeepStrictEqual(current.credentials, target.credentials)) return { account: current };
1351
+ if (!this.hooks.refresh || !force && !this.shouldRefreshCredential(current, Date.now())) return { account: current };
1352
+ const key = JSON.stringify([this.operationKey, current.id]);
1353
+ const pending = poolRefreshes.get(key);
1354
+ if (pending !== void 0 && isDeepStrictEqual(pending.credentials, current.credentials)) return { result: pending.result };
1355
+ const result = new Promise((resolve, reject) => {
1356
+ launch = () => {
1357
+ this.refreshAccount(current, fetchFn).then(resolve, reject);
1358
+ };
1359
+ });
1360
+ const flight = {
1361
+ credentials: structuredClone(current.credentials),
1362
+ result
1363
+ };
1364
+ poolRefreshes.set(key, flight);
1365
+ const release = () => {
1366
+ if (poolRefreshes.get(key) === flight) poolRefreshes.delete(key);
1367
+ };
1368
+ result.then(release, release);
1369
+ return { result };
1370
+ });
1371
+ launch?.();
1372
+ return plan.result ?? plan.account;
1373
+ }
1374
+ /**
1375
+ * Refresh one account: network outside the lock, then a conditional commit.
1376
+ *
1377
+ * Every write-back is checked against the credential the refresh started from,
1378
+ * which is what keeps a late response from restoring a spent token over a
1379
+ * re-login, and keeps a deleted account from coming back.
1380
+ */
1381
+ async refreshAccount(target, fetchFn) {
1382
+ const expected = structuredClone(target.credentials);
1265
1383
  const live = await this.hooks.liveCredentialsFor?.(target).catch(() => null);
1266
1384
  if (live !== null && live !== void 0 && live.advanced) {
1267
- target.credentials = live.credentials;
1268
- await this.write(data).catch(() => void 0);
1269
- return live.credentials;
1385
+ const account = await this.updateAccountCredentials(target.id, live.credentials, expected).catch(() => ({
1386
+ ...target,
1387
+ credentials: live.credentials
1388
+ }));
1389
+ this.assertUsableAccount(account);
1390
+ return account;
1270
1391
  }
1271
1392
  let refreshed;
1272
1393
  try {
1273
1394
  refreshed = await this.hooks.refresh(target.credentials, fetchFn);
1274
1395
  } catch (error) {
1275
1396
  const status = this.hooks.refreshFailureStatus?.(error);
1276
- if (status !== void 0) await this.markAuthFailed(target.id, error instanceof Error ? error.message : String(error), status).catch(() => void 0);
1397
+ let superseded;
1398
+ await this.updatePool((data) => {
1399
+ const current = data.accounts.find((account) => account.id === target.id);
1400
+ if (current === void 0) return;
1401
+ if (!isDeepStrictEqual(current.credentials, expected)) {
1402
+ superseded = current;
1403
+ return;
1404
+ }
1405
+ if (status !== void 0) {
1406
+ current.authStatus = status;
1407
+ current.authFailedReason = error instanceof Error ? error.message : String(error);
1408
+ }
1409
+ }).catch(() => void 0);
1410
+ if (superseded !== void 0) {
1411
+ this.assertUsableAccount(superseded);
1412
+ return superseded;
1413
+ }
1277
1414
  throw error;
1278
1415
  }
1279
- await this.updateAccountCredentials(target.id, refreshed);
1280
- return refreshed;
1416
+ const account = await this.updateAccountCredentials(target.id, refreshed, expected);
1417
+ this.assertUsableAccount(account);
1418
+ return account;
1419
+ }
1420
+ assertUsableAccount(account) {
1421
+ if (account === void 0) throw new PoolAccountUnavailableError(this.hooks.emptyMessage ?? "未登录 " + this.hooks.displayName + " 账号,请先在设置页添加账号。", "AUTH");
1422
+ if (!this.isCredentialUsable(account)) throw new PoolAccountUnavailableError(this.hooks.displayName + " 账号的凭据已被拒绝,需要重新登录。", "AUTH", { status: 401 });
1281
1423
  }
1282
1424
  /** Whether one account's credential must be refreshed before it is handed out. */
1283
1425
  shouldRefreshCredential(account, now) {
@@ -1321,37 +1463,34 @@ var AccountPoolCore = class {
1321
1463
  * @param fetchFn - fetch used by the provider's token refresh.
1322
1464
  * @throws LlmError with `RATE_LIMIT` when every account is cooling down.
1323
1465
  */
1324
- async getEffectiveAccount(excludeIds, fetchFn = fetch) {
1466
+ async getEffectiveAccount(excludeIds, fetchFn = fetch, forceRefresh = false) {
1325
1467
  const data = await this.read();
1326
1468
  if (data.accounts.length === 0) throw new Error(this.hooks.emptyMessage ?? `未登录 ${this.hooks.displayName} 账号,请先在设置页添加账号。`);
1327
1469
  const now = Date.now();
1328
1470
  const eligible = data.accounts.filter((account) => this.isEligible(account, now, excludeIds));
1329
1471
  if (eligible.length === 0) throw this.allUnavailableError(data, now);
1330
1472
  const selected = this.selectAccount(eligible, data);
1331
- if (this.shouldRefreshCredential(selected, now) && this.hooks.refresh) {
1332
- let refreshed;
1333
- try {
1334
- refreshed = await this.hooks.refresh(selected.credentials, fetchFn);
1335
- } catch (error) {
1336
- const status = this.hooks.refreshFailureStatus?.(error);
1337
- if (status === void 0) throw error;
1338
- await this.markAuthFailed(selected.id, error instanceof Error ? error.message : String(error), status);
1339
- const nextExclude = new Set(excludeIds ?? []);
1340
- nextExclude.add(selected.id);
1341
- if (await this.hasAnotherAvailableAccount(nextExclude)) return this.getEffectiveAccount(nextExclude, fetchFn);
1342
- throw error;
1343
- }
1344
- selected.credentials = refreshed;
1345
- const index = data.accounts.findIndex((account) => account.id === selected.id);
1346
- if (index >= 0) data.accounts[index] = selected;
1347
- if (selected.isPrimary) this.hooks.mirrorPrimary?.(refreshed).catch(() => void 0);
1348
- }
1349
- selected.lastUsedAt = now;
1350
- data.activeAccountId = selected.id;
1351
- await this.write(data).catch(() => void 0);
1473
+ let refreshed;
1474
+ try {
1475
+ refreshed = await this.freshAccount(selected, fetchFn, forceRefresh);
1476
+ } catch (error) {
1477
+ if (this.hooks.refreshFailureStatus?.(error) === void 0 && !(error instanceof PoolAccountUnavailableError)) throw error;
1478
+ const nextExclude = new Set(excludeIds ?? []);
1479
+ nextExclude.add(selected.id);
1480
+ if (await this.hasAnotherAvailableAccount(nextExclude)) return this.getEffectiveAccount(nextExclude, fetchFn, forceRefresh);
1481
+ throw error;
1482
+ }
1483
+ const account = await this.updatePool((current) => {
1484
+ const latest = current.accounts.find((entry) => entry.id === refreshed.id);
1485
+ if (latest === void 0 || !isDeepStrictEqual(latest.credentials, refreshed.credentials)) return latest;
1486
+ latest.lastUsedAt = now;
1487
+ if (current.activeAccountId === data.activeAccountId) current.activeAccountId = latest.id;
1488
+ return latest;
1489
+ }).catch(() => refreshed);
1490
+ this.assertUsableAccount(account);
1352
1491
  return {
1353
- account: selected,
1354
- credentials: selected.credentials
1492
+ account,
1493
+ credentials: account.credentials
1355
1494
  };
1356
1495
  }
1357
1496
  };
@@ -1724,8 +1863,7 @@ var OAuthService = class {
1724
1863
  async refresh() {
1725
1864
  this.assertAvailable();
1726
1865
  if (this.pool !== null) {
1727
- const { account, credentials } = await this.pool.getEffectiveAccount(void 0, this.fetchFn);
1728
- await this.refreshAccount(credentials).then((refreshed) => this.pool.updateAccountCredentials(account.id, refreshed));
1866
+ await this.pool.getEffectiveAccount(void 0, this.fetchFn, true);
1729
1867
  return this.status();
1730
1868
  }
1731
1869
  const stored = await this.loadAuthenticated();
@@ -1787,18 +1925,8 @@ var OAuthService = class {
1787
1925
  */
1788
1926
  async credentials(forceRefresh = false, access = {}) {
1789
1927
  if (this.pool !== null) {
1790
- if (access.purpose === "tool") {
1791
- const { account, credentials } = await this.pool.getCredentialAccount(this.fetchFn);
1792
- if (!forceRefresh) return credentials;
1793
- const refreshed = await this.refreshAccount(credentials, this.fetchFn);
1794
- await this.pool.updateAccountCredentials(account.id, refreshed);
1795
- return refreshed;
1796
- }
1797
- const { account, credentials } = await this.pool.getEffectiveAccount(void 0, this.fetchFn);
1798
- if (!forceRefresh) return credentials;
1799
- const refreshed = await this.refreshAccount(credentials, this.fetchFn);
1800
- await this.pool.updateAccountCredentials(account.id, refreshed);
1801
- return refreshed;
1928
+ if (access.purpose === "tool") return (await this.pool.getCredentialAccount(this.fetchFn, forceRefresh)).credentials;
1929
+ return (await this.pool.getEffectiveAccount(void 0, this.fetchFn, forceRefresh)).credentials;
1802
1930
  }
1803
1931
  const stored = await this.loadAuthenticated();
1804
1932
  if (forceRefresh || stored.expiresAt - this.now() <= 6e4) return this.refreshCredentials(stored);
@@ -2251,11 +2379,8 @@ var CodexAccountPool = class extends AccountPoolCore {
2251
2379
  async refreshAccountNow(accountId) {
2252
2380
  const account = (await this.read()).accounts.find((entry) => entry.id === accountId);
2253
2381
  if (account === void 0) throw new Error("ChatGPT 账号已不在号池中");
2254
- const refresher = this.refresherRef.current;
2255
- if (refresher === void 0) throw new Error("ChatGPT 凭据刷新服务尚未就绪");
2256
- const refreshed = await refresher.refreshAccount(account.credentials);
2257
- await this.updateAccountCredentials(accountId, refreshed);
2258
- return refreshed;
2382
+ if (this.refresherRef.current === void 0) throw new Error("ChatGPT 凭据刷新服务尚未就绪");
2383
+ return this.renewCredential(accountId, fetch, account.credentials);
2259
2384
  }
2260
2385
  /** The single-credential store this pool mirrors its primary account into. */
2261
2386
  mirrorStore() {
@@ -21552,23 +21677,30 @@ var MinimaxCodeAccountPool = class extends AccountPoolCore {
21552
21677
  * because that one genuinely needs a sign-in.
21553
21678
  */
21554
21679
  async healStaleAuthFailures(current) {
21555
- const data = current ?? await this.read();
21556
- if (!data.accounts.some(isMarkedFailed)) return false;
21680
+ const failedAccounts = (current ?? await this.read()).accounts.filter(isMarkedFailed);
21681
+ if (failedAccounts.length === 0) return false;
21682
+ const targets = new Map(failedAccounts.map((account) => [account.id, {
21683
+ credentials: account.credentials,
21684
+ authStatus: account.authStatus
21685
+ }]));
21557
21686
  const live = await this.store.read().catch(() => null);
21558
21687
  if (live === null) return false;
21559
- let changed = false;
21560
- for (const account of data.accounts) {
21561
- if (account.authStatus === void 0 || account.authStatus === "ok") continue;
21562
- if (!minimaxCodeSameCredentialSession(live, account.credentials)) continue;
21563
- if (!minimaxCodeCredentialAdvancedPast(live, account.credentials)) continue;
21564
- account.authStatus = void 0;
21565
- account.authFailedReason = void 0;
21566
- account.credentials = live;
21567
- changed = true;
21568
- }
21569
- if (!changed) return false;
21570
- await this.write(data).catch(() => void 0);
21571
- return true;
21688
+ return await this.updatePool((draft) => {
21689
+ let changed = false;
21690
+ for (const account of draft.accounts) {
21691
+ const expected = targets.get(account.id);
21692
+ if (!expected) continue;
21693
+ if (account.authStatus !== expected.authStatus) continue;
21694
+ if (!isDeepStrictEqual(account.credentials, expected.credentials)) continue;
21695
+ if (!minimaxCodeSameCredentialSession(live, account.credentials)) continue;
21696
+ if (!minimaxCodeCredentialAdvancedPast(live, account.credentials)) continue;
21697
+ account.authStatus = void 0;
21698
+ account.authFailedReason = void 0;
21699
+ account.credentials = live;
21700
+ changed = true;
21701
+ }
21702
+ return changed;
21703
+ });
21572
21704
  }
21573
21705
  /**
21574
21706
  * Pick the account for the next request.
@@ -21582,7 +21714,13 @@ var MinimaxCodeAccountPool = class extends AccountPoolCore {
21582
21714
  const effective = await this.getEffectiveAccount(excludeIds, fetchFn);
21583
21715
  const live = await this.liveNativeCredential(effective.account);
21584
21716
  if (live === null) return effective;
21585
- if (live.accessToken !== effective.credentials.accessToken) await this.updateAccountCredentials(effective.account.id, live).catch(() => void 0);
21717
+ if (live.accessToken !== effective.credentials.accessToken) {
21718
+ const updated = await this.updateAccountCredentials(effective.account.id, live, effective.credentials).catch(() => void 0);
21719
+ if (updated) return {
21720
+ account: updated,
21721
+ credentials: updated.credentials
21722
+ };
21723
+ }
21586
21724
  return {
21587
21725
  account: effective.account,
21588
21726
  credentials: live
@@ -21644,7 +21782,7 @@ var MinimaxCodeAccountPool = class extends AccountPoolCore {
21644
21782
  return this.addAccount(credentials, alias);
21645
21783
  }
21646
21784
  /**
21647
- * Delete one account.
21785
+ * Remove one account inside a transaction.
21648
21786
  *
21649
21787
  * An account whose credential is the desktop app's own \`auth.json\` is refused:
21650
21788
  * this plugin does not own that file, and removing the account would either
@@ -21652,9 +21790,9 @@ var MinimaxCodeAccountPool = class extends AccountPoolCore {
21652
21790
  * file — sign the user out of MiniMax Code itself. Signing out there is the
21653
21791
  * supported way to end it, and the card is told so by \`removable: false\`.
21654
21792
  */
21655
- async deleteAccount(accountId) {
21656
- if ((await this.read()).accounts.find((account) => account.id === accountId)?.source === "minimax-native") throw new Error("该账号来自 MiniMax Code 桌面端(~/.minimax/auth),本插件只能读取和续期,不能删除。请在 MiniMax Code 应用中退出登录。");
21657
- await super.deleteAccount(accountId);
21793
+ deleteAccountFromPool(data, accountId) {
21794
+ if (data.accounts.find((account) => account.id === accountId)?.source === "minimax-native") throw new Error("该账号来自 MiniMax Code 桌面端(~/.minimax/auth),本插件只能读取和续期,不能删除。请在 MiniMax Code 应用中退出登录。");
21795
+ super.deleteAccountFromPool(data, accountId);
21658
21796
  }
21659
21797
  /** The credential store this pool mirrors its primary account into. */
21660
21798
  mirrorStore() {
@@ -26157,15 +26295,15 @@ var WorkBuddyAccountPool = class extends AccountPoolCore {
26157
26295
  this.options_selection = options.selection;
26158
26296
  }
26159
26297
  /**
26160
- * Read the pool, refreshing the legacy-key index on the way.
26298
+ * Load the pool, refreshing the legacy-key index on the way.
26161
26299
  *
26162
- * Every routing decision goes through this method before it consults the
26163
- * settings selection, so building the index here is what keeps a pin written
26164
- * before the identity fix resolvable when {@link resolveSelectionId} is asked
26165
- * for it.
26300
+ * Every routing decision and mutation goes through this method before it
26301
+ * consults the settings selection, so building the index here is what keeps
26302
+ * a pin written before the identity fix resolvable when
26303
+ * {@link resolveSelectionId} is asked for it.
26166
26304
  */
26167
- async read() {
26168
- const data = await super.read();
26305
+ async loadPoolData() {
26306
+ const data = await super.loadPoolData();
26169
26307
  this.legacyIds.clear();
26170
26308
  for (const account of data.accounts) for (const alias of workBuddyAccountIdAliases(account.credentials)) if (alias !== account.id) this.legacyIds.set(alias, account.id);
26171
26309
  return data;
@@ -26185,20 +26323,34 @@ var WorkBuddyAccountPool = class extends AccountPoolCore {
26185
26323
  */
26186
26324
  async syncDesktopAccounts() {
26187
26325
  const discovered = await this.store.discoverDesktopCredentials();
26188
- if (discovered.length > 0) {
26189
- const data = await this.read();
26190
- const known = new Set(data.accounts.map((account) => workBuddyAccountId(account.credentials)));
26191
- const seen = /* @__PURE__ */ new Set();
26192
- for (const credentials of discovered) {
26193
- const id = workBuddyAccountId(credentials);
26194
- if (known.has(id) || seen.has(id)) continue;
26195
- seen.add(id);
26196
- await this.addAccount({
26197
- ...credentials,
26198
- source: "desktop"
26199
- });
26326
+ const candidates = [];
26327
+ const seen = /* @__PURE__ */ new Set();
26328
+ for (const raw of discovered) {
26329
+ const credentials = withResolvedIdentity({
26330
+ ...raw,
26331
+ source: "desktop"
26332
+ });
26333
+ const id = workBuddyAccountId(credentials);
26334
+ if (seen.has(id)) continue;
26335
+ seen.add(id);
26336
+ candidates.push(credentials);
26337
+ }
26338
+ if (candidates.length > 0) await this.updatePool((data) => {
26339
+ const existingKeys = /* @__PURE__ */ new Set();
26340
+ for (const account of data.accounts) {
26341
+ existingKeys.add(account.id);
26342
+ const key = this.hooks.dedupeKey?.(account.credentials);
26343
+ if (key !== void 0) existingKeys.add(key);
26200
26344
  }
26201
- }
26345
+ for (const credentials of candidates) {
26346
+ const key = this.hooks.dedupeKey?.(credentials);
26347
+ const id = this.hooks.accountId?.(credentials);
26348
+ if (key !== void 0 && existingKeys.has(key) || id !== void 0 && existingKeys.has(id)) continue;
26349
+ this.addAccountToPool(data, credentials);
26350
+ if (key !== void 0) existingKeys.add(key);
26351
+ if (id !== void 0) existingKeys.add(id);
26352
+ }
26353
+ });
26202
26354
  return this.listAccounts();
26203
26355
  }
26204
26356
  /**
@@ -26214,7 +26366,13 @@ var WorkBuddyAccountPool = class extends AccountPoolCore {
26214
26366
  const live = await this.store.readDesktopFile(effective.account.sourceFile);
26215
26367
  if (live === null) return effective;
26216
26368
  if (isExpired(live)) return effective;
26217
- if (live.accessToken !== effective.credentials.accessToken) await this.updateAccountCredentials(effective.account.id, live).catch(() => void 0);
26369
+ if (live.accessToken !== effective.credentials.accessToken) {
26370
+ const updated = await this.updateAccountCredentials(effective.account.id, live, effective.credentials).catch(() => void 0);
26371
+ if (updated) return {
26372
+ account: updated,
26373
+ credentials: updated.credentials
26374
+ };
26375
+ }
26218
26376
  return {
26219
26377
  account: effective.account,
26220
26378
  credentials: live
@@ -26251,15 +26409,15 @@ var WorkBuddyAccountPool = class extends AccountPoolCore {
26251
26409
  return super.addAccount(withResolvedIdentity(credentials), alias);
26252
26410
  }
26253
26411
  /**
26254
- * Delete one account.
26412
+ * Remove one account inside a transaction.
26255
26413
  *
26256
26414
  * A desktop account is refused: the plugin does not own that credential file,
26257
26415
  * so removing it from the pool would strand the IDE's session while telling
26258
26416
  * the user their account was deleted. Hiding is the supported action there.
26259
26417
  */
26260
- async deleteAccount(accountId) {
26261
- if ((await this.read()).accounts.find((account) => account.id === accountId)?.source === "desktop") throw new Error("桌面账号由 CodeBuddy 客户端所有,只能隐藏,不能删除。");
26262
- await super.deleteAccount(accountId);
26418
+ deleteAccountFromPool(data, accountId) {
26419
+ if (data.accounts.find((account) => account.id === accountId)?.source === "desktop") throw new Error("桌面账号由 CodeBuddy 客户端所有,只能隐藏,不能删除。");
26420
+ super.deleteAccountFromPool(data, accountId);
26263
26421
  }
26264
26422
  };
26265
26423
  //#endregion
@@ -32577,7 +32735,7 @@ function isAdoptedCredentialExpired(credential, now = Date.now()) {
32577
32735
  * caller in the process, so with a pool it would hand the second account the
32578
32736
  * first account's freshly rotated credential — and, through the adapter above,
32579
32737
  * write it into the second account's record. The pool therefore keeps its OWN
32580
- * single-flight, per account (see the inFlight map), which preserves the property
32738
+ * single-flight, per pool file and account, which preserves the property
32581
32739
  * the frozen module wanted (one rotation per expiry, no two callers spending the
32582
32740
  * same rotating token) without the cross-account contamination.
32583
32741
  *
@@ -32963,7 +33121,6 @@ var ClaudeAccountPool = class extends AccountPoolCore {
32963
33121
  * marked expired immediately after a refresh that in fact succeeded. Keyed by
32964
33122
  * the record's id — never by a token — so it cannot mix two accounts up.
32965
33123
  */
32966
- inFlight = /* @__PURE__ */ new Map();
32967
33124
  constructor(options = {}) {
32968
33125
  const store = options.store ?? new FileCredentialStore();
32969
33126
  const refreshSeam = options.refreshCredentials ?? ((credentials, fetchFn) => refreshClaudeCredential(credentials, fetchFn));
@@ -33010,6 +33167,10 @@ var ClaudeAccountPool = class extends AccountPoolCore {
33010
33167
  if (record === void 0) return null;
33011
33168
  return poolAccountForRecord(record, true);
33012
33169
  },
33170
+ credentialsCommitted: async (account) => {
33171
+ if (isAdoptedPoolCredential(account.credentials)) return;
33172
+ await store.saveAccount(account.credentials, { internalId: account.id });
33173
+ },
33013
33174
  mirrorPrimary: async (credentials) => {
33014
33175
  if (credentials === null) {
33015
33176
  await store.delete();
@@ -33071,8 +33232,8 @@ var ClaudeAccountPool = class extends AccountPoolCore {
33071
33232
  * {@link recordIdsFor} reads is kept in step with the rows that actually exist.
33072
33233
  * See the recordIds field for why entries are not pruned.
33073
33234
  */
33074
- async read() {
33075
- const data = await super.read();
33235
+ async loadPoolData() {
33236
+ const data = await super.loadPoolData();
33076
33237
  for (const account of data.accounts) this.indexAccount(account);
33077
33238
  return data;
33078
33239
  }
@@ -33085,10 +33246,27 @@ var ClaudeAccountPool = class extends AccountPoolCore {
33085
33246
  * 'one account, one key' section.
33086
33247
  */
33087
33248
  async addAccount(credentials, alias) {
33088
- const data = await this.read();
33249
+ return this.updatePool((data) => this.addManagedAccount(data, credentials, alias)).catch((error) => {
33250
+ this.recordIds.clear();
33251
+ throw error;
33252
+ });
33253
+ }
33254
+ async addManagedAccount(data, credentials, alias) {
33089
33255
  if (isAdoptedPoolCredential(credentials)) return this.addAdoptedAccount(credentials, data, alias);
33256
+ parseClaudeCredentials(credentials);
33090
33257
  const knownId = await this.recordIdFor(credentials);
33091
- const record = await this.store.saveAccount(credentials);
33258
+ const document = await this.store.read();
33259
+ const keys = identityKeysFor(credentials);
33260
+ const stored = document?.accounts.find((record) => record.identityKeys.some((key) => keys.includes(key)));
33261
+ const record = stored === void 0 ? {
33262
+ internalId: data.accounts.find((account) => account.id === knownId)?.adopted === false ? knownId ?? createInternalId() : createInternalId(),
33263
+ identityKeys: keys,
33264
+ credentials
33265
+ } : {
33266
+ ...stored,
33267
+ identityKeys: mergeIdentityKeys(stored.identityKeys, keys),
33268
+ credentials
33269
+ };
33092
33270
  this.indexRecord(record);
33093
33271
  if (knownId !== void 0 && knownId !== record.internalId) {
33094
33272
  const row = data.accounts.find((account) => account.id === knownId);
@@ -33100,10 +33278,9 @@ var ClaudeAccountPool = class extends AccountPoolCore {
33100
33278
  delete row.sourcePath;
33101
33279
  row.identityKeys = mergeIdentityKeys(row.identityKeys, record.identityKeys);
33102
33280
  if (data.activeAccountId === knownId) data.activeAccountId = record.internalId;
33103
- await this.write(data);
33104
33281
  }
33105
33282
  }
33106
- return super.addAccount(credentials, alias);
33283
+ return this.addAccountToPool(data, credentials, alias);
33107
33284
  }
33108
33285
  /**
33109
33286
  * Add, or re-import, one borrowed Claude Code snapshot.
@@ -33132,9 +33309,7 @@ var ClaudeAccountPool = class extends AccountPoolCore {
33132
33309
  const candidateId = byIdentity ?? this.recordIds.get(sourceKey);
33133
33310
  const existing = candidateId === void 0 ? void 0 : data.accounts.find((account) => account.id === candidateId);
33134
33311
  if (existing !== void 0 && existing.adopted !== true) {
33135
- const before = existing.identityKeys.length;
33136
33312
  existing.identityKeys = mergeIdentityKeys(existing.identityKeys, identityKeysFor(credentials));
33137
- if (existing.identityKeys.length !== before) await this.write(data);
33138
33313
  return existing;
33139
33314
  }
33140
33315
  if (candidateId === void 0) {
@@ -33145,7 +33320,7 @@ var ClaudeAccountPool = class extends AccountPoolCore {
33145
33320
  this.recordIds.set(sourceKey, candidateId);
33146
33321
  this.indexCredential(credentials, candidateId);
33147
33322
  }
33148
- return super.addAccount(credentials, alias);
33323
+ return this.addAccountToPool(data, credentials, alias);
33149
33324
  }
33150
33325
  /**
33151
33326
  * Merge two rows that are one account.
@@ -33168,7 +33343,21 @@ var ClaudeAccountPool = class extends AccountPoolCore {
33168
33343
  */
33169
33344
  async mergeAccounts(keepAccountId, dropAccountId) {
33170
33345
  if (keepAccountId === dropAccountId) throw new Error("A Claude pool merge needs two different accounts.");
33171
- const data = await this.read();
33346
+ let originals;
33347
+ return await this.updatePool(async (data) => {
33348
+ const keep = data.accounts.find((account) => account.id === keepAccountId);
33349
+ const drop = data.accounts.find((account) => account.id === dropAccountId);
33350
+ if (keep === void 0 || drop === void 0) throw new Error("The Claude pool merge addressed an account the pool does not have.");
33351
+ originals = {
33352
+ keep,
33353
+ drop
33354
+ };
33355
+ return this.mergeAccountsInPool(data, keepAccountId, dropAccountId);
33356
+ }, async (_data, result) => {
33357
+ if (originals !== void 0) await this.reconcileMergedDocument(originals.keep, originals.drop, result);
33358
+ });
33359
+ }
33360
+ async mergeAccountsInPool(data, keepAccountId, dropAccountId) {
33172
33361
  const keep = data.accounts.find((account) => account.id === keepAccountId);
33173
33362
  const drop = data.accounts.find((account) => account.id === dropAccountId);
33174
33363
  if (keep === void 0 || drop === void 0) throw new Error("The Claude pool merge addressed an account the pool does not have.");
@@ -33217,9 +33406,6 @@ var ClaudeAccountPool = class extends AccountPoolCore {
33217
33406
  data.accounts[index] = merged;
33218
33407
  data.accounts = data.accounts.filter((account) => account.id !== dropAccountId);
33219
33408
  if (data.activeAccountId === dropAccountId) data.activeAccountId = merged.id;
33220
- await this.write(data);
33221
- if (merged.isPrimary) this.hooks.mirrorPrimary?.(merged.credentials).catch(() => void 0);
33222
- await this.reconcileMergedDocument(keep, drop, merged);
33223
33409
  return merged;
33224
33410
  }
33225
33411
  /**
@@ -33259,10 +33445,12 @@ var ClaudeAccountPool = class extends AccountPoolCore {
33259
33445
  * neither path.
33260
33446
  */
33261
33447
  async removeImportedAccount(accountId) {
33262
- const target = (await this.read()).accounts.find((account) => account.id === accountId);
33263
- if (target === void 0) return;
33264
- if (target.adopted !== true) throw new Error("That account was signed in through this plugin; delete it instead of removing an import.");
33265
- await super.deleteAccount(accountId);
33448
+ return this.updatePool((data) => {
33449
+ const target = data.accounts.find((account) => account.id === accountId);
33450
+ if (target === void 0) return;
33451
+ if (target.adopted !== true) throw new Error("That account was signed in through this plugin; delete it instead of removing an import.");
33452
+ super.deleteAccountFromPool(data, accountId);
33453
+ });
33266
33454
  }
33267
33455
  /**
33268
33456
  * Delete one plugin-owned account.
@@ -33275,9 +33463,12 @@ var ClaudeAccountPool = class extends AccountPoolCore {
33275
33463
  * back a credential the user just deleted.
33276
33464
  */
33277
33465
  async deleteAccount(accountId) {
33278
- if ((await this.read()).accounts.find((account) => account.id === accountId)?.adopted === true) throw new Error("The sign-in imported from Claude Code is not an account this plugin owns; remove the import instead.");
33279
- await super.deleteAccount(accountId);
33280
- await this.store.deleteAccount(accountId);
33466
+ return this.updatePool((data) => {
33467
+ if (data.accounts.find((account) => account.id === accountId)?.adopted === true) throw new Error("The sign-in imported from Claude Code is not an account this plugin owns; remove the import instead.");
33468
+ super.deleteAccountFromPool(data, accountId);
33469
+ }, async () => {
33470
+ await this.store.deleteAccount(accountId);
33471
+ });
33281
33472
  }
33282
33473
  /**
33283
33474
  * The immutable id of the record that owns a credential, or nothing.
@@ -33357,44 +33548,7 @@ var ClaudeAccountPool = class extends AccountPoolCore {
33357
33548
  */
33358
33549
  refreshCredentials(credentials, fetchFn) {
33359
33550
  if (isAdoptedPoolCredential(credentials)) return Promise.reject(new ClaudeAdoptedCredentialError(adoptedCredentialExpired(credentials) ? ADOPTED_CREDENTIAL_EXPIRED_HINT : ADOPTED_NEVER_REFRESHED_MESSAGE));
33360
- return this.singleFlightRefresh(credentials, fetchFn);
33361
- }
33362
- /**
33363
- * The per-account single-flight, resolving the account's id first.
33364
- *
33365
- * Separated from the hook above only because resolving the id consults the
33366
- * credential document, and a hook cannot await: the guard stays synchronous so
33367
- * it can never be skipped by a caller that forgets to wait.
33368
- */
33369
- async singleFlightRefresh(credentials, fetchFn) {
33370
- const key = await this.recordIdFor(credentials);
33371
- if (key !== void 0) {
33372
- const shared = this.inFlight.get(key);
33373
- if (shared !== void 0) return shared;
33374
- }
33375
- const pending = this.performRefresh(credentials, fetchFn);
33376
- if (key !== void 0) {
33377
- this.inFlight.set(key, pending);
33378
- pending.then(() => void 0, () => void 0).then(() => {
33379
- if (this.inFlight.get(key) === pending) this.inFlight.delete(key);
33380
- });
33381
- }
33382
- return pending;
33383
- }
33384
- /**
33385
- * Rotate one managed credential and keep the credential document in step.
33386
- *
33387
- * The document write is what stops the pre-pool projection from handing out a
33388
- * token the rotation has already spent. Its failure is swallowed on purpose:
33389
- * the credential WAS refreshed, the pool's own record holds it, and a mirror
33390
- * that could not be written must not fail a request that can still be served —
33391
- * the same rule the core applies to its own bookkeeping writes.
33392
- */
33393
- async performRefresh(credentials, fetchFn) {
33394
- const recordId = await this.recordIdFor(credentials);
33395
- const refreshed = await this.refreshSeam(credentials, fetchFn);
33396
- if (recordId !== void 0) await new ClaudeCredentialStoreAdapter(this.store, recordId).write(refreshed).catch(() => void 0);
33397
- return refreshed;
33551
+ return this.refreshSeam(credentials, fetchFn);
33398
33552
  }
33399
33553
  };
33400
33554
  //#endregion
@@ -37112,9 +37266,9 @@ function appendWindow(current, addition, limit) {
37112
37266
  /**
37113
37267
  * Install the reasoning-collapse guard on a Context.
37114
37268
  *
37115
- * The guard is inert unless the host exposes the `llm/stream` seam, so a
37116
- * harness without it keeps the built-in behaviour instead of failing this
37117
- * plugin's load.
37269
+ * The guard is inert unless the host exposes the event bus, so a harness
37270
+ * without it keeps the built-in behaviour instead of failing this plugin's
37271
+ * load.
37118
37272
  *
37119
37273
  * Resuming needs the live Agent for `cancel` and `steer`, and no supported
37120
37274
  * generation hands the agent to `llm/stream`. `agent/created` reports it on
@@ -37124,19 +37278,19 @@ function appendWindow(current, addition, limit) {
37124
37278
  *
37125
37279
  * @param ctx - plugin context.
37126
37280
  * @param options - see {@link GuardOptions}; validated fail-loud.
37127
- * @returns a disposer, or undefined when the seam is unavailable.
37281
+ * @returns a disposer removing both listeners, or undefined when the host
37282
+ * exposes no event bus.
37128
37283
  */
37129
37284
  function installReasoningCollapseGuard(ctx, options = {}) {
37130
37285
  const resolved = resolveGuardOptions(options);
37131
- const runtime = ctx.llm;
37132
- if (runtime === void 0 || typeof runtime.stream !== "function") return void 0;
37133
- const original = runtime.stream;
37286
+ if (typeof ctx.on !== "function") return void 0;
37134
37287
  const watched = new Set(resolved.includeModels);
37135
37288
  const breakers = /* @__PURE__ */ new WeakMap();
37136
37289
  let currentAgent;
37137
- const releaseAgent = typeof ctx.on === "function" ? ctx.on("agent/created", ({ agent }) => {
37138
- currentAgent = agent;
37139
- }) : void 0;
37290
+ const releaseAgent = ctx.on("agent/created", (payload) => {
37291
+ currentAgent = payload.agent;
37292
+ });
37293
+ const releaseStream = ctx.on("llm/stream", guardStream, { global: true });
37140
37294
  function breakerFor(key) {
37141
37295
  let state = breakers.get(key);
37142
37296
  if (state === void 0) {
@@ -37174,7 +37328,7 @@ function installReasoningCollapseGuard(ctx, options = {}) {
37174
37328
  agent.steer?.(createResumeMessage(text));
37175
37329
  });
37176
37330
  }
37177
- runtime.stream = function reasoningCollapseGuardStream(request, next) {
37331
+ function guardStream(request, next) {
37178
37332
  const state = breakerFor(request.signal ?? STATIC_KEY);
37179
37333
  if (state.exhausted || !watchedModel(request.model)) return next();
37180
37334
  async function* guarded() {
@@ -37196,9 +37350,9 @@ function installReasoningCollapseGuard(ctx, options = {}) {
37196
37350
  }
37197
37351
  }
37198
37352
  return guarded();
37199
- };
37353
+ }
37200
37354
  return () => {
37201
- runtime.stream = original;
37355
+ releaseListener(releaseStream);
37202
37356
  releaseListener(releaseAgent);
37203
37357
  };
37204
37358
  }
@@ -37290,7 +37444,7 @@ function apply(ctx, pluginConfig = {}) {
37290
37444
  ctx.effect(() => {
37291
37445
  const dispose = installReasoningCollapseGuard(ctx, guardOptions);
37292
37446
  if (dispose === void 0) {
37293
- ctx.logger.warn("[dsh-chatgpt-subscription] reasoning-collapse guard skipped: this harness exposes no llm/stream seam");
37447
+ ctx.logger.warn("[dsh-chatgpt-subscription] reasoning-collapse guard skipped: this harness exposes no event bus");
37294
37448
  return () => void 0;
37295
37449
  }
37296
37450
  return dispose;