@foxden-app/foxclaw 0.5.20 → 0.5.22

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.
@@ -19,6 +19,7 @@ import { diffObservedTurn, findLatestTurn, findLiveTurn } from './observer.js';
19
19
  import { applySessionLog, bootstrapSessionLog, splitJsonlChunk, } from './session_observer.js';
20
20
  import { renderActiveTurnStatus } from './status.js';
21
21
  import { writeRuntimeStatus } from '../runtime.js';
22
+ const AUTH_DELETE_REASON_NEEDS_REPAIR = 'needs_repair';
22
23
  class UserFacingError extends Error {
23
24
  }
24
25
  const OBSERVED_THREAD_POLL_MS = 1500;
@@ -432,6 +433,7 @@ export class BridgeSessionCore {
432
433
  t(locale, 'status_pending_user_inputs', { value: this.store.countPendingUserInputs() }),
433
434
  t(locale, 'status_queued_turns', { value: this.store.countQueuedTurnInputs(scopeId) }),
434
435
  t(locale, 'status_active_turns', { value: this.activeTurns.size }),
436
+ formatCodexAuthPoolSummary(locale, this.store.getCodexAuthPoolStats()),
435
437
  ];
436
438
  if (serviceStatus) {
437
439
  lines.push('', t(locale, 'status_runtime_overview'));
@@ -755,7 +757,7 @@ export class BridgeSessionCore {
755
757
  return;
756
758
  }
757
759
  case 'config': {
758
- await this.handleConfigCommand(scopeId, locale);
760
+ await this.handleConfigCommand(scopeId, locale, args);
759
761
  return;
760
762
  }
761
763
  case 'requirements': {
@@ -1053,6 +1055,11 @@ export class BridgeSessionCore {
1053
1055
  await this.handleSetupCallback(event, setupMatch[1], setupMatch[2], locale);
1054
1056
  return;
1055
1057
  }
1058
+ const configMatch = /^config:auth_auto_delete:(on|off)$/.exec(event.data);
1059
+ if (configMatch) {
1060
+ await this.handleConfigToggleCallback(event, configMatch[1] === 'on', locale);
1061
+ return;
1062
+ }
1056
1063
  const settingsMatch = /^settings:(model|effort|access):(.+)$/.exec(event.data);
1057
1064
  if (settingsMatch) {
1058
1065
  await this.handleSettingsCallback(event, settingsMatch[1], settingsMatch[2], locale);
@@ -1383,7 +1390,8 @@ export class BridgeSessionCore {
1383
1390
  : threadId
1384
1391
  ? this.findActiveTurnsByThreadId(threadId)
1385
1392
  : [];
1386
- const isAuthRotationError = isCodexAuthRotationError(params);
1393
+ const authRotationReason = classifyCodexAuthRotationError(params);
1394
+ const isAuthRotationError = authRotationReason !== null;
1387
1395
  const willRetry = params?.willRetry === true;
1388
1396
  const active = isAuthRotationError
1389
1397
  ? activeTurns.find(turn => turn.authRetry !== null) ?? activeTurns.find(turn => !turn.isObserved) ?? activeTurns[0] ?? null
@@ -1396,12 +1404,13 @@ export class BridgeSessionCore {
1396
1404
  else if (turnId) {
1397
1405
  this.pendingTurnErrors.set(turnId, message);
1398
1406
  }
1399
- if (isAuthRotationError && !willRetry && active?.authRetry) {
1407
+ if (authRotationReason && !willRetry && active?.authRetry) {
1400
1408
  const scopeId = active.scopeId;
1401
1409
  if (scopeId) {
1402
1410
  this.pendingAuthRotation = {
1403
1411
  scopeId,
1404
1412
  reason: message,
1413
+ reasonKind: authRotationReason,
1405
1414
  retry: cloneAuthRetryContext(active.authRetry),
1406
1415
  };
1407
1416
  }
@@ -2931,6 +2940,19 @@ export class BridgeSessionCore {
2931
2940
  async getCurrentAuthLabel() {
2932
2941
  return (await this.listCodexAuthState()).currentLabel;
2933
2942
  }
2943
+ async handleExternalCodexAuthCandidateDeleted(candidateName, reason = null) {
2944
+ this.store.deleteCodexAuthCandidate(candidateName);
2945
+ if (isInvalidCodexAuthDeleteReason(reason)) {
2946
+ this.store.recordCodexAuthCandidateInvalidDelete(candidateName, reason);
2947
+ }
2948
+ else {
2949
+ this.store.recordCodexAuthCandidateRemoved(candidateName, reason);
2950
+ }
2951
+ this.authRotationFailedTargets.delete(path.join(this.resolveAuthDir(), candidateName));
2952
+ this.pendingTurnErrors.clear();
2953
+ this.attachedThreads.clear();
2954
+ await this.app.restart();
2955
+ }
2934
2956
  async validateExternalCodexAuthCandidate(candidateName, rawAuth, expectedAccountId) {
2935
2957
  if (this.externalAuthValidationInProgress) {
2936
2958
  return { ok: false, reason: 'runtime is not idle' };
@@ -4761,13 +4783,13 @@ export class BridgeSessionCore {
4761
4783
  throw error;
4762
4784
  }
4763
4785
  }
4764
- async deleteCodexAuthCandidate(candidate) {
4786
+ async deleteCodexAuthCandidate(candidate, reason = null) {
4765
4787
  const wasCurrent = candidate.isCurrent;
4766
4788
  const authDir = this.resolveAuthDir();
4767
4789
  const authPath = path.join(authDir, 'auth.json');
4768
4790
  let deletedByCoordinator = false;
4769
4791
  try {
4770
- await this.coordinator?.authCandidateDeleted?.(this.authRuntimeId(), candidate.name);
4792
+ await this.coordinator?.authCandidateDeleted?.(this.authRuntimeId(), candidate.name, reason);
4771
4793
  deletedByCoordinator = Boolean(this.coordinator?.authCandidateDeleted);
4772
4794
  }
4773
4795
  catch (error) {
@@ -4784,6 +4806,12 @@ export class BridgeSessionCore {
4784
4806
  }
4785
4807
  }
4786
4808
  this.store.deleteCodexAuthCandidate(candidate.name);
4809
+ if (isInvalidCodexAuthDeleteReason(reason)) {
4810
+ this.store.recordCodexAuthCandidateInvalidDelete(candidate.name, reason);
4811
+ }
4812
+ else {
4813
+ this.store.recordCodexAuthCandidateRemoved(candidate.name, reason);
4814
+ }
4787
4815
  this.authRotationFailedTargets.delete(candidate.path);
4788
4816
  const snapshots = await this.readCodexAuthQuotaSnapshots();
4789
4817
  if (Object.prototype.hasOwnProperty.call(snapshots, candidate.name)) {
@@ -5092,10 +5120,58 @@ export class BridgeSessionCore {
5092
5120
  const features = await this.app.listExperimentalFeatures();
5093
5121
  await this.sendMessage(scopeId, formatFeaturesMessage(locale, features));
5094
5122
  }
5095
- async handleConfigCommand(scopeId, locale) {
5123
+ async handleConfigCommand(scopeId, locale, args = []) {
5124
+ const action = args[0]?.toLowerCase() ?? '';
5125
+ if (['auth_auto_delete', 'auth-auto-delete', 'auto_delete_needs_repair', 'auto-delete-needs-repair'].includes(action)) {
5126
+ const enabled = parseConfigBooleanArg(args[1]);
5127
+ if (enabled === null) {
5128
+ await this.sendMessage(scopeId, t(locale, 'config_auth_auto_delete_usage'));
5129
+ return;
5130
+ }
5131
+ const update = await this.setAuthAutoDeleteNeedsRepair(enabled);
5132
+ const binding = this.store.getBinding(scopeId);
5133
+ const result = await this.app.readConfig(binding?.cwd ?? this.config.defaultCwd, true);
5134
+ await this.sendMessage(scopeId, `${this.formatConfigToggleUpdate(locale, update)}\n\n${formatConfigMessage(locale, result, this.config, this.store.getCodexAuthPoolStats())}`, configKeyboard(locale, this.config));
5135
+ return;
5136
+ }
5096
5137
  const binding = this.store.getBinding(scopeId);
5097
5138
  const result = await this.app.readConfig(binding?.cwd ?? this.config.defaultCwd, true);
5098
- await this.sendMessage(scopeId, formatConfigMessage(locale, result));
5139
+ await this.sendMessage(scopeId, formatConfigMessage(locale, result, this.config, this.store.getCodexAuthPoolStats()), configKeyboard(locale, this.config));
5140
+ }
5141
+ async handleConfigToggleCallback(event, enabled, locale) {
5142
+ const update = await this.setAuthAutoDeleteNeedsRepair(enabled);
5143
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
5144
+ const binding = this.store.getBinding(event.scopeId);
5145
+ const result = await this.app.readConfig(binding?.cwd ?? this.config.defaultCwd, true);
5146
+ const message = `${this.formatConfigToggleUpdate(locale, update)}\n\n${formatConfigMessage(locale, result, this.config, this.store.getCodexAuthPoolStats())}`;
5147
+ if (event.messageId !== null) {
5148
+ await this.editMessage(event.scopeId, event.messageId, message, configKeyboard(locale, this.config));
5149
+ }
5150
+ else {
5151
+ await this.sendMessage(event.scopeId, message, configKeyboard(locale, this.config));
5152
+ }
5153
+ }
5154
+ async setAuthAutoDeleteNeedsRepair(enabled) {
5155
+ this.config.authAutoDeleteNeedsRepair = enabled;
5156
+ const envPath = this.config.envPath;
5157
+ if (!envPath) {
5158
+ return { enabled, envPath: null, envUpdated: false, envError: null };
5159
+ }
5160
+ try {
5161
+ await writeEnvBoolean(envPath, 'AUTH_AUTO_DELETE_NEEDS_REPAIR', enabled);
5162
+ return { enabled, envPath, envUpdated: true, envError: null };
5163
+ }
5164
+ catch (error) {
5165
+ this.logger.warn('config.env_update_failed', { key: 'AUTH_AUTO_DELETE_NEEDS_REPAIR', envPath, error: toErrorMeta(error) });
5166
+ return { enabled, envPath, envUpdated: false, envError: formatUserError(error) };
5167
+ }
5168
+ }
5169
+ formatConfigToggleUpdate(locale, update) {
5170
+ const lines = [t(locale, 'config_auth_auto_delete_updated', { value: t(locale, update.enabled ? 'yes' : 'no') })];
5171
+ if (update.envError) {
5172
+ lines.push(t(locale, 'config_env_update_failed', { value: update.envPath ?? t(locale, 'unknown'), error: update.envError }));
5173
+ }
5174
+ return lines.join('\n');
5099
5175
  }
5100
5176
  async handleRequirementsCommand(scopeId, locale) {
5101
5177
  const requirements = await this.app.readConfigRequirements();
@@ -5430,7 +5506,7 @@ export class BridgeSessionCore {
5430
5506
  const failedTargets = rotation.retry?.failedAuthTargets ?? this.authRotationFailedTargets;
5431
5507
  const locale = this.localeForChat(rotation.scopeId);
5432
5508
  const current = (await this.listCodexAuthState()).candidates.find(candidate => candidate.isCurrent) ?? null;
5433
- if (current) {
5509
+ if (current && rotation.reasonKind === 'auth_invalid') {
5434
5510
  const recoveredCurrent = await this.recoverCodexAuthCandidate(current.name);
5435
5511
  if (recoveredCurrent) {
5436
5512
  await this.sendMessage(rotation.scopeId, t(locale, 'auth_auto_recovered_current', {
@@ -5447,21 +5523,29 @@ export class BridgeSessionCore {
5447
5523
  }
5448
5524
  return false;
5449
5525
  }
5450
- this.markCodexAuthCandidateNeedsRepair(current.name);
5526
+ const disposition = await this.markCodexAuthCandidateNeedsRepair(current.name);
5527
+ if (disposition.deleted) {
5528
+ await this.sendMessage(rotation.scopeId, formatCodexAuthPoolSummary(locale, this.store.getCodexAuthPoolStats()));
5529
+ }
5451
5530
  }
5452
5531
  const selection = await this.selectNextCodexAuthCandidate(failedTargets);
5453
5532
  if (!selection) {
5454
- await this.sendMessage(rotation.scopeId, t(locale, 'auth_auto_no_candidate', {
5533
+ await this.sendMessage(rotation.scopeId, t(locale, rotation.reasonKind === 'quota_limited' ? 'auth_quota_no_candidate' : 'auth_auto_no_candidate', {
5455
5534
  error: formatShortStatusError(rotation.reason),
5456
5535
  }));
5457
5536
  return false;
5458
5537
  }
5459
5538
  const { candidate, fromLabel, toLabel } = selection;
5460
- await this.sendMessage(rotation.scopeId, t(locale, 'auth_auto_switching', {
5461
- ...this.codexAuthSwitchParams(locale, fromLabel, toLabel),
5462
- error: formatShortStatusError(rotation.reason),
5463
- }));
5464
- const outcome = await this.switchCodexAuthAndRestart(rotation.scopeId, locale, candidate, true);
5539
+ const switchingKey = rotation.reasonKind === 'quota_limited'
5540
+ ? (this.config.authAutoDeleteNeedsRepair ? 'auth_quota_switching_quiet' : 'auth_quota_switching')
5541
+ : (this.config.authAutoDeleteNeedsRepair ? 'auth_auto_switching_quiet' : 'auth_auto_switching');
5542
+ await this.sendMessage(rotation.scopeId, this.config.authAutoDeleteNeedsRepair
5543
+ ? t(locale, switchingKey, { error: formatShortStatusError(rotation.reason) })
5544
+ : t(locale, switchingKey, {
5545
+ ...this.codexAuthSwitchParams(locale, fromLabel, toLabel),
5546
+ error: formatShortStatusError(rotation.reason),
5547
+ }));
5548
+ const outcome = await this.switchCodexAuthAndRestart(rotation.scopeId, locale, candidate, true, true, rotation.reasonKind);
5465
5549
  if (!outcome.ok) {
5466
5550
  return false;
5467
5551
  }
@@ -5548,6 +5632,7 @@ export class BridgeSessionCore {
5548
5632
  }
5549
5633
  async listCodexAuthState() {
5550
5634
  const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.store.listCodexAuthCandidateStates(this.authRuntimeId()), this.resolveAuthDir());
5635
+ this.store.recordCodexAuthPoolInventory(state.candidates.map(candidate => candidate.name));
5551
5636
  const snapshots = await this.readCodexAuthQuotaSnapshots();
5552
5637
  const candidateQuotaIdentities = await this.readCodexAuthCandidateQuotaIdentities(state.candidates);
5553
5638
  state.candidates.forEach((candidate) => {
@@ -5579,7 +5664,7 @@ export class BridgeSessionCore {
5579
5664
  to: toLabel,
5580
5665
  };
5581
5666
  }
5582
- async switchCodexAuthAndRestart(scopeId, locale, candidate, automatic, sendResult = true) {
5667
+ async switchCodexAuthAndRestart(scopeId, locale, candidate, automatic, sendResult = true, automaticReason = 'auth_invalid') {
5583
5668
  const authDir = this.resolveAuthDir();
5584
5669
  const initialState = await listCodexAuthState(new Set(), new Map(), authDir);
5585
5670
  const authStat = await fs.lstat(initialState.authPath).catch(() => null);
@@ -5594,7 +5679,6 @@ export class BridgeSessionCore {
5594
5679
  await this.app.restart();
5595
5680
  const validation = await this.validateCurrentCodexAuthCandidate(candidate);
5596
5681
  if (!validation.ok) {
5597
- this.markCodexAuthCandidateNeedsRepair(candidate.name);
5598
5682
  let restoredPrevious = false;
5599
5683
  try {
5600
5684
  await restoreCodexAuthTarget(initialState.authDir, initialState.authPath, initialState.currentTargetPath, originalRegularAuth);
@@ -5609,6 +5693,7 @@ export class BridgeSessionCore {
5609
5693
  error: toErrorMeta(error),
5610
5694
  });
5611
5695
  }
5696
+ const repairDisposition = await this.markCodexAuthCandidateNeedsRepair(candidate.name);
5612
5697
  const outcome = {
5613
5698
  ...result,
5614
5699
  ok: false,
@@ -5616,6 +5701,8 @@ export class BridgeSessionCore {
5616
5701
  recovered,
5617
5702
  error: validation.error,
5618
5703
  restoredPrevious,
5704
+ autoDeleted: repairDisposition.deleted,
5705
+ deleteRestarted: repairDisposition.restarted,
5619
5706
  };
5620
5707
  if (sendResult) {
5621
5708
  const lines = this.formatAuthSwitchValidationLines(locale, outcome);
@@ -5632,11 +5719,17 @@ export class BridgeSessionCore {
5632
5719
  recovered,
5633
5720
  error: null,
5634
5721
  restoredPrevious: false,
5722
+ autoDeleted: false,
5723
+ deleteRestarted: false,
5635
5724
  };
5636
5725
  if (!sendResult) {
5637
5726
  return outcome;
5638
5727
  }
5639
- const lines = [t(locale, automatic ? 'auth_auto_done' : 'auth_switch_done', this.codexAuthSwitchParams(locale, result.fromLabel, result.toLabel))];
5728
+ const doneKey = automaticReason === 'quota_limited' ? 'auth_quota_done' : 'auth_auto_done';
5729
+ const doneQuietKey = automaticReason === 'quota_limited' ? 'auth_quota_done_quiet' : 'auth_auto_done_quiet';
5730
+ const lines = [automatic && this.config.authAutoDeleteNeedsRepair
5731
+ ? t(locale, doneQuietKey)
5732
+ : t(locale, automatic ? doneKey : 'auth_switch_done', this.codexAuthSwitchParams(locale, result.fromLabel, result.toLabel))];
5640
5733
  if (recovered) {
5641
5734
  lines.push(t(locale, 'auth_recovered_newer_candidate', { value: candidate.name }));
5642
5735
  }
@@ -5685,12 +5778,19 @@ export class BridgeSessionCore {
5685
5778
  if (outcome.ok) {
5686
5779
  return [];
5687
5780
  }
5781
+ const validationKey = outcome.autoDeleted && this.config.authAutoDeleteNeedsRepair
5782
+ ? 'auth_switch_validation_auto_deleted_quiet'
5783
+ : outcome.autoDeleted
5784
+ ? 'auth_switch_validation_auto_deleted'
5785
+ : 'auth_switch_validation_failed';
5688
5786
  return [
5689
- t(locale, 'auth_switch_validation_failed', {
5787
+ t(locale, validationKey, {
5690
5788
  value: outcome.candidateName,
5691
5789
  error: outcome.error ?? t(locale, 'unknown'),
5692
5790
  }),
5693
5791
  outcome.restoredPrevious ? t(locale, 'auth_switch_validation_reverted') : '',
5792
+ outcome.deleteRestarted ? t(locale, 'auth_delete_current_restarted') : '',
5793
+ outcome.autoDeleted ? formatCodexAuthPoolSummary(locale, this.store.getCodexAuthPoolStats()) : '',
5694
5794
  ].filter(Boolean);
5695
5795
  }
5696
5796
  async recoverCodexAuthCandidate(candidateName, options = { crossNode: true }) {
@@ -5709,8 +5809,23 @@ export class BridgeSessionCore {
5709
5809
  return false;
5710
5810
  }
5711
5811
  }
5712
- markCodexAuthCandidateNeedsRepair(candidateName) {
5812
+ async markCodexAuthCandidateNeedsRepair(candidateName) {
5813
+ if (this.config.authAutoDeleteNeedsRepair) {
5814
+ const candidate = (await this.listCodexAuthState()).candidates.find(entry => entry.name === candidateName) ?? null;
5815
+ if (!candidate) {
5816
+ this.store.deleteCodexAuthCandidate(candidateName);
5817
+ this.store.recordCodexAuthCandidateInvalidDelete(candidateName, AUTH_DELETE_REASON_NEEDS_REPAIR);
5818
+ return { deleted: true, restarted: false };
5819
+ }
5820
+ const restarted = await this.deleteCodexAuthCandidate(candidate, AUTH_DELETE_REASON_NEEDS_REPAIR);
5821
+ this.logger.warn('codex.auth_candidate_auto_deleted', {
5822
+ candidate: candidateName,
5823
+ runtimeId: this.authRuntimeId(),
5824
+ });
5825
+ return { deleted: true, restarted };
5826
+ }
5713
5827
  this.store.setCodexAuthCandidateState(candidateName, 'needs_repair');
5828
+ return { deleted: false, restarted: false };
5714
5829
  }
5715
5830
  markCodexAuthCandidateActive(candidateName) {
5716
5831
  this.store.setCodexAuthCandidateState(candidateName, 'active');
@@ -8078,7 +8193,7 @@ function formatFeaturesMessage(locale, features) {
8078
8193
  }
8079
8194
  return lines.join('\n');
8080
8195
  }
8081
- function formatConfigMessage(locale, result) {
8196
+ function formatConfigMessage(locale, result, appConfig, authPoolStats) {
8082
8197
  const config = result.config && typeof result.config === 'object' ? result.config : {};
8083
8198
  const layers = Array.isArray(result.layers) ? result.layers : [];
8084
8199
  const keys = ['model', 'model_provider', 'approval_policy', 'sandbox_mode', 'web_search', 'service_tier', 'profile', 'review_model'];
@@ -8088,8 +8203,62 @@ function formatConfigMessage(locale, result) {
8088
8203
  lines.push(`${key}: ${value === null || value === undefined ? '-' : formatConfigValue(value)}`);
8089
8204
  }
8090
8205
  lines.push(t(locale, 'config_layers', { count: layers.length }));
8206
+ lines.push('');
8207
+ lines.push(t(locale, 'config_foxclaw_title'));
8208
+ lines.push(t(locale, 'config_auth_auto_delete_needs_repair', {
8209
+ value: t(locale, appConfig.authAutoDeleteNeedsRepair ? 'yes' : 'no'),
8210
+ }));
8211
+ lines.push(`AUTH_AUTO_DELETE_NEEDS_REPAIR=${appConfig.authAutoDeleteNeedsRepair ? 'true' : 'false'}`);
8212
+ lines.push(formatCodexAuthPoolSummary(locale, authPoolStats));
8091
8213
  return lines.join('\n');
8092
8214
  }
8215
+ function formatCodexAuthPoolSummary(locale, stats) {
8216
+ return t(locale, 'auth_pool_summary', {
8217
+ total: stats.totalSeen,
8218
+ alive: stats.alive,
8219
+ deleted: stats.deletedInvalid,
8220
+ });
8221
+ }
8222
+ function isInvalidCodexAuthDeleteReason(reason) {
8223
+ return reason === AUTH_DELETE_REASON_NEEDS_REPAIR;
8224
+ }
8225
+ function configKeyboard(locale, appConfig) {
8226
+ const enabled = appConfig.authAutoDeleteNeedsRepair;
8227
+ return [[{
8228
+ text: t(locale, enabled ? 'button_config_auth_auto_delete_off' : 'button_config_auth_auto_delete_on'),
8229
+ callback_data: `config:auth_auto_delete:${enabled ? 'off' : 'on'}`,
8230
+ }]];
8231
+ }
8232
+ function parseConfigBooleanArg(value) {
8233
+ const normalized = value?.trim().toLowerCase();
8234
+ if (!normalized)
8235
+ return null;
8236
+ if (['1', 'true', 'yes', 'on', 'enable', 'enabled'].includes(normalized))
8237
+ return true;
8238
+ if (['0', 'false', 'no', 'off', 'disable', 'disabled'].includes(normalized))
8239
+ return false;
8240
+ return null;
8241
+ }
8242
+ async function writeEnvBoolean(envPath, key, enabled) {
8243
+ await fs.mkdir(path.dirname(envPath), { recursive: true });
8244
+ const nextLine = `${key}=${enabled ? 'true' : 'false'}`;
8245
+ let contents = '';
8246
+ try {
8247
+ contents = await fs.readFile(envPath, 'utf8');
8248
+ }
8249
+ catch {
8250
+ await fs.writeFile(envPath, `${nextLine}\n`, { encoding: 'utf8', mode: 0o600 });
8251
+ return;
8252
+ }
8253
+ const pattern = new RegExp(`(^|\\n)[ \\t#]*${escapeRegExp(key)}\\s*=.*(?=\\r?\\n|$)`);
8254
+ const nextContents = pattern.test(contents)
8255
+ ? contents.replace(pattern, (_match, prefix) => `${prefix}${nextLine}`)
8256
+ : `${contents}${contents.endsWith('\n') || contents.length === 0 ? '' : '\n'}${nextLine}\n`;
8257
+ await fs.writeFile(envPath, nextContents, { encoding: 'utf8', mode: 0o600 });
8258
+ }
8259
+ function escapeRegExp(value) {
8260
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8261
+ }
8093
8262
  function formatRequirementsMessage(locale, requirements) {
8094
8263
  const lines = [t(locale, 'requirements_title')];
8095
8264
  if (!requirements) {
@@ -9175,16 +9344,36 @@ function cloneAuthRetryContext(context) {
9175
9344
  failedAuthTargets: new Set(context.failedAuthTargets),
9176
9345
  };
9177
9346
  }
9178
- function isCodexAuthRotationError(params) {
9179
- if (isChatGptBackendAccessBlocked(collectCodexErrorText(params))) {
9180
- return false;
9347
+ function classifyCodexAuthRotationError(params) {
9348
+ const collected = collectCodexErrorText(params);
9349
+ if (isChatGptBackendAccessBlocked(collected)) {
9350
+ return null;
9181
9351
  }
9182
9352
  const code = stringOrNull(params?.error?.codexErrorInfo) ?? stringOrNull(params?.error?.code);
9183
- if (code && /usageLimitExceeded|auth|unauthorized|forbidden|login/i.test(code)) {
9184
- return true;
9185
- }
9186
9353
  const message = stringOrNull(params?.error?.message) ?? '';
9187
- return /(usage limit|rate limit|not authenticated|unauthorized|forbidden|sign in|log in|login|auth)/i.test(message);
9354
+ const text = `${code ?? ''}\n${message}\n${collected}`;
9355
+ if (isCodexQuotaLimitError(text)) {
9356
+ return 'quota_limited';
9357
+ }
9358
+ if (code && /auth|unauthorized|forbidden|login/i.test(code)) {
9359
+ return 'auth_invalid';
9360
+ }
9361
+ return /(not authenticated|unauthorized|forbidden|sign in|log in|login|auth)/i.test(message)
9362
+ ? 'auth_invalid'
9363
+ : null;
9364
+ }
9365
+ function isCodexQuotaLimitError(text) {
9366
+ return /usageLimitExceeded/i.test(text)
9367
+ || /you['’]?ve hit your usage limit/i.test(text)
9368
+ || /\busage limit(?:s)?\b/i.test(text)
9369
+ || /\brate limit(?:ed|s)?\b/i.test(text)
9370
+ || /\btoo many requests\b/i.test(text)
9371
+ || /\binsufficient[_\s-]?quota\b/i.test(text)
9372
+ || /\bquota exceeded\b/i.test(text)
9373
+ || /\bbilling hard limit\b/i.test(text)
9374
+ || /\bcredits? exhausted\b/i.test(text)
9375
+ || /\bout of credits?\b/i.test(text)
9376
+ || /\bcredits? limit\b/i.test(text);
9188
9377
  }
9189
9378
  function formatCodexNotificationError(params) {
9190
9379
  const collected = collectCodexErrorText(params);
package/dist/i18n.d.ts CHANGED
@@ -187,6 +187,8 @@ declare const MESSAGES: {
187
187
  readonly auth_repair_reverted: "Restored previous auth. The candidate still needs repair.";
188
188
  readonly auth_candidate_deleted: "Deleted auth candidate: {value}";
189
189
  readonly auth_candidate_deleted_short: "Auth deleted";
190
+ readonly auth_candidate_auto_deleted: "Auto-deleted unrecoverable auth candidate: {value}";
191
+ readonly auth_pool_summary: "Auth pool: total seen {total}, alive {alive}, invalid-deleted {deleted}.";
190
192
  readonly auth_delete_current_restarted: "The deleted candidate was current, so Codex app-server was restarted without it.";
191
193
  readonly auth_no_candidates: "No auth candidates found. Expected files like auth.json_personal in the auth dir.";
192
194
  readonly auth_choice_expired: "This auth list is no longer active";
@@ -195,6 +197,8 @@ declare const MESSAGES: {
195
197
  readonly auth_switching: "Switching Codex auth: {from} -> {to}...";
196
198
  readonly auth_switch_done: "Codex auth switched: {from} -> {to}.";
197
199
  readonly auth_switch_validation_failed: "Selected auth failed validation: {error}. Marked {value} for login repair.";
200
+ readonly auth_switch_validation_auto_deleted: "Selected auth failed validation: {error}. Auto-deleted {value}.";
201
+ readonly auth_switch_validation_auto_deleted_quiet: "Selected auth failed validation and was auto-deleted.";
198
202
  readonly auth_switch_validation_reverted: "Restored the previous auth after the failed switch.";
199
203
  readonly auth_recovered_newer_candidate: "Recovered a newer same-account credential for {value} from another Codex home before reload.";
200
204
  readonly auth_refresh_all_confirm_short: "Review refresh all risk first.";
@@ -211,11 +215,18 @@ declare const MESSAGES: {
211
215
  readonly auth_refresh_all_skipped: "Skipped non-ChatGPT/invalid candidates: {value}";
212
216
  readonly auth_refresh_all_failed: "Failed: {value}";
213
217
  readonly auth_auto_switching: "Codex auth problem detected ({error}). Switching: {from} -> {to}...";
218
+ readonly auth_auto_switching_quiet: "Codex auth problem detected ({error}). Selecting another maintained candidate...";
214
219
  readonly auth_auto_recovered_current: "Codex auth problem detected ({error}). Recovered a newer same-account credential for {value} and restarted Codex app-server.";
215
220
  readonly auth_auto_done: "Auto-switched Codex auth: {from} -> {to}.";
221
+ readonly auth_auto_done_quiet: "Codex auth problem handled with another maintained candidate.";
222
+ readonly auth_quota_switching: "Codex usage limit detected ({error}). Switching: {from} -> {to}...";
223
+ readonly auth_quota_switching_quiet: "Codex usage limit detected ({error}). Selecting another maintained candidate...";
224
+ readonly auth_quota_done: "Switched Codex auth after usage limit: {from} -> {to}.";
225
+ readonly auth_quota_done_quiet: "Codex usage limit handled with another maintained candidate.";
216
226
  readonly auth_auto_retrying: "Retrying the same request with the new auth...";
217
227
  readonly auth_auto_retry_thread_missing: "Auth was switched, but the original thread is no longer available ({threadId}). Retry was stopped to avoid creating duplicate sessions.";
218
228
  readonly auth_auto_no_candidate: "Codex auth problem detected ({error}), but no unused auth candidate is available.";
229
+ readonly auth_quota_no_candidate: "Codex usage limit detected ({error}), but no unused auth candidate is available.";
219
230
  readonly auth_add_exists: "Auth candidate already exists: {value}";
220
231
  readonly auth_add_preparing: "Preparing new Codex auth candidate {value}...";
221
232
  readonly auth_add_started: "New auth login started for {value}.";
@@ -263,6 +274,8 @@ declare const MESSAGES: {
263
274
  readonly button_auth_filter_all: "All";
264
275
  readonly button_auth_filter_enabled: "Enabled";
265
276
  readonly button_auth_filter_attention: "Attention";
277
+ readonly button_config_auth_auto_delete_on: "Auto-delete on";
278
+ readonly button_config_auth_auto_delete_off: "Auto-delete off";
266
279
  readonly another_turn_running: "Another turn is already running. Use /interrupt, /takeover, /queue, or wait.";
267
280
  readonly working: "Working...";
268
281
  readonly usage_open: "Usage: /open <n>";
@@ -593,6 +606,11 @@ declare const MESSAGES: {
593
606
  readonly features_empty: "No features found.";
594
607
  readonly config_title: "Config summary";
595
608
  readonly config_layers: "Config layers: {count}";
609
+ readonly config_foxclaw_title: "FoxClaw runtime config";
610
+ readonly config_auth_auto_delete_needs_repair: "Auto-delete unrecoverable auth candidates: {value}";
611
+ readonly config_auth_auto_delete_updated: "Auto-delete unrecoverable auth candidates set to: {value}";
612
+ readonly config_auth_auto_delete_usage: "Usage: /config auth_auto_delete <on|off>";
613
+ readonly config_env_update_failed: "Runtime setting changed, but updating {value} failed: {error}";
596
614
  readonly requirements_title: "Config requirements";
597
615
  readonly requirements_empty: "No config requirements are configured.";
598
616
  readonly provider_title: "Model provider capabilities";
@@ -862,6 +880,8 @@ declare const MESSAGES: {
862
880
  readonly auth_repair_reverted: "已恢复之前的 auth。该候选仍需要修复。";
863
881
  readonly auth_candidate_deleted: "已删除 auth 候选:{value}";
864
882
  readonly auth_candidate_deleted_short: "auth 已删除";
883
+ readonly auth_candidate_auto_deleted: "已自动剔除无法恢复的 auth 候选:{value}";
884
+ readonly auth_pool_summary: "auth 池:历史 {total},存活 {alive},因失效剔除 {deleted}。";
865
885
  readonly auth_delete_current_restarted: "被删除的候选是当前 auth,已在删除后重启 Codex app-server。";
866
886
  readonly auth_no_candidates: "没有找到 auth 候选文件。请在 auth 目录中放置类似 auth.json_personal 的文件。";
867
887
  readonly auth_choice_expired: "这个 auth 列表已经不再有效";
@@ -870,6 +890,8 @@ declare const MESSAGES: {
870
890
  readonly auth_switching: "正在切换 Codex auth:{from} -> {to}...";
871
891
  readonly auth_switch_done: "Codex auth 已切换:{from} -> {to}。";
872
892
  readonly auth_switch_validation_failed: "选中的 auth 验证失败:{error}。已将 {value} 标记为需要登录修复。";
893
+ readonly auth_switch_validation_auto_deleted: "选中的 auth 验证失败:{error}。已自动剔除 {value}。";
894
+ readonly auth_switch_validation_auto_deleted_quiet: "选中的 auth 验证失败,已自动剔除。";
873
895
  readonly auth_switch_validation_reverted: "已在切换失败后恢复到之前的 auth。";
874
896
  readonly auth_recovered_newer_candidate: "重载前已从其他 Codex home 恢复 {value} 的同账号较新凭据。";
875
897
  readonly auth_refresh_all_confirm_short: "请先确认刷新全部风险。";
@@ -886,11 +908,18 @@ declare const MESSAGES: {
886
908
  readonly auth_refresh_all_skipped: "已跳过非 ChatGPT/无效候选:{value}";
887
909
  readonly auth_refresh_all_failed: "失败:{value}";
888
910
  readonly auth_auto_switching: "检测到 Codex auth 问题({error}),正在切换:{from} -> {to}...";
911
+ readonly auth_auto_switching_quiet: "检测到 Codex auth 问题({error}),正在选择另一个维护中的候选...";
889
912
  readonly auth_auto_recovered_current: "检测到 Codex auth 问题({error}),已为 {value} 恢复同账号较新凭据并重启 Codex app-server。";
890
913
  readonly auth_auto_done: "已自动切换 Codex auth:{from} -> {to}。";
914
+ readonly auth_auto_done_quiet: "Codex auth 问题已用另一个维护中的候选处理。";
915
+ readonly auth_quota_switching: "检测到 Codex 额度限制({error}),正在切换:{from} -> {to}...";
916
+ readonly auth_quota_switching_quiet: "检测到 Codex 额度限制({error}),正在选择另一个维护中的候选...";
917
+ readonly auth_quota_done: "已因 Codex 额度限制自动切换 auth:{from} -> {to}。";
918
+ readonly auth_quota_done_quiet: "Codex 额度限制已用另一个维护中的候选处理。";
891
919
  readonly auth_auto_retrying: "正在用新的 auth 重试同一条请求...";
892
920
  readonly auth_auto_retry_thread_missing: "Auth 已切换,但原线程已经不可用({threadId})。已停止重试,避免创建重复 session。";
893
921
  readonly auth_auto_no_candidate: "检测到 Codex auth 问题({error}),但没有可用的未失败候选 auth。";
922
+ readonly auth_quota_no_candidate: "检测到 Codex 额度限制({error}),但没有可用的未失败候选 auth。";
894
923
  readonly auth_add_exists: "auth 候选已经存在:{value}";
895
924
  readonly auth_add_preparing: "正在准备新的 Codex auth 候选 {value}...";
896
925
  readonly auth_add_started: "{value} 的新 auth 登录已开始。";
@@ -938,6 +967,8 @@ declare const MESSAGES: {
938
967
  readonly button_auth_filter_all: "全部";
939
968
  readonly button_auth_filter_enabled: "已启用";
940
969
  readonly button_auth_filter_attention: "需关注";
970
+ readonly button_config_auth_auto_delete_on: "开启自动剔除";
971
+ readonly button_config_auth_auto_delete_off: "关闭自动剔除";
941
972
  readonly another_turn_running: "已经有一个回复在进行中。请先等待,或使用 /interrupt、/takeover、/queue。";
942
973
  readonly working: "处理中...";
943
974
  readonly usage_open: "用法:/open <编号>";
@@ -1268,6 +1299,11 @@ declare const MESSAGES: {
1268
1299
  readonly features_empty: "没有找到功能开关。";
1269
1300
  readonly config_title: "配置摘要";
1270
1301
  readonly config_layers: "配置层数:{count}";
1302
+ readonly config_foxclaw_title: "FoxClaw 运行时配置";
1303
+ readonly config_auth_auto_delete_needs_repair: "自动剔除无法恢复的 auth 候选:{value}";
1304
+ readonly config_auth_auto_delete_updated: "自动剔除无法恢复的 auth 候选已设置为:{value}";
1305
+ readonly config_auth_auto_delete_usage: "用法:/config auth_auto_delete <on|off>";
1306
+ readonly config_env_update_failed: "运行时设置已改变,但更新 {value} 失败:{error}";
1271
1307
  readonly requirements_title: "配置要求";
1272
1308
  readonly requirements_empty: "当前没有配置要求。";
1273
1309
  readonly provider_title: "模型供应商能力";