@foxden-app/foxclaw 0.5.14 → 0.5.16

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
4
4
 
5
+ ## 0.5.16 - 2026-06-08
6
+
7
+ ### 中文
8
+ - FoxClaw 重启后会把仍在运行的桥接自有 Codex turn 恢复为可继续操作的活动态,状态卡继续刷新,后续 Telegram 输入会继续 steer 或按聊天设置排队,不再退化成需要用户重新发消息的只读观察态。
9
+ - `/watch` 产生的观察态 turn 会在状态卡中持久化只读标记,重启恢复后仍保持只读,避免把旁观线程误恢复成可操作任务。
10
+
11
+ ### English
12
+ - After a FoxClaw restart, bridge-owned live Codex turns are restored as actionable active turns: their status cards keep updating, and later Telegram messages keep steering or queueing according to the chat setting instead of degrading into read-only watch mode.
13
+ - `/watch`-created observed turns now persist their read-only marker, so restart recovery keeps watched threads read-only and does not accidentally promote them into actionable tasks.
14
+
15
+ ## 0.5.15 - 2026-06-08
16
+
17
+ ### 中文
18
+ - `/auth` 现在会把已确认不可用、并且本机/跨节点同步恢复失败的候选标记为“需要登录修复”,用 `?` 按钮显示,并从自动轮换、主动刷新和 enabled 视图中排除。
19
+ - 点击 `?` 会进入修复菜单,可选择“登录修复”对该候选执行设备码登录,成功后清除修复状态并重新参与轮换;也可选择“删除”,从 canonical 和所有本机 bot runtime 中删除该候选并清理额度缓存。
20
+ - 删除 auth 候选现在走 auth mirror 统一删除,避免只删一个 runtime 后又被其他 runtime 或 canonical 副本恢复。
21
+
22
+ ### English
23
+ - `/auth` now marks candidates that have been proven unusable and could not be recovered through local/cross-node sync as “needs login repair”, shows a `?` action, and excludes them from auto-rotation, proactive refresh, and the enabled filter.
24
+ - Tapping `?` opens a repair menu: Login repair runs device-code login for that candidate and clears the repair state on success; Delete removes the candidate from canonical storage and all local bot runtimes while clearing quota cache.
25
+ - Auth candidate deletion now flows through the auth mirror so deleting a candidate from one runtime is not undone by another runtime or canonical copy.
26
+
5
27
  ## 0.5.14 - 2026-06-08
6
28
 
7
29
  ### 中文
@@ -84,6 +84,7 @@ export declare class AuthCandidateMirror {
84
84
  readRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorCandidateRecord | null>;
85
85
  listNewestCandidates(): Promise<AuthMirrorCandidateRecord[]>;
86
86
  syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
87
+ deleteCandidate(candidateName: string): Promise<boolean>;
87
88
  syncAllRuntimeCandidates(): Promise<AuthMirrorSyncAllResult>;
88
89
  recoverRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorRecovery | null>;
89
90
  private scan;
@@ -126,6 +126,27 @@ export class AuthCandidateMirror {
126
126
  return false;
127
127
  return this.withActivity(() => this.propagateValidatedCandidate(runtime, candidateName));
128
128
  }
129
+ async deleteCandidate(candidateName) {
130
+ if (!isAuthCandidateName(candidateName))
131
+ return false;
132
+ return this.withActivity(async () => {
133
+ const directories = [
134
+ this.canonicalDir,
135
+ ...this.runtimes.map(runtime => runtime.authDir),
136
+ ];
137
+ for (const authDir of directories) {
138
+ await removeAuthCandidate(authDir, candidateName);
139
+ }
140
+ this.lastSyncedRefresh.delete(candidateName);
141
+ for (const key of [...this.lastValidationFailures.keys()]) {
142
+ if (key.endsWith(`:${candidateName}`)) {
143
+ this.lastValidationFailures.delete(key);
144
+ }
145
+ }
146
+ this.logger.info('auth.mirror.deleted', { candidateName });
147
+ return true;
148
+ });
149
+ }
129
150
  async syncAllRuntimeCandidates() {
130
151
  return this.withActivity(async () => {
131
152
  let synced = 0;
@@ -687,6 +708,24 @@ async function pointAuthSymlink(dir, candidateName) {
687
708
  await fs.symlink(path.join(dir, candidateName), temporary);
688
709
  await fs.rename(temporary, path.join(dir, 'auth.json'));
689
710
  }
711
+ async function removeAuthCandidate(dir, candidateName) {
712
+ const candidatePath = path.join(dir, candidateName);
713
+ const authPath = path.join(dir, 'auth.json');
714
+ const currentTarget = await resolveCurrentAuthCandidatePath(authPath);
715
+ await fs.rm(candidatePath, { force: true }).catch(() => undefined);
716
+ if (currentTarget === candidatePath) {
717
+ await fs.rm(authPath, { force: true }).catch(() => undefined);
718
+ }
719
+ }
720
+ async function resolveCurrentAuthCandidatePath(authPath) {
721
+ const stat = await fs.lstat(authPath).catch(() => null);
722
+ if (!stat)
723
+ return null;
724
+ if (stat.isSymbolicLink()) {
725
+ return resolveFinalPath(authPath);
726
+ }
727
+ return null;
728
+ }
690
729
  async function removeValidationTempFiles(dir) {
691
730
  const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
692
731
  await Promise.all(entries
@@ -9,6 +9,7 @@ import type { SelfUpdateRuntime, SelfUpdateStatus } from '../update.js';
9
9
  export interface CoreCoordinator {
10
10
  canSelfUpdate?: () => boolean;
11
11
  authCandidateUpdated?: (runtimeId: string, candidateName: string) => Promise<void>;
12
+ authCandidateDeleted?: (runtimeId: string, candidateName: string) => Promise<void>;
12
13
  recoverAuthCandidate?: (runtimeId: string, candidateName: string, options?: {
13
14
  crossNode?: boolean;
14
15
  }) => Promise<boolean>;
@@ -295,6 +296,8 @@ export declare class BridgeSessionCore {
295
296
  private handleAuthToggleCommand;
296
297
  private findLatestAuthChoiceList;
297
298
  private handleAuthAddCommand;
299
+ private startAuthRepairLogin;
300
+ private deleteCodexAuthCandidate;
298
301
  private handleAccountCommand;
299
302
  private handleQuotaCommand;
300
303
  private handleQuotaNudgeCommand;
@@ -331,6 +334,8 @@ export declare class BridgeSessionCore {
331
334
  private requireReadyBinding;
332
335
  private handleAuthPanelActionCallback;
333
336
  private handleAuthListViewCallback;
337
+ private handleAuthRepairMenuCallback;
338
+ private handleAuthRepairActionCallback;
334
339
  private handleAuthToggleCallback;
335
340
  private handleAuthSwitchCallback;
336
341
  private maybeRunPendingAuthRotation;
@@ -342,6 +347,8 @@ export declare class BridgeSessionCore {
342
347
  private codexAuthSwitchParams;
343
348
  private switchCodexAuthAndRestart;
344
349
  private recoverCodexAuthCandidate;
350
+ private markCodexAuthCandidateNeedsRepair;
351
+ private markCodexAuthCandidateActive;
345
352
  private syncCodexAuthCandidate;
346
353
  private refreshAllCodexAuthCandidates;
347
354
  private refreshCodexAuthCandidates;
@@ -1045,6 +1045,16 @@ export class BridgeSessionCore {
1045
1045
  await this.handleSettingsCallback(event, settingsMatch[1], settingsMatch[2], locale);
1046
1046
  return;
1047
1047
  }
1048
+ const authRepairActionMatch = /^auth:([a-f0-9]+):repair_(login|delete|cancel):(\d+)$/.exec(event.data);
1049
+ if (authRepairActionMatch) {
1050
+ await this.handleAuthRepairActionCallback(event, authRepairActionMatch[1], authRepairActionMatch[2], Number.parseInt(authRepairActionMatch[3], 10), locale);
1051
+ return;
1052
+ }
1053
+ const authRepairMatch = /^auth:([a-f0-9]+):repair:(\d+)$/.exec(event.data);
1054
+ if (authRepairMatch) {
1055
+ await this.handleAuthRepairMenuCallback(event, authRepairMatch[1], Number.parseInt(authRepairMatch[2], 10), locale);
1056
+ return;
1057
+ }
1048
1058
  const authToggleMatch = /^auth:([a-f0-9]+):toggle:(\d+)$/.exec(event.data);
1049
1059
  if (authToggleMatch) {
1050
1060
  await this.handleAuthToggleCallback(event, authToggleMatch[1], Number.parseInt(authToggleMatch[2], 10), locale);
@@ -1657,8 +1667,11 @@ export class BridgeSessionCore {
1657
1667
  if (!success) {
1658
1668
  await this.restorePendingAuthAdd(pendingAuthAdd);
1659
1669
  await this.sendMessage(scopeId, [
1660
- t(locale, 'auth_add_failed', { value: pendingAuthAdd.name, error: params?.error ?? t(locale, 'unknown') }),
1661
- t(locale, 'auth_add_reverted'),
1670
+ t(locale, pendingAuthAdd.mode === 'repair' ? 'auth_repair_failed' : 'auth_add_failed', {
1671
+ value: pendingAuthAdd.name,
1672
+ error: params?.error ?? t(locale, 'unknown'),
1673
+ }),
1674
+ t(locale, pendingAuthAdd.mode === 'repair' ? 'auth_repair_reverted' : 'auth_add_reverted'),
1662
1675
  ].join('\n'));
1663
1676
  return;
1664
1677
  }
@@ -1666,12 +1679,26 @@ export class BridgeSessionCore {
1666
1679
  if (!stat?.isFile()) {
1667
1680
  await this.restorePendingAuthAdd(pendingAuthAdd);
1668
1681
  await this.sendMessage(scopeId, [
1669
- t(locale, 'auth_add_missing_file', { value: pendingAuthAdd.name }),
1670
- t(locale, 'auth_add_reverted'),
1682
+ t(locale, pendingAuthAdd.mode === 'repair' ? 'auth_repair_missing_file' : 'auth_add_missing_file', { value: pendingAuthAdd.name }),
1683
+ t(locale, pendingAuthAdd.mode === 'repair' ? 'auth_repair_reverted' : 'auth_add_reverted'),
1671
1684
  ].join('\n'));
1672
1685
  return;
1673
1686
  }
1674
- const lines = [t(locale, 'auth_add_done', { value: pendingAuthAdd.name })];
1687
+ if (pendingAuthAdd.mode === 'repair') {
1688
+ const metadata = await readChatGptAuthMetadata(pendingAuthAdd.path);
1689
+ if (!metadata || !chatGptAuthMetadataMatchesCandidateName(pendingAuthAdd.name, metadata)) {
1690
+ await this.restorePendingAuthAdd(pendingAuthAdd);
1691
+ await this.sendMessage(scopeId, [
1692
+ t(locale, 'auth_repair_identity_mismatch', { value: pendingAuthAdd.name }),
1693
+ t(locale, 'auth_repair_reverted'),
1694
+ ].join('\n'));
1695
+ return;
1696
+ }
1697
+ this.markCodexAuthCandidateActive(pendingAuthAdd.name);
1698
+ this.store.setCodexAuthCandidateDisabled(pendingAuthAdd.name, false);
1699
+ this.store.setCodexAuthCandidateDisabled(pendingAuthAdd.name, false, this.authRuntimeId());
1700
+ }
1701
+ const lines = [t(locale, pendingAuthAdd.mode === 'repair' ? 'auth_repair_done' : 'auth_add_done', { value: pendingAuthAdd.name })];
1675
1702
  try {
1676
1703
  await this.coordinator?.authCandidateUpdated?.(this.authRuntimeId(), pendingAuthAdd.name);
1677
1704
  }
@@ -1692,6 +1719,7 @@ export class BridgeSessionCore {
1692
1719
  }
1693
1720
  const currentCandidate = (await this.listCodexAuthState()).candidates.find(candidate => candidate.isCurrent) ?? null;
1694
1721
  if (currentCandidate) {
1722
+ this.markCodexAuthCandidateActive(currentCandidate.name);
1695
1723
  await this.syncCodexAuthCandidate(currentCandidate.name);
1696
1724
  }
1697
1725
  await this.sendMessage(scopeId, t(locale, 'login_completed'));
@@ -2834,6 +2862,7 @@ export class BridgeSessionCore {
2834
2862
  scopeId,
2835
2863
  threadId,
2836
2864
  messageId: previewMessageId,
2865
+ isObserved: active.isObserved,
2837
2866
  });
2838
2867
  }
2839
2868
  this.updateStatus();
@@ -4340,6 +4369,7 @@ export class BridgeSessionCore {
4340
4369
  }
4341
4370
  const state = await this.listCodexAuthState();
4342
4371
  const dueCandidates = state.candidates.filter(candidate => !candidate.disabled
4372
+ && candidate.state !== 'needs_repair'
4343
4373
  && candidate.credentialKind === 'chatgpt'
4344
4374
  && candidate.credentialLastRefreshMs !== null
4345
4375
  && candidate.credentialLastRefreshMs <= Date.now() - CODEX_AUTH_PROACTIVE_REFRESH_DAYS * 24 * 60 * 60_000);
@@ -4553,6 +4583,10 @@ export class BridgeSessionCore {
4553
4583
  await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null));
4554
4584
  return;
4555
4585
  }
4586
+ if (candidate.state === 'needs_repair') {
4587
+ await this.sendMessage(scopeId, t(locale, 'auth_candidate_needs_repair', { value: candidate.name }));
4588
+ return;
4589
+ }
4556
4590
  const switchLabels = await this.readCodexAuthSwitchLabels(candidate);
4557
4591
  await this.sendMessage(scopeId, t(locale, 'auth_switching', this.codexAuthSwitchParams(locale, switchLabels.fromLabel, switchLabels.toLabel)));
4558
4592
  await this.switchCodexAuthAndRestart(scopeId, locale, candidate, false);
@@ -4625,6 +4659,7 @@ export class BridgeSessionCore {
4625
4659
  name: candidateName,
4626
4660
  path: targetPath,
4627
4661
  previousTargetPath: state.currentTargetPath,
4662
+ mode: 'add',
4628
4663
  createdAt: Date.now(),
4629
4664
  });
4630
4665
  await this.sendMessage(scopeId, [
@@ -4641,6 +4676,85 @@ export class BridgeSessionCore {
4641
4676
  throw error;
4642
4677
  }
4643
4678
  }
4679
+ async startAuthRepairLogin(scopeId, locale, candidate) {
4680
+ const state = await this.listCodexAuthState();
4681
+ const target = state.candidates.find(entry => entry.name === candidate.name) ?? null;
4682
+ if (!target) {
4683
+ await this.sendMessage(scopeId, t(locale, 'auth_choice_expired'));
4684
+ return;
4685
+ }
4686
+ await pointCodexAuthAtTarget(state.authDir, state.authPath, target.path);
4687
+ this.pendingTurnErrors.clear();
4688
+ this.attachedThreads.clear();
4689
+ try {
4690
+ await this.app.restart();
4691
+ const login = await this.app.startDeviceLogin();
4692
+ const oldLoginId = this.pendingLoginsByScope.get(scopeId);
4693
+ if (oldLoginId) {
4694
+ this.pendingLoginScopesById.delete(oldLoginId);
4695
+ this.pendingAuthAddsByLoginId.delete(oldLoginId);
4696
+ }
4697
+ this.pendingLoginsByScope.set(scopeId, login.loginId);
4698
+ this.pendingLoginScopesById.set(login.loginId, scopeId);
4699
+ this.pendingAuthAddsByLoginId.set(login.loginId, {
4700
+ loginId: login.loginId,
4701
+ scopeId,
4702
+ name: target.name,
4703
+ path: target.path,
4704
+ previousTargetPath: state.currentTargetPath,
4705
+ mode: 'repair',
4706
+ createdAt: Date.now(),
4707
+ });
4708
+ await this.sendMessage(scopeId, [
4709
+ t(locale, 'auth_repair_started', { value: target.name }),
4710
+ t(locale, 'login_device_prereq'),
4711
+ t(locale, 'login_url', { value: login.verificationUrl }),
4712
+ t(locale, 'login_code', { value: login.userCode }),
4713
+ t(locale, 'login_id', { value: login.loginId }),
4714
+ t(locale, 'login_cancel_hint', { value: login.loginId }),
4715
+ ].join('\n'));
4716
+ }
4717
+ catch (error) {
4718
+ await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, state.currentTargetPath);
4719
+ throw error;
4720
+ }
4721
+ }
4722
+ async deleteCodexAuthCandidate(candidate) {
4723
+ const wasCurrent = candidate.isCurrent;
4724
+ const authDir = this.resolveAuthDir();
4725
+ const authPath = path.join(authDir, 'auth.json');
4726
+ let deletedByCoordinator = false;
4727
+ try {
4728
+ await this.coordinator?.authCandidateDeleted?.(this.authRuntimeId(), candidate.name);
4729
+ deletedByCoordinator = Boolean(this.coordinator?.authCandidateDeleted);
4730
+ }
4731
+ catch (error) {
4732
+ this.logger.warn('codex.auth_candidate_delete_sync_failed', {
4733
+ candidate: candidate.name,
4734
+ runtimeId: this.authRuntimeId(),
4735
+ error: toErrorMeta(error),
4736
+ });
4737
+ }
4738
+ if (!deletedByCoordinator) {
4739
+ await fs.rm(candidate.path, { force: true }).catch(() => undefined);
4740
+ if (wasCurrent) {
4741
+ await fs.rm(authPath, { force: true }).catch(() => undefined);
4742
+ }
4743
+ }
4744
+ this.store.deleteCodexAuthCandidate(candidate.name);
4745
+ this.authRotationFailedTargets.delete(candidate.path);
4746
+ const snapshots = await this.readCodexAuthQuotaSnapshots();
4747
+ if (Object.prototype.hasOwnProperty.call(snapshots, candidate.name)) {
4748
+ delete snapshots[candidate.name];
4749
+ await this.writeCodexAuthQuotaSnapshots();
4750
+ }
4751
+ if (wasCurrent) {
4752
+ this.pendingTurnErrors.clear();
4753
+ this.attachedThreads.clear();
4754
+ await this.app.restart();
4755
+ }
4756
+ return wasCurrent;
4757
+ }
4644
4758
  async handleAccountCommand(scopeId, locale) {
4645
4759
  const account = await this.app.readAccount();
4646
4760
  const lines = [
@@ -5126,6 +5240,74 @@ export class BridgeSessionCore {
5126
5240
  await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
5127
5241
  }
5128
5242
  }
5243
+ async handleAuthRepairMenuCallback(event, localId, index, locale) {
5244
+ const record = this.pendingAuthChoiceLists.get(localId);
5245
+ if (!record) {
5246
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_expired'));
5247
+ return;
5248
+ }
5249
+ if (record.chatId !== event.scopeId || (record.messageId !== null && record.messageId !== event.messageId)) {
5250
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_mismatch'));
5251
+ return;
5252
+ }
5253
+ const candidate = record.candidates[index];
5254
+ if (!candidate) {
5255
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
5256
+ return;
5257
+ }
5258
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_repair_actions_short'));
5259
+ if (record.messageId !== null) {
5260
+ await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_repair_actions_message', { value: formatCodexAuthCandidateDisplayName(candidate.name) }), authRepairKeyboard(locale, record, index));
5261
+ }
5262
+ }
5263
+ async handleAuthRepairActionCallback(event, localId, action, index, locale) {
5264
+ const record = this.pendingAuthChoiceLists.get(localId);
5265
+ if (!record) {
5266
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_expired'));
5267
+ return;
5268
+ }
5269
+ if (record.chatId !== event.scopeId || (record.messageId !== null && record.messageId !== event.messageId)) {
5270
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_mismatch'));
5271
+ return;
5272
+ }
5273
+ const candidate = record.candidates[index];
5274
+ if (!candidate) {
5275
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
5276
+ return;
5277
+ }
5278
+ if (action === 'cancel') {
5279
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
5280
+ const state = await this.listCodexAuthState();
5281
+ record.candidates = state.candidates;
5282
+ record.createdAt = Date.now();
5283
+ clampCodexAuthListOffset(record);
5284
+ if (record.messageId !== null) {
5285
+ await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
5286
+ }
5287
+ return;
5288
+ }
5289
+ if (this.hasLocalBlockingActivity()) {
5290
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_reload_blocked_active'));
5291
+ return;
5292
+ }
5293
+ if (action === 'login') {
5294
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'login_device_started'));
5295
+ if (record.messageId !== null) {
5296
+ await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_repair_login_preparing', { value: candidate.name }), []);
5297
+ }
5298
+ await this.startAuthRepairLogin(event.scopeId, locale, candidate);
5299
+ return;
5300
+ }
5301
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_candidate_deleted_short'));
5302
+ const restarted = await this.deleteCodexAuthCandidate(candidate);
5303
+ const state = await this.listCodexAuthState();
5304
+ record.candidates = state.candidates;
5305
+ record.createdAt = Date.now();
5306
+ clampCodexAuthListOffset(record);
5307
+ if (record.messageId !== null) {
5308
+ await this.editMessage(event.scopeId, record.messageId, `${t(locale, 'auth_candidate_deleted', { value: candidate.name })}${restarted ? `\n${t(locale, 'auth_delete_current_restarted')}` : ''}\n\n${renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record)}`, authChoiceKeyboard(locale, record));
5309
+ }
5310
+ }
5129
5311
  async handleAuthToggleCallback(event, localId, index, locale) {
5130
5312
  const record = this.pendingAuthChoiceLists.get(localId);
5131
5313
  if (!record) {
@@ -5170,6 +5352,10 @@ export class BridgeSessionCore {
5170
5352
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
5171
5353
  return;
5172
5354
  }
5355
+ if (candidate.state === 'needs_repair') {
5356
+ await this.handleAuthRepairMenuCallback(event, localId, index, locale);
5357
+ return;
5358
+ }
5173
5359
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_recorded'));
5174
5360
  const switchLabels = await this.readCodexAuthSwitchLabels(candidate);
5175
5361
  const switchingMessage = t(locale, 'auth_switching', this.codexAuthSwitchParams(locale, switchLabels.fromLabel, switchLabels.toLabel));
@@ -5217,6 +5403,7 @@ export class BridgeSessionCore {
5217
5403
  }
5218
5404
  return false;
5219
5405
  }
5406
+ this.markCodexAuthCandidateNeedsRepair(current.name);
5220
5407
  }
5221
5408
  const selection = await this.selectNextCodexAuthCandidate(failedTargets);
5222
5409
  if (!selection) {
@@ -5291,7 +5478,9 @@ export class BridgeSessionCore {
5291
5478
  if (state.currentTargetPath) {
5292
5479
  failedTargets.add(state.currentTargetPath);
5293
5480
  }
5294
- const candidates = state.candidates.filter(candidate => !candidate.disabled && !failedTargets.has(candidate.path));
5481
+ const candidates = state.candidates.filter(candidate => !candidate.disabled
5482
+ && candidate.state !== 'needs_repair'
5483
+ && !failedTargets.has(candidate.path));
5295
5484
  if (candidates.length === 0) {
5296
5485
  return null;
5297
5486
  }
@@ -5300,7 +5489,10 @@ export class BridgeSessionCore {
5300
5489
  : -1;
5301
5490
  for (let offset = 1; offset <= state.candidates.length; offset += 1) {
5302
5491
  const candidate = state.candidates[(currentIndex + offset + state.candidates.length) % state.candidates.length];
5303
- if (candidate && !candidate.disabled && !failedTargets.has(candidate.path)) {
5492
+ if (candidate
5493
+ && !candidate.disabled
5494
+ && candidate.state !== 'needs_repair'
5495
+ && !failedTargets.has(candidate.path)) {
5304
5496
  return { candidate, fromLabel: state.currentLabel, toLabel: await authPathDisplayLabel(candidate.path) };
5305
5497
  }
5306
5498
  }
@@ -5308,7 +5500,7 @@ export class BridgeSessionCore {
5308
5500
  return candidate ? { candidate, fromLabel: state.currentLabel, toLabel: await authPathDisplayLabel(candidate.path) } : null;
5309
5501
  }
5310
5502
  async listCodexAuthState() {
5311
- const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.resolveAuthDir());
5503
+ const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.store.listCodexAuthCandidateStates(this.authRuntimeId()), this.resolveAuthDir());
5312
5504
  const snapshots = await this.readCodexAuthQuotaSnapshots();
5313
5505
  const candidateQuotaIdentities = await this.readCodexAuthCandidateQuotaIdentities(state.candidates);
5314
5506
  state.candidates.forEach((candidate) => {
@@ -5374,6 +5566,12 @@ export class BridgeSessionCore {
5374
5566
  return false;
5375
5567
  }
5376
5568
  }
5569
+ markCodexAuthCandidateNeedsRepair(candidateName) {
5570
+ this.store.setCodexAuthCandidateState(candidateName, 'needs_repair');
5571
+ }
5572
+ markCodexAuthCandidateActive(candidateName) {
5573
+ this.store.setCodexAuthCandidateState(candidateName, 'active');
5574
+ }
5377
5575
  async syncCodexAuthCandidate(candidateName) {
5378
5576
  try {
5379
5577
  await this.coordinator?.authCandidateUpdated?.(this.authRuntimeId(), candidateName);
@@ -5410,7 +5608,7 @@ export class BridgeSessionCore {
5410
5608
  try {
5411
5609
  for (const candidate of candidates) {
5412
5610
  const before = await readChatGptAuthMetadata(candidate.path);
5413
- if (!before || !chatGptAuthMetadataMatchesCandidateName(candidate.name, before)) {
5611
+ if (candidate.disabled || candidate.state === 'needs_repair' || !before || !chatGptAuthMetadataMatchesCandidateName(candidate.name, before)) {
5414
5612
  result.skipped.push(candidate.name);
5415
5613
  continue;
5416
5614
  }
@@ -5655,6 +5853,9 @@ export class BridgeSessionCore {
5655
5853
  if (!candidate) {
5656
5854
  return;
5657
5855
  }
5856
+ if (candidate.state === 'needs_repair') {
5857
+ return;
5858
+ }
5658
5859
  try {
5659
5860
  const metadata = await readChatGptAuthMetadata(candidate.path);
5660
5861
  if (!metadata || !chatGptAuthMetadataMatchesCandidateName(candidate.name, metadata)) {
@@ -6746,7 +6947,7 @@ export class BridgeSessionCore {
6746
6947
  return this.interruptOrphanWaitingUserInput(preview);
6747
6948
  }
6748
6949
  await this.stopWatchingScopeThread(preview.scopeId, preview.threadId);
6749
- const active = this.createActiveTurnState(preview.scopeId, target.chatId, target.chatType, target.topicId, preview.threadId, preview.turnId, preview.messageId, true);
6950
+ const active = this.createActiveTurnState(preview.scopeId, target.chatId, target.chatType, target.topicId, preview.threadId, preview.turnId, preview.messageId, preview.isObserved);
6750
6951
  this.setActiveTurn(preview.scopeId, preview.turnId, active);
6751
6952
  const watcher = {
6752
6953
  scopeId: preview.scopeId,
@@ -6773,6 +6974,7 @@ export class BridgeSessionCore {
6773
6974
  scopeId: preview.scopeId,
6774
6975
  threadId: preview.threadId,
6775
6976
  turnId: preview.turnId,
6977
+ isObserved: preview.isObserved,
6776
6978
  });
6777
6979
  return true;
6778
6980
  }
@@ -6953,6 +7155,7 @@ export class BridgeSessionCore {
6953
7155
  scopeId: active.scopeId,
6954
7156
  threadId: active.threadId,
6955
7157
  messageId,
7158
+ isObserved: active.isObserved,
6956
7159
  });
6957
7160
  }
6958
7161
  catch (error) {
@@ -8234,7 +8437,7 @@ function parseActiveTurnKey(key) {
8234
8437
  function codexAuthDir(explicitAuthDir = null) {
8235
8438
  return explicitAuthDir || process.env.CODEX_AUTH_DIR || path.join(os.homedir(), '.codex');
8236
8439
  }
8237
- async function listCodexAuthState(disabledNames = new Set(), explicitAuthDir = null) {
8440
+ async function listCodexAuthState(disabledNames = new Set(), candidateStates = new Map(), explicitAuthDir = null) {
8238
8441
  const authDir = codexAuthDir(explicitAuthDir);
8239
8442
  const authPath = path.join(authDir, 'auth.json');
8240
8443
  const currentTargetPath = await resolveCurrentAuthTarget(authDir, authPath);
@@ -8259,6 +8462,7 @@ async function listCodexAuthState(disabledNames = new Set(), explicitAuthDir = n
8259
8462
  path: candidatePath,
8260
8463
  isCurrent: currentTargetPath === candidatePath,
8261
8464
  disabled: disabledNames.has(entry.name),
8465
+ state: candidateStates.get(entry.name) ?? 'active',
8262
8466
  mtimeMs: stat.mtimeMs,
8263
8467
  credentialKind: 'invalid',
8264
8468
  credentialLastRefreshMs: null,
@@ -8370,7 +8574,7 @@ async function restoreCodexAuthTarget(authDir, authPath, targetPath, regularAuth
8370
8574
  });
8371
8575
  }
8372
8576
  async function switchCodexAuth(targetPath, explicitAuthDir = null) {
8373
- const state = await listCodexAuthState(new Set(), explicitAuthDir);
8577
+ const state = await listCodexAuthState(new Set(), new Map(), explicitAuthDir);
8374
8578
  const candidate = state.candidates.find(entry => entry.path === targetPath);
8375
8579
  if (!candidate) {
8376
8580
  throw new Error(`Auth candidate is no longer available: ${path.basename(targetPath)}`);
@@ -8446,7 +8650,7 @@ function filterCodexAuthCandidates(candidates, view) {
8446
8650
  .map((candidate, index) => ({ candidate, index }))
8447
8651
  .filter(({ candidate }) => !searchTerm || candidate.name.toLowerCase().includes(searchTerm))
8448
8652
  .filter(({ candidate }) => view.filter === 'all'
8449
- || (view.filter === 'enabled' && !candidate.disabled)
8653
+ || (view.filter === 'enabled' && !candidate.disabled && candidate.state !== 'needs_repair')
8450
8654
  || (view.filter === 'attention' && codexAuthCandidateNeedsAttention(candidate)));
8451
8655
  }
8452
8656
  function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopyPaste = false, view = null) {
@@ -8505,12 +8709,12 @@ function authChoiceKeyboard(locale, record) {
8505
8709
  const page = codexAuthListPage(record.candidates, record);
8506
8710
  const rows = page.visible.map(({ candidate, index }) => [
8507
8711
  {
8508
- text: clipButtonText(`${candidate.isCurrent ? '✅ ' : '🔐 '}${formatAuthQuotaButtonPrefix(candidate.quota)}|${formatCodexAuthCandidateDisplayName(candidate.name)}${candidate.disabled ? ' · off' : ''}`),
8509
- callback_data: `auth:${record.localId}:${index}`,
8712
+ text: clipButtonText(`${candidate.state === 'needs_repair' ? '? ' : candidate.isCurrent ? '✅ ' : '🔐 '}${formatAuthQuotaButtonPrefix(candidate.quota)}|${formatCodexAuthCandidateDisplayName(candidate.name)}${candidate.disabled ? ' · off' : ''}`),
8713
+ callback_data: candidate.state === 'needs_repair' ? `auth:${record.localId}:repair:${index}` : `auth:${record.localId}:${index}`,
8510
8714
  },
8511
8715
  {
8512
- text: t(locale, candidate.disabled ? 'button_auth_disable' : 'button_auth_enable'),
8513
- callback_data: `auth:${record.localId}:toggle:${index}`,
8716
+ text: candidate.state === 'needs_repair' ? '?' : t(locale, candidate.disabled ? 'button_auth_disable' : 'button_auth_enable'),
8717
+ callback_data: candidate.state === 'needs_repair' ? `auth:${record.localId}:repair:${index}` : `auth:${record.localId}:toggle:${index}`,
8514
8718
  },
8515
8719
  ]);
8516
8720
  const navigationRow = [];
@@ -8555,6 +8759,9 @@ function authFilterButton(locale, record, filter) {
8555
8759
  };
8556
8760
  }
8557
8761
  function codexAuthCandidateHealth(candidate) {
8762
+ if (candidate.state === 'needs_repair') {
8763
+ return 'needs_repair';
8764
+ }
8558
8765
  if (candidate.disabled) {
8559
8766
  return 'disabled';
8560
8767
  }
@@ -8589,6 +8796,7 @@ function codexAuthCandidateNeedsAttention(candidate) {
8589
8796
  || health === 'unknown'
8590
8797
  || health === 'low'
8591
8798
  || health === 'exhausted'
8799
+ || health === 'needs_repair'
8592
8800
  || health === 'invalid';
8593
8801
  }
8594
8802
  function formatCodexAuthCandidateStatus(locale, candidate) {
@@ -8613,6 +8821,13 @@ function authRefreshAllConfirmKeyboard(locale, record) {
8613
8821
  [{ text: t(locale, 'button_cancel'), callback_data: `auth:${record.localId}:refresh_all_cancel` }],
8614
8822
  ];
8615
8823
  }
8824
+ function authRepairKeyboard(locale, record, index) {
8825
+ return [
8826
+ [{ text: t(locale, 'button_auth_repair_login'), callback_data: `auth:${record.localId}:repair_login:${index}` }],
8827
+ [{ text: t(locale, 'button_auth_delete'), callback_data: `auth:${record.localId}:repair_delete:${index}` }],
8828
+ [{ text: t(locale, 'button_cancel'), callback_data: `auth:${record.localId}:repair_cancel:${index}` }],
8829
+ ];
8830
+ }
8616
8831
  function formatAuthRefreshAllResult(locale, result, mode = 'manual') {
8617
8832
  const lines = [t(locale, mode === 'proactive' ? 'auth_proactive_refresh_done' : 'auth_refresh_all_done', {
8618
8833
  refreshed: String(result.refreshed.length),
package/dist/i18n.d.ts CHANGED
@@ -162,6 +162,7 @@ declare const MESSAGES: {
162
162
  readonly auth_candidate_status_enabled: "enabled";
163
163
  readonly auth_candidate_status_disabled: "disabled";
164
164
  readonly auth_candidate_health_disabled: "disabled";
165
+ readonly auth_candidate_health_needs_repair: "needs login repair";
165
166
  readonly auth_candidate_health_ready: "ready";
166
167
  readonly auth_candidate_health_low: "low quota";
167
168
  readonly auth_candidate_health_exhausted: "quota exhausted";
@@ -174,6 +175,19 @@ declare const MESSAGES: {
174
175
  readonly auth_candidate_disabled: "Disabled auth candidate for auto-rotation: {value}";
175
176
  readonly auth_candidate_enabled_short: "Auth enabled";
176
177
  readonly auth_candidate_disabled_short: "Auth disabled";
178
+ readonly auth_candidate_needs_repair: "{value} needs login repair. Open /auth and use the ? action to repair or delete it.";
179
+ readonly auth_repair_actions_short: "Repair actions";
180
+ readonly auth_repair_actions_message: "{value} has been verified unusable and could not be recovered from sync. Choose an action.";
181
+ readonly auth_repair_login_preparing: "Preparing device login repair for {value}...";
182
+ readonly auth_repair_started: "Device login repair started for {value}.";
183
+ readonly auth_repair_done: "Auth candidate repaired: {value}";
184
+ readonly auth_repair_failed: "Auth repair failed for {value}: {error}";
185
+ readonly auth_repair_missing_file: "Auth repair finished but {value} was not written.";
186
+ readonly auth_repair_identity_mismatch: "Auth repair finished, but the logged-in account does not match {value}.";
187
+ readonly auth_repair_reverted: "Restored previous auth. The candidate still needs repair.";
188
+ readonly auth_candidate_deleted: "Deleted auth candidate: {value}";
189
+ readonly auth_candidate_deleted_short: "Auth deleted";
190
+ readonly auth_delete_current_restarted: "The deleted candidate was current, so Codex app-server was restarted without it.";
177
191
  readonly auth_no_candidates: "No auth candidates found. Expected files like auth.json_personal in the auth dir.";
178
192
  readonly auth_choice_expired: "This auth list is no longer active";
179
193
  readonly auth_choice_mismatch: "Auth choice does not match this message";
@@ -239,6 +253,8 @@ declare const MESSAGES: {
239
253
  readonly button_auth_reload: "🔄 Reload auth";
240
254
  readonly button_auth_safe_sync: "🧷 Safe sync";
241
255
  readonly button_auth_refresh_all_confirm: "⚠️ Accept risk & refresh";
256
+ readonly button_auth_repair_login: "🔑 Login repair";
257
+ readonly button_auth_delete: "🗑️ Delete";
242
258
  readonly button_auth_enable: "✅";
243
259
  readonly button_auth_disable: "⏸️";
244
260
  readonly button_auth_filter_all: "All";
@@ -818,6 +834,7 @@ declare const MESSAGES: {
818
834
  readonly auth_candidate_status_enabled: "启用";
819
835
  readonly auth_candidate_status_disabled: "禁用";
820
836
  readonly auth_candidate_health_disabled: "已禁用";
837
+ readonly auth_candidate_health_needs_repair: "需要登录修复";
821
838
  readonly auth_candidate_health_ready: "正常";
822
839
  readonly auth_candidate_health_low: "额度偏低";
823
840
  readonly auth_candidate_health_exhausted: "额度耗尽";
@@ -830,6 +847,19 @@ declare const MESSAGES: {
830
847
  readonly auth_candidate_disabled: "已禁用 auth 候选自动轮换:{value}";
831
848
  readonly auth_candidate_enabled_short: "auth 已启用";
832
849
  readonly auth_candidate_disabled_short: "auth 已禁用";
850
+ readonly auth_candidate_needs_repair: "{value} 需要登录修复。打开 /auth 后使用 ? 操作修复或删除。";
851
+ readonly auth_repair_actions_short: "修复操作";
852
+ readonly auth_repair_actions_message: "{value} 已确认不可用,并且同步恢复失败。请选择处理方式。";
853
+ readonly auth_repair_login_preparing: "正在准备为 {value} 进行设备登录修复...";
854
+ readonly auth_repair_started: "已开始为 {value} 进行设备登录修复。";
855
+ readonly auth_repair_done: "auth 候选已修复:{value}";
856
+ readonly auth_repair_failed: "{value} 修复失败:{error}";
857
+ readonly auth_repair_missing_file: "修复流程已结束,但没有写入 {value}。";
858
+ readonly auth_repair_identity_mismatch: "修复流程已结束,但登录账号与 {value} 不匹配。";
859
+ readonly auth_repair_reverted: "已恢复之前的 auth。该候选仍需要修复。";
860
+ readonly auth_candidate_deleted: "已删除 auth 候选:{value}";
861
+ readonly auth_candidate_deleted_short: "auth 已删除";
862
+ readonly auth_delete_current_restarted: "被删除的候选是当前 auth,已在删除后重启 Codex app-server。";
833
863
  readonly auth_no_candidates: "没有找到 auth 候选文件。请在 auth 目录中放置类似 auth.json_personal 的文件。";
834
864
  readonly auth_choice_expired: "这个 auth 列表已经不再有效";
835
865
  readonly auth_choice_mismatch: "这个 auth 选择按钮不属于当前消息";
@@ -895,6 +925,8 @@ declare const MESSAGES: {
895
925
  readonly button_auth_reload: "🔄 重载 auth";
896
926
  readonly button_auth_safe_sync: "🧷 安全同步";
897
927
  readonly button_auth_refresh_all_confirm: "⚠️ 接受风险并刷新";
928
+ readonly button_auth_repair_login: "🔑 登录修复";
929
+ readonly button_auth_delete: "🗑️ 删除";
898
930
  readonly button_auth_enable: "✅";
899
931
  readonly button_auth_disable: "⏸️";
900
932
  readonly button_auth_filter_all: "全部";
package/dist/i18n.js CHANGED
@@ -160,6 +160,7 @@ const MESSAGES = {
160
160
  auth_candidate_status_enabled: 'enabled',
161
161
  auth_candidate_status_disabled: 'disabled',
162
162
  auth_candidate_health_disabled: 'disabled',
163
+ auth_candidate_health_needs_repair: 'needs login repair',
163
164
  auth_candidate_health_ready: 'ready',
164
165
  auth_candidate_health_low: 'low quota',
165
166
  auth_candidate_health_exhausted: 'quota exhausted',
@@ -172,6 +173,19 @@ const MESSAGES = {
172
173
  auth_candidate_disabled: 'Disabled auth candidate for auto-rotation: {value}',
173
174
  auth_candidate_enabled_short: 'Auth enabled',
174
175
  auth_candidate_disabled_short: 'Auth disabled',
176
+ auth_candidate_needs_repair: '{value} needs login repair. Open /auth and use the ? action to repair or delete it.',
177
+ auth_repair_actions_short: 'Repair actions',
178
+ auth_repair_actions_message: '{value} has been verified unusable and could not be recovered from sync. Choose an action.',
179
+ auth_repair_login_preparing: 'Preparing device login repair for {value}...',
180
+ auth_repair_started: 'Device login repair started for {value}.',
181
+ auth_repair_done: 'Auth candidate repaired: {value}',
182
+ auth_repair_failed: 'Auth repair failed for {value}: {error}',
183
+ auth_repair_missing_file: 'Auth repair finished but {value} was not written.',
184
+ auth_repair_identity_mismatch: 'Auth repair finished, but the logged-in account does not match {value}.',
185
+ auth_repair_reverted: 'Restored previous auth. The candidate still needs repair.',
186
+ auth_candidate_deleted: 'Deleted auth candidate: {value}',
187
+ auth_candidate_deleted_short: 'Auth deleted',
188
+ auth_delete_current_restarted: 'The deleted candidate was current, so Codex app-server was restarted without it.',
175
189
  auth_no_candidates: 'No auth candidates found. Expected files like auth.json_personal in the auth dir.',
176
190
  auth_choice_expired: 'This auth list is no longer active',
177
191
  auth_choice_mismatch: 'Auth choice does not match this message',
@@ -237,6 +251,8 @@ const MESSAGES = {
237
251
  button_auth_reload: '🔄 Reload auth',
238
252
  button_auth_safe_sync: '🧷 Safe sync',
239
253
  button_auth_refresh_all_confirm: '⚠️ Accept risk & refresh',
254
+ button_auth_repair_login: '🔑 Login repair',
255
+ button_auth_delete: '🗑️ Delete',
240
256
  button_auth_enable: '✅',
241
257
  button_auth_disable: '⏸️',
242
258
  button_auth_filter_all: 'All',
@@ -816,6 +832,7 @@ const MESSAGES = {
816
832
  auth_candidate_status_enabled: '启用',
817
833
  auth_candidate_status_disabled: '禁用',
818
834
  auth_candidate_health_disabled: '已禁用',
835
+ auth_candidate_health_needs_repair: '需要登录修复',
819
836
  auth_candidate_health_ready: '正常',
820
837
  auth_candidate_health_low: '额度偏低',
821
838
  auth_candidate_health_exhausted: '额度耗尽',
@@ -828,6 +845,19 @@ const MESSAGES = {
828
845
  auth_candidate_disabled: '已禁用 auth 候选自动轮换:{value}',
829
846
  auth_candidate_enabled_short: 'auth 已启用',
830
847
  auth_candidate_disabled_short: 'auth 已禁用',
848
+ auth_candidate_needs_repair: '{value} 需要登录修复。打开 /auth 后使用 ? 操作修复或删除。',
849
+ auth_repair_actions_short: '修复操作',
850
+ auth_repair_actions_message: '{value} 已确认不可用,并且同步恢复失败。请选择处理方式。',
851
+ auth_repair_login_preparing: '正在准备为 {value} 进行设备登录修复...',
852
+ auth_repair_started: '已开始为 {value} 进行设备登录修复。',
853
+ auth_repair_done: 'auth 候选已修复:{value}',
854
+ auth_repair_failed: '{value} 修复失败:{error}',
855
+ auth_repair_missing_file: '修复流程已结束,但没有写入 {value}。',
856
+ auth_repair_identity_mismatch: '修复流程已结束,但登录账号与 {value} 不匹配。',
857
+ auth_repair_reverted: '已恢复之前的 auth。该候选仍需要修复。',
858
+ auth_candidate_deleted: '已删除 auth 候选:{value}',
859
+ auth_candidate_deleted_short: 'auth 已删除',
860
+ auth_delete_current_restarted: '被删除的候选是当前 auth,已在删除后重启 Codex app-server。',
831
861
  auth_no_candidates: '没有找到 auth 候选文件。请在 auth 目录中放置类似 auth.json_personal 的文件。',
832
862
  auth_choice_expired: '这个 auth 列表已经不再有效',
833
863
  auth_choice_mismatch: '这个 auth 选择按钮不属于当前消息',
@@ -893,6 +923,8 @@ const MESSAGES = {
893
923
  button_auth_reload: '🔄 重载 auth',
894
924
  button_auth_safe_sync: '🧷 安全同步',
895
925
  button_auth_refresh_all_confirm: '⚠️ 接受风险并刷新',
926
+ button_auth_repair_login: '🔑 登录修复',
927
+ button_auth_delete: '🗑️ 删除',
896
928
  button_auth_enable: '✅',
897
929
  button_auth_disable: '⏸️',
898
930
  button_auth_filter_all: '全部',
package/dist/main.js CHANGED
@@ -324,6 +324,7 @@ async function runServeCli() {
324
324
  canSelfUpdate: () => authSyncLocalIdle()
325
325
  && (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
326
326
  authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
327
+ authCandidateDeleted: (_runtimeId, candidateName) => mirror.deleteCandidate(candidateName).then(() => undefined),
327
328
  recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
328
329
  const local = await mirror.recoverRuntimeCandidate(runtimeId, candidateName);
329
330
  if (local)
@@ -492,6 +493,7 @@ async function runServeCli() {
492
493
  canSelfUpdate: () => singleAuthSyncLocalIdle()
493
494
  && (singleAuthSync ? singleAuthSync.isIdle() : singleLocalAuthRefreshLease.isIdle()),
494
495
  authCandidateUpdated: (runtimeId, candidateName) => singleMirror?.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined) ?? Promise.resolve(),
496
+ authCandidateDeleted: (_runtimeId, candidateName) => singleMirror?.deleteCandidate(candidateName).then(() => undefined) ?? Promise.resolve(),
495
497
  recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
496
498
  const local = await singleMirror?.recoverRuntimeCandidate(runtimeId, candidateName) ?? null;
497
499
  if (local)
@@ -4,6 +4,7 @@ export interface ActiveTurnPreviewRecord {
4
4
  scopeId: string;
5
5
  threadId: string;
6
6
  messageId: number;
7
+ isObserved: boolean;
7
8
  createdAt: number;
8
9
  updatedAt: number;
9
10
  }
@@ -37,6 +38,7 @@ export interface CodexAuthQuotaSnapshotRecord {
37
38
  secondaryRemainingPercent: number | null;
38
39
  updatedAt: number;
39
40
  }
41
+ export type CodexAuthCandidateState = 'active' | 'needs_repair';
40
42
  export declare class BridgeStore {
41
43
  private db;
42
44
  constructor(dbPath: string);
@@ -73,7 +75,9 @@ export declare class BridgeStore {
73
75
  getPendingApprovalByServerRequestId(serverRequestId: string): PendingApprovalRecord | null;
74
76
  markApprovalResolved(localId: string): void;
75
77
  countPendingApprovals(): number;
76
- saveActiveTurnPreview(record: Pick<ActiveTurnPreviewRecord, 'turnId' | 'scopeId' | 'threadId' | 'messageId'>): void;
78
+ saveActiveTurnPreview(record: Pick<ActiveTurnPreviewRecord, 'turnId' | 'scopeId' | 'threadId' | 'messageId'> & {
79
+ isObserved?: boolean;
80
+ }): void;
77
81
  listActiveTurnPreviews(): ActiveTurnPreviewRecord[];
78
82
  removeActiveTurnPreview(turnId: string): void;
79
83
  removeActiveTurnPreviewByMessage(scopeId: string, messageId: number): void;
@@ -117,7 +121,10 @@ export declare class BridgeStore {
117
121
  getWeixinContextToken(scopeId: string): string | null;
118
122
  setWeixinContextToken(scopeId: string, contextToken: string): void;
119
123
  listDisabledCodexAuthCandidateNames(runtimeId?: string): Set<string>;
124
+ listCodexAuthCandidateStates(runtimeId?: string): Map<string, CodexAuthCandidateState>;
120
125
  setCodexAuthCandidateDisabled(name: string, disabled: boolean, runtimeId?: string): void;
126
+ setCodexAuthCandidateState(name: string, state: CodexAuthCandidateState, runtimeId?: string): void;
127
+ deleteCodexAuthCandidate(name: string): void;
121
128
  setCodexAuthQuotaSnapshot(runtimeId: string, candidateName: string, accountId: string, quotaIdentityId: string, snapshot: Pick<CodexAuthQuotaSnapshotRecord, 'capturedAtMs' | 'planType' | 'primaryWindowDurationMins' | 'primaryRemainingPercent' | 'secondaryWindowDurationMins' | 'secondaryRemainingPercent'>): void;
122
129
  listCodexAuthQuotaSnapshots(quotaIdentityIds: string[]): CodexAuthQuotaSnapshotRecord[];
123
130
  private ensureColumn;
@@ -70,6 +70,7 @@ export class BridgeStore {
70
70
  scope_id TEXT NOT NULL,
71
71
  thread_id TEXT NOT NULL,
72
72
  message_id INTEGER NOT NULL,
73
+ is_observed INTEGER NOT NULL DEFAULT 0,
73
74
  created_at INTEGER NOT NULL,
74
75
  updated_at INTEGER NOT NULL
75
76
  );
@@ -169,12 +170,14 @@ export class BridgeStore {
169
170
  CREATE TABLE IF NOT EXISTS codex_auth_candidates (
170
171
  name TEXT PRIMARY KEY,
171
172
  disabled INTEGER NOT NULL DEFAULT 0,
173
+ state TEXT NOT NULL DEFAULT 'active',
172
174
  updated_at INTEGER NOT NULL
173
175
  );
174
176
  CREATE TABLE IF NOT EXISTS codex_auth_candidate_runtime (
175
177
  runtime_id TEXT NOT NULL,
176
178
  name TEXT NOT NULL,
177
179
  disabled INTEGER NOT NULL DEFAULT 0,
180
+ state TEXT NOT NULL DEFAULT 'active',
178
181
  updated_at INTEGER NOT NULL,
179
182
  PRIMARY KEY (runtime_id, name)
180
183
  );
@@ -207,6 +210,9 @@ export class BridgeStore {
207
210
  this.ensureColumn('pending_approvals', 'payload_json', 'TEXT');
208
211
  this.ensureColumn('pending_user_inputs', 'status', "TEXT NOT NULL DEFAULT 'pending'");
209
212
  this.ensureColumn('pending_user_inputs', 'submitted_at', 'INTEGER');
213
+ this.ensureColumn('active_turn_previews', 'is_observed', 'INTEGER NOT NULL DEFAULT 0');
214
+ this.ensureColumn('codex_auth_candidates', 'state', "TEXT NOT NULL DEFAULT 'active'");
215
+ this.ensureColumn('codex_auth_candidate_runtime', 'state', "TEXT NOT NULL DEFAULT 'active'");
210
216
  this.ensureColumn('codex_auth_quota_snapshots', 'plan_type', 'TEXT');
211
217
  this.ensureColumn('codex_auth_quota_snapshots', 'primary_window_duration_mins', 'REAL');
212
218
  this.ensureColumn('codex_auth_quota_snapshots', 'secondary_window_duration_mins', 'REAL');
@@ -410,22 +416,23 @@ export class BridgeStore {
410
416
  saveActiveTurnPreview(record) {
411
417
  const now = Date.now();
412
418
  this.db.prepare('DELETE FROM active_turn_previews WHERE turn_id = ? OR scope_id = ?').run(record.turnId, record.scopeId);
413
- this.db.prepare(`
414
- INSERT INTO active_turn_previews (turn_id, scope_id, thread_id, message_id, created_at, updated_at)
415
- VALUES (?, ?, ?, ?, ?, ?)
416
- `).run(record.turnId, record.scopeId, record.threadId, record.messageId, now, now);
419
+ this.db.prepare(`
420
+ INSERT INTO active_turn_previews (turn_id, scope_id, thread_id, message_id, is_observed, created_at, updated_at)
421
+ VALUES (?, ?, ?, ?, ?, ?, ?)
422
+ `).run(record.turnId, record.scopeId, record.threadId, record.messageId, record.isObserved ? 1 : 0, now, now);
417
423
  }
418
424
  listActiveTurnPreviews() {
419
- const rows = this.db.prepare(`
420
- SELECT turn_id, scope_id, thread_id, message_id, created_at, updated_at
421
- FROM active_turn_previews
422
- ORDER BY created_at ASC
425
+ const rows = this.db.prepare(`
426
+ SELECT turn_id, scope_id, thread_id, message_id, is_observed, created_at, updated_at
427
+ FROM active_turn_previews
428
+ ORDER BY created_at ASC
423
429
  `).all();
424
430
  return rows.map((row) => ({
425
431
  turnId: String(row.turn_id),
426
432
  scopeId: String(row.scope_id),
427
433
  threadId: String(row.thread_id),
428
434
  messageId: Number(row.message_id),
435
+ isObserved: Boolean(row.is_observed),
429
436
  createdAt: Number(row.created_at),
430
437
  updatedAt: Number(row.updated_at),
431
438
  }));
@@ -835,21 +842,60 @@ export class BridgeStore {
835
842
  const rows = this.db.prepare('SELECT name FROM codex_auth_candidates WHERE disabled = 1').all();
836
843
  return new Set(rows.map(row => String(row.name)));
837
844
  }
845
+ listCodexAuthCandidateStates(runtimeId = 'default') {
846
+ const states = new Map();
847
+ const globalRows = this.db.prepare('SELECT name, state FROM codex_auth_candidates').all();
848
+ for (const row of globalRows) {
849
+ states.set(String(row.name), normalizeCodexAuthCandidateState(row.state));
850
+ }
851
+ if (runtimeId !== 'default') {
852
+ const runtimeRows = this.db.prepare('SELECT name, state FROM codex_auth_candidate_runtime WHERE runtime_id = ?').all(runtimeId);
853
+ for (const row of runtimeRows) {
854
+ states.set(String(row.name), normalizeCodexAuthCandidateState(row.state));
855
+ }
856
+ }
857
+ return states;
858
+ }
838
859
  setCodexAuthCandidateDisabled(name, disabled, runtimeId = 'default') {
839
860
  if (runtimeId !== 'default') {
840
861
  this.db.prepare(`
841
- INSERT INTO codex_auth_candidate_runtime (runtime_id, name, disabled, updated_at)
842
- VALUES (?, ?, ?, ?)
862
+ INSERT INTO codex_auth_candidate_runtime (runtime_id, name, disabled, state, updated_at)
863
+ VALUES (?, ?, ?, 'active', ?)
843
864
  ON CONFLICT(runtime_id, name) DO UPDATE SET disabled = excluded.disabled, updated_at = excluded.updated_at
844
865
  `).run(runtimeId, name, disabled ? 1 : 0, Date.now());
845
866
  return;
846
867
  }
847
868
  this.db.prepare(`
848
- INSERT INTO codex_auth_candidates (name, disabled, updated_at)
849
- VALUES (?, ?, ?)
869
+ INSERT INTO codex_auth_candidates (name, disabled, state, updated_at)
870
+ VALUES (?, ?, 'active', ?)
850
871
  ON CONFLICT(name) DO UPDATE SET disabled = excluded.disabled, updated_at = excluded.updated_at
851
872
  `).run(name, disabled ? 1 : 0, Date.now());
852
873
  }
874
+ setCodexAuthCandidateState(name, state, runtimeId = 'default') {
875
+ if (runtimeId !== 'default') {
876
+ this.db.prepare(`
877
+ INSERT INTO codex_auth_candidate_runtime (runtime_id, name, disabled, state, updated_at)
878
+ VALUES (?, ?, 0, ?, ?)
879
+ ON CONFLICT(runtime_id, name) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at
880
+ `).run(runtimeId, name, state, Date.now());
881
+ return;
882
+ }
883
+ this.db.prepare(`
884
+ INSERT INTO codex_auth_candidates (name, disabled, state, updated_at)
885
+ VALUES (?, 0, ?, ?)
886
+ ON CONFLICT(name) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at
887
+ `).run(name, state, Date.now());
888
+ this.db.prepare(`
889
+ UPDATE codex_auth_candidate_runtime
890
+ SET state = ?, updated_at = ?
891
+ WHERE name = ?
892
+ `).run(state, Date.now(), name);
893
+ }
894
+ deleteCodexAuthCandidate(name) {
895
+ this.db.prepare('DELETE FROM codex_auth_candidates WHERE name = ?').run(name);
896
+ this.db.prepare('DELETE FROM codex_auth_candidate_runtime WHERE name = ?').run(name);
897
+ this.db.prepare('DELETE FROM codex_auth_quota_snapshots WHERE candidate_name = ?').run(name);
898
+ }
853
899
  setCodexAuthQuotaSnapshot(runtimeId, candidateName, accountId, quotaIdentityId, snapshot) {
854
900
  this.db.prepare(`
855
901
  INSERT INTO codex_auth_quota_snapshots (
@@ -932,6 +978,9 @@ function nullableNumber(value) {
932
978
  function nullableString(value) {
933
979
  return typeof value === 'string' && value.trim() ? value : null;
934
980
  }
981
+ function normalizeCodexAuthCandidateState(value) {
982
+ return value === 'needs_repair' ? 'needs_repair' : 'active';
983
+ }
935
984
  function normalizeCollaborationMode(value) {
936
985
  return value === 'default' || value === 'plan' ? value : null;
937
986
  }
@@ -415,7 +415,9 @@ Quota remaining: window:percent|auth
415
415
  [🔄 Reload auth]
416
416
  ```
417
417
 
418
- The right-side `✅` / `⏸️` button controls whether the candidate participates in auto-rotation. Tapping it toggles enabled/disabled, and the refreshed list shows the new state. Tapping a candidate switches auth, restarts that runtime, and refreshes the same panel with its buttons intact so you can switch again immediately. `--` means no quota snapshot has been observed for that candidate yet. Health summaries distinguish ready, low quota, quota exhausted, quota unknown, not recently refreshed, API key, and invalid auth file states.
418
+ The right-side `✅` / `⏸️` button controls whether the candidate participates in auto-rotation. Tapping it toggles enabled/disabled, and the refreshed list shows the new state. Tapping a candidate switches auth, restarts that runtime, and refreshes the same panel with its buttons intact so you can switch again immediately. `--` means no quota snapshot has been observed for that candidate yet. Health summaries distinguish ready, low quota, quota exhausted, quota unknown, not recently refreshed, API key, invalid auth file, and needs login repair states.
419
+
420
+ When an auth candidate has already failed while in use and FoxClaw cannot recover a newer same-account credential from local mirror or cross-node sync, it is marked as `needs login repair`. These candidates are skipped by auto-rotation and proactive refresh, and are hidden from the `Enabled` filter. Their row shows a `?` action. Tapping it opens two choices: Login repair starts device-code login with that candidate selected; Delete removes the candidate from canonical storage and all local bot runtimes, and clears cached quota for it.
419
421
 
420
422
  `/auth refresh all` is a command-only maintenance action because ChatGPT refresh tokens are rotated. It is allowed only when every Telegram runtime, the Weixin runtime, approvals, inputs, logins, and auth mirroring are idle. The command first shows a risk confirmation: if OpenAI/Codex consumes an old refresh token but the new token cannot be saved because of network, process, or disk failure, that candidate may require device login or phone verification again. After confirmation, FoxClaw visits every ChatGPT candidate, asks Codex to force-refresh tokens with `account/read refreshToken=true`, verifies the result through the usage endpoint, mirrors successful candidates, restores the original current auth, and shows a summary.
421
423
 
@@ -415,7 +415,9 @@ Candidates: 2
415
415
  [🔄 Reload auth]
416
416
  ```
417
417
 
418
- 右侧 `✅` / `⏸️` 表示当前是否参与自动轮转。点一下会切换启用/禁用,列表刷新后图标会随状态变化。点击候选会切换 auth、重启对应 runtime,并在原消息上刷新面板且保留按钮,因此可以立即连续切换。`--` 表示该候选还没有额度历史快照。健康摘要会区分正常、额度偏低、额度耗尽、额度未知、长期未刷新、API key 和无效 auth 文件。
418
+ 右侧 `✅` / `⏸️` 表示当前是否参与自动轮转。点一下会切换启用/禁用,列表刷新后图标会随状态变化。点击候选会切换 auth、重启对应 runtime,并在原消息上刷新面板且保留按钮,因此可以立即连续切换。`--` 表示该候选还没有额度历史快照。健康摘要会区分正常、额度偏低、额度耗尽、额度未知、长期未刷新、API key、无效 auth 文件和需要登录修复。
419
+
420
+ 当某个候选在实际使用中已经失败,并且 FoxClaw 无法从本机 mirror 或跨节点同步恢复同账号较新凭据时,会标为“需要登录修复”。这类候选不会参与自动轮转和后台主动刷新,也不会出现在“已启用”筛选里。它的按钮会显示 `?`。点击后有两个选择:`登录修复` 会在选中该候选的状态下启动设备码登录;`删除` 会从 canonical 和所有本机 bot runtime 中删除这个候选,并清理它的额度缓存。
419
421
 
420
422
  `/auth refresh all` 是仅命令入口的维护操作,因为 ChatGPT refresh token 会被轮换。只有所有 Telegram runtime、微信 runtime、审批、待输入、登录流程和 auth 镜像写入都空闲时才允许执行。命令会先显示风险确认:如果 OpenAI/Codex 已经消费旧 refresh token,但因为网络、进程或磁盘故障导致新 token 没能成功保存,该候选可能需要重新设备登录,甚至重新手机号验证。确认后,它会逐个访问 ChatGPT 候选,让 Codex 通过 `account/read refreshToken=true` 强制刷新 token,再用 usage 接口验证,成功后镜像到其他 bot home,最后恢复原本的当前 auth 并显示摘要。
421
423
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.14",
3
+ "version": "0.5.16",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",