@foxden-app/foxclaw 0.5.20 → 0.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -253,7 +253,9 @@ async function runServeCli() {
253
253
  label: runtime.bot.username ? `@${runtime.bot.username}` : runtime.id,
254
254
  authDir: runtime.authDir,
255
255
  validate: async (context) => validateRefreshedAuthCandidate(runtime, context.candidateName),
256
- notify: createAuthMirrorNotifier(store, runtime.id, runtime.bot, authNotificationAggregator),
256
+ notify: createAuthMirrorNotifier(store, runtime.id, runtime.bot, authNotificationAggregator, {
257
+ quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
258
+ }),
257
259
  })), logger, path.join(APP_HOME, 'runtime', 'auth-mirror.json'), {
258
260
  onSynced: async (event) => {
259
261
  await authSync?.publishCandidate(event.record.candidateName);
@@ -324,7 +326,10 @@ async function runServeCli() {
324
326
  canSelfUpdate: () => authSyncLocalIdle()
325
327
  && (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
326
328
  authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
327
- authCandidateDeleted: (_runtimeId, candidateName) => mirror.deleteCandidate(candidateName).then(() => undefined),
329
+ authCandidateDeleted: async (_runtimeId, candidateName, reason = null) => {
330
+ await mirror.deleteCandidate(candidateName);
331
+ await authSync?.publishCandidateDeletion(candidateName, reason);
332
+ },
328
333
  recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
329
334
  const local = await mirror.recoverRuntimeCandidate(runtimeId, candidateName);
330
335
  if (local)
@@ -424,8 +429,22 @@ async function runServeCli() {
424
429
  return runtime.core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
425
430
  },
426
431
  importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
432
+ deleteLocalCandidate: async (candidateName, source) => {
433
+ if (!authSyncLocalIdle()) {
434
+ return { ok: false, deleted: false, reason: 'runtime is not idle' };
435
+ }
436
+ await mirror.deleteCandidate(candidateName);
437
+ store.deleteCodexAuthCandidate(candidateName);
438
+ await Promise.all(runtimes.map(runtime => runtime.core.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null)));
439
+ if (activeWeixinCore) {
440
+ await activeWeixinCore.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null);
441
+ }
442
+ return { ok: true, deleted: true, reason: source.reason ?? null };
443
+ },
427
444
  isIdle: authSyncLocalIdle,
428
- notify: createAuthSyncNotifier(store, authSyncTransportBot.bot, authNotificationAggregator),
445
+ notify: createAuthSyncNotifier(store, authSyncTransportBot.bot, authNotificationAggregator, {
446
+ quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
447
+ }),
429
448
  });
430
449
  await authSync.initialize();
431
450
  activeAuthSync = authSync;
@@ -494,7 +513,10 @@ async function runServeCli() {
494
513
  canSelfUpdate: () => singleAuthSyncLocalIdle()
495
514
  && (singleAuthSync ? singleAuthSync.isIdle() : singleLocalAuthRefreshLease.isIdle()),
496
515
  authCandidateUpdated: (runtimeId, candidateName) => singleMirror?.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined) ?? Promise.resolve(),
497
- authCandidateDeleted: (_runtimeId, candidateName) => singleMirror?.deleteCandidate(candidateName).then(() => undefined) ?? Promise.resolve(),
516
+ authCandidateDeleted: async (_runtimeId, candidateName, reason = null) => {
517
+ await singleMirror?.deleteCandidate(candidateName);
518
+ await singleAuthSync?.publishCandidateDeletion(candidateName, reason);
519
+ },
498
520
  recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
499
521
  const local = await singleMirror?.recoverRuntimeCandidate(runtimeId, candidateName) ?? null;
500
522
  if (local)
@@ -570,8 +592,19 @@ async function runServeCli() {
570
592
  return core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
571
593
  },
572
594
  importCandidate: (candidateName, raw, source) => singleMirror.importExternalCandidate(candidateName, raw, source),
595
+ deleteLocalCandidate: async (candidateName, source) => {
596
+ if (!singleAuthSyncLocalIdle()) {
597
+ return { ok: false, deleted: false, reason: 'runtime is not idle' };
598
+ }
599
+ await singleMirror.deleteCandidate(candidateName);
600
+ store.deleteCodexAuthCandidate(candidateName);
601
+ await core.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null);
602
+ return { ok: true, deleted: true, reason: source.reason ?? null };
603
+ },
573
604
  isIdle: singleAuthSyncLocalIdle,
574
- notify: createAuthSyncNotifier(store, bot, authNotificationAggregator),
605
+ notify: createAuthSyncNotifier(store, bot, authNotificationAggregator, {
606
+ quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
607
+ }),
575
608
  });
576
609
  await singleAuthSync.initialize();
577
610
  activeAuthSync = singleAuthSync;
@@ -641,8 +674,10 @@ async function runServeCli() {
641
674
  throw error;
642
675
  }
643
676
  }
644
- function createAuthMirrorNotifier(store, botId, bot, aggregator) {
677
+ function createAuthMirrorNotifier(store, botId, bot, aggregator, options = {}) {
645
678
  return async (event) => {
679
+ if (options.quietAuthPoolMode?.())
680
+ return;
646
681
  const privateScope = store.getTelegramPrivateScope(botId);
647
682
  if (!privateScope)
648
683
  return;
@@ -654,7 +689,27 @@ function createAuthMirrorNotifier(store, botId, bot, aggregator) {
654
689
  }, event);
655
690
  };
656
691
  }
657
- function createAuthSyncNotifier(store, bot, aggregator) {
692
+ function createAuthSyncNotifier(store, bot, aggregator, options = {}) {
693
+ const poolSummaryQueues = new Map();
694
+ const enqueuePoolSummary = (destination) => {
695
+ const existing = poolSummaryQueues.get(destination.key);
696
+ if (existing) {
697
+ clearTimeout(existing.timer);
698
+ }
699
+ const timer = setTimeout(() => {
700
+ const queued = poolSummaryQueues.get(destination.key);
701
+ if (!queued)
702
+ return;
703
+ poolSummaryQueues.delete(destination.key);
704
+ void queued.sendMessage(formatAuthPoolSummary(queued.locale, store.getCodexAuthPoolStats())).catch(() => undefined);
705
+ }, 2_000);
706
+ timer.unref();
707
+ poolSummaryQueues.set(destination.key, {
708
+ locale: destination.locale,
709
+ sendMessage: destination.sendMessage,
710
+ timer,
711
+ });
712
+ };
658
713
  return async (event) => {
659
714
  if (!bot.identity)
660
715
  return;
@@ -667,6 +722,12 @@ function createAuthSyncNotifier(store, bot, aggregator) {
667
722
  locale,
668
723
  sendMessage: (text) => bot.sendMessage(privateScope.chatId, text),
669
724
  };
725
+ if (options.quietAuthPoolMode?.() && isQuietAuthPoolNotification(event)) {
726
+ if (shouldSendQuietAuthPoolSummary(event)) {
727
+ enqueuePoolSummary(destination);
728
+ }
729
+ return;
730
+ }
670
731
  if (aggregator.enqueueAuthSync(destination, event)) {
671
732
  return;
672
733
  }
@@ -676,6 +737,45 @@ function createAuthSyncNotifier(store, bot, aggregator) {
676
737
  function authNotificationDestinationKey(botId, chatId) {
677
738
  return `${botId}:${chatId}`;
678
739
  }
740
+ function formatAuthPoolSummary(locale, stats) {
741
+ return locale === 'zh'
742
+ ? `auth 池:历史 ${stats.totalSeen},存活 ${stats.alive},因失效剔除 ${stats.deletedInvalid}。`
743
+ : `Auth pool: total seen ${stats.totalSeen}, alive ${stats.alive}, invalid-deleted ${stats.deletedInvalid}.`;
744
+ }
745
+ function isQuietAuthPoolNotification(event) {
746
+ switch (event.kind) {
747
+ case 'candidate_publish_started':
748
+ case 'candidate_publish_completed':
749
+ case 'push_all_started':
750
+ case 'push_all_completed':
751
+ case 'remote_bundle_received':
752
+ case 'remote_import_imported':
753
+ case 'remote_import_skipped':
754
+ case 'candidate_delete_sent':
755
+ case 'remote_delete_received':
756
+ case 'remote_delete_deleted':
757
+ case 'remote_delete_skipped':
758
+ case 'recovery_started':
759
+ case 'recovery_peer_empty':
760
+ case 'recovery_peer_bundle_received':
761
+ case 'recovery_failed':
762
+ case 'pull_request_received':
763
+ case 'pull_response_sent':
764
+ return true;
765
+ default:
766
+ return false;
767
+ }
768
+ }
769
+ function shouldSendQuietAuthPoolSummary(event) {
770
+ switch (event.kind) {
771
+ case 'candidate_delete_sent':
772
+ case 'remote_delete_deleted':
773
+ case 'remote_delete_skipped':
774
+ return true;
775
+ default:
776
+ return false;
777
+ }
778
+ }
679
779
  function formatAuthSyncNotification(locale, event) {
680
780
  const peers = 'peers' in event ? formatPeerList(event.peers, locale) : '';
681
781
  if (locale === 'zh') {
@@ -704,6 +804,23 @@ function formatAuthSyncNotification(locale, event) {
704
804
  return `${event.mode === 'pull' ? '跨节点拉取未改动本机文件' : '收到跨节点 auth 但未写盘'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}`;
705
805
  case 'remote_import_failed':
706
806
  return `${event.mode === 'pull' ? '跨节点拉取导入失败' : '跨节点 auth 导入失败'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}\n需要注意:如果其他候选也无法恢复,请人工介入重新登录或刷新这个 auth。`;
807
+ case 'candidate_delete_sent':
808
+ return `跨节点 auth 删除已发出:${event.candidateName}\nPeer:${peers}${event.reason ? `\n原因:${event.reason}` : ''}`;
809
+ case 'candidate_delete_failed':
810
+ return `跨节点 auth 删除发送失败:${event.candidateName}\nPeer:${peers}\n原因:${event.reason}`;
811
+ case 'remote_delete_received':
812
+ return [
813
+ `收到跨节点 auth 删除:${event.candidateName}`,
814
+ `来源:${formatSource(event.sourceLabel, event.sourceNodeId)},peer ${event.peer}`,
815
+ `处理:${event.queued ? `本机忙,已排队等待空闲后删除;当前待删除 ${event.queueLength}` : '本机空闲,正在删除同名候选'}`,
816
+ event.reason ? `原因:${event.reason}` : null,
817
+ ].filter(Boolean).join('\n');
818
+ case 'remote_delete_deleted':
819
+ return `已执行跨节点 auth 删除:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}${event.reason ? `\n原因:${event.reason}` : ''}`;
820
+ case 'remote_delete_skipped':
821
+ return `跨节点 auth 删除未改动本机文件:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}`;
822
+ case 'remote_delete_failed':
823
+ return `跨节点 auth 删除失败:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}`;
707
824
  case 'recovery_started':
708
825
  return `auth 恢复开始:${event.candidateName}\nRequest:${event.requestId}\n处理:同节点没有可用较新副本,正在向跨节点 peer 查询:${peers},最长等待 ${event.timeoutMs}ms`;
709
826
  case 'recovery_peer_empty':
@@ -753,6 +870,23 @@ function formatAuthSyncNotification(locale, event) {
753
870
  return `${event.mode === 'pull' ? 'Cross-node pull did not change local files' : 'Received cross-node auth but did not write it'}: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}`;
754
871
  case 'remote_import_failed':
755
872
  return `${event.mode === 'pull' ? 'Cross-node pull import failed' : 'Cross-node auth import failed'}: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}\nAttention: if no other candidate can recover this account, run device login or refresh this auth manually.`;
873
+ case 'candidate_delete_sent':
874
+ return `Cross-node auth delete sent: ${event.candidateName}\nPeers: ${peers}${event.reason ? `\nReason: ${event.reason}` : ''}`;
875
+ case 'candidate_delete_failed':
876
+ return `Cross-node auth delete send failed: ${event.candidateName}\nPeers: ${peers}\nReason: ${event.reason}`;
877
+ case 'remote_delete_received':
878
+ return [
879
+ `Received cross-node auth delete: ${event.candidateName}`,
880
+ `Source: ${formatSource(event.sourceLabel, event.sourceNodeId)}, peer ${event.peer}`,
881
+ `Action: ${event.queued ? `queued until this node is idle; pending deletes ${event.queueLength}` : 'deleting the matching local candidate'}`,
882
+ event.reason ? `Reason: ${event.reason}` : null,
883
+ ].filter(Boolean).join('\n');
884
+ case 'remote_delete_deleted':
885
+ return `Applied cross-node auth delete: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}${event.reason ? `\nReason: ${event.reason}` : ''}`;
886
+ case 'remote_delete_skipped':
887
+ return `Cross-node auth delete did not change local files: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}`;
888
+ case 'remote_delete_failed':
889
+ return `Cross-node auth delete failed: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}`;
756
890
  case 'recovery_started':
757
891
  return `Auth recovery started: ${event.candidateName}\nRequest: ${event.requestId}\nAction: no newer same-node copy was available; querying cross-node peers: ${peers}; timeout ${event.timeoutMs}ms`;
758
892
  case 'recovery_peer_empty':
@@ -38,6 +38,11 @@ export interface CodexAuthQuotaSnapshotRecord {
38
38
  secondaryRemainingPercent: number | null;
39
39
  updatedAt: number;
40
40
  }
41
+ export interface CodexAuthPoolStats {
42
+ totalSeen: number;
43
+ alive: number;
44
+ deletedInvalid: number;
45
+ }
41
46
  export type CodexAuthCandidateState = 'active' | 'needs_repair';
42
47
  export declare class BridgeStore {
43
48
  private db;
@@ -125,6 +130,10 @@ export declare class BridgeStore {
125
130
  setCodexAuthCandidateDisabled(name: string, disabled: boolean, runtimeId?: string): void;
126
131
  setCodexAuthCandidateState(name: string, state: CodexAuthCandidateState, runtimeId?: string): void;
127
132
  deleteCodexAuthCandidate(name: string): void;
133
+ recordCodexAuthPoolInventory(names: string[]): void;
134
+ recordCodexAuthCandidateInvalidDelete(name: string, reason?: string | null): void;
135
+ recordCodexAuthCandidateRemoved(name: string, reason?: string | null): void;
136
+ getCodexAuthPoolStats(): CodexAuthPoolStats;
128
137
  setCodexAuthQuotaSnapshot(runtimeId: string, candidateName: string, accountId: string, quotaIdentityId: string, snapshot: Pick<CodexAuthQuotaSnapshotRecord, 'capturedAtMs' | 'planType' | 'primaryWindowDurationMins' | 'primaryRemainingPercent' | 'secondaryWindowDurationMins' | 'secondaryRemainingPercent'>): void;
129
138
  listCodexAuthQuotaSnapshots(quotaIdentityIds: string[]): CodexAuthQuotaSnapshotRecord[];
130
139
  private ensureColumn;
@@ -197,6 +197,14 @@ export class BridgeStore {
197
197
  );
198
198
  CREATE INDEX IF NOT EXISTS codex_auth_quota_snapshots_account_idx
199
199
  ON codex_auth_quota_snapshots(account_id);
200
+ CREATE TABLE IF NOT EXISTS codex_auth_pool_history (
201
+ name TEXT PRIMARY KEY,
202
+ first_seen_at INTEGER NOT NULL,
203
+ last_seen_at INTEGER NOT NULL,
204
+ deleted_at INTEGER,
205
+ delete_reason TEXT,
206
+ invalid_delete_count INTEGER NOT NULL DEFAULT 0
207
+ );
200
208
  `);
201
209
  this.ensureColumn('thread_cache', 'name', 'TEXT');
202
210
  this.ensureColumn('thread_cache', 'model_provider', 'TEXT');
@@ -217,6 +225,7 @@ export class BridgeStore {
217
225
  this.ensureColumn('codex_auth_quota_snapshots', 'primary_window_duration_mins', 'REAL');
218
226
  this.ensureColumn('codex_auth_quota_snapshots', 'secondary_window_duration_mins', 'REAL');
219
227
  this.ensureColumn('codex_auth_quota_snapshots', 'quota_identity_id', "TEXT NOT NULL DEFAULT ''");
228
+ this.ensureColumn('codex_auth_pool_history', 'invalid_delete_count', 'INTEGER NOT NULL DEFAULT 0');
220
229
  this.db.prepare(`
221
230
  UPDATE codex_auth_quota_snapshots
222
231
  SET quota_identity_id = account_id
@@ -896,6 +905,84 @@ export class BridgeStore {
896
905
  this.db.prepare('DELETE FROM codex_auth_candidate_runtime WHERE name = ?').run(name);
897
906
  this.db.prepare('DELETE FROM codex_auth_quota_snapshots WHERE candidate_name = ?').run(name);
898
907
  }
908
+ recordCodexAuthPoolInventory(names) {
909
+ if (names.length === 0) {
910
+ return;
911
+ }
912
+ const now = Date.now();
913
+ const upsert = this.db.prepare(`
914
+ INSERT INTO codex_auth_pool_history (name, first_seen_at, last_seen_at, deleted_at, delete_reason, invalid_delete_count)
915
+ VALUES (?, ?, ?, NULL, NULL, 0)
916
+ ON CONFLICT(name) DO UPDATE SET
917
+ last_seen_at = excluded.last_seen_at,
918
+ deleted_at = NULL,
919
+ delete_reason = NULL
920
+ `);
921
+ const seen = new Set(names);
922
+ this.db.exec('BEGIN');
923
+ try {
924
+ for (const name of seen) {
925
+ upsert.run(name, now, now);
926
+ }
927
+ this.db.exec('COMMIT');
928
+ }
929
+ catch (error) {
930
+ this.db.exec('ROLLBACK');
931
+ throw error;
932
+ }
933
+ }
934
+ recordCodexAuthCandidateInvalidDelete(name, reason = null) {
935
+ const now = Date.now();
936
+ this.db.prepare(`
937
+ INSERT INTO codex_auth_pool_history (
938
+ name,
939
+ first_seen_at,
940
+ last_seen_at,
941
+ deleted_at,
942
+ delete_reason,
943
+ invalid_delete_count
944
+ )
945
+ VALUES (?, ?, ?, ?, ?, 1)
946
+ ON CONFLICT(name) DO UPDATE SET
947
+ last_seen_at = excluded.last_seen_at,
948
+ deleted_at = excluded.deleted_at,
949
+ delete_reason = excluded.delete_reason,
950
+ invalid_delete_count = codex_auth_pool_history.invalid_delete_count
951
+ + CASE WHEN codex_auth_pool_history.deleted_at IS NULL THEN 1 ELSE 0 END
952
+ `).run(name, now, now, now, reason);
953
+ }
954
+ recordCodexAuthCandidateRemoved(name, reason = null) {
955
+ const now = Date.now();
956
+ this.db.prepare(`
957
+ INSERT INTO codex_auth_pool_history (
958
+ name,
959
+ first_seen_at,
960
+ last_seen_at,
961
+ deleted_at,
962
+ delete_reason,
963
+ invalid_delete_count
964
+ )
965
+ VALUES (?, ?, ?, ?, ?, 0)
966
+ ON CONFLICT(name) DO UPDATE SET
967
+ last_seen_at = excluded.last_seen_at,
968
+ deleted_at = excluded.deleted_at,
969
+ delete_reason = excluded.delete_reason
970
+ `).run(name, now, now, now, reason);
971
+ }
972
+ getCodexAuthPoolStats() {
973
+ const row = this.db.prepare(`
974
+ SELECT
975
+ COUNT(*) AS total_seen,
976
+ COALESCE(SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END), 0) AS alive,
977
+ COALESCE(SUM(invalid_delete_count), 0) AS deleted_invalid
978
+ FROM codex_auth_pool_history
979
+ `).get();
980
+ return {
981
+ totalSeen: Number(row?.total_seen ?? 0),
982
+ alive: Number(row?.alive ?? 0),
983
+ deletedInvalid: Number(row?.deleted_invalid ?? 0),
984
+ };
985
+ }
899
986
  setCodexAuthQuotaSnapshot(runtimeId, candidateName, accountId, quotaIdentityId, snapshot) {
900
987
  this.db.prepare(`
901
988
  INSERT INTO codex_auth_quota_snapshots (
@@ -92,6 +92,7 @@ AUTH_SYNC_KEY=<shared key with at least 32 bytes>
92
92
  AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
93
93
  AUTH_SYNC_NODE_ID=workstation-a
94
94
  AUTH_SYNC_PEERS=@foxclaw_node_b_bot
95
+ AUTH_AUTO_DELETE_NEEDS_REPAIR=false
95
96
  ```
96
97
 
97
98
  Node B:
@@ -103,6 +104,7 @@ AUTH_SYNC_KEY=<shared key with at least 32 bytes>
103
104
  AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
104
105
  AUTH_SYNC_NODE_ID=workstation-b
105
106
  AUTH_SYNC_PEERS=@foxclaw_node_a_bot
107
+ AUTH_AUTO_DELETE_NEEDS_REPAIR=false
106
108
  ```
107
109
 
108
110
  For more peers, separate bot usernames with commas:
@@ -168,6 +170,8 @@ Note: `/auth sync push all` saying “sent” only means this node successfully
168
170
 
169
171
  When cross-node sync is enabled, the contact bot private chat receives node-level notifications: local auth updates and the peers being contacted, received remote bundles and whether they were queued or immediately validated, import success/skip/failure reasons, recovery peer queries and peer replies, and a manual-intervention notice when every peer lacks an importable copy. Refresh/send/import bursts are grouped into short summaries so one candidate update does not produce separate start, receive, mirror-write, and completion messages. Recovery and manual-intervention notices remain explicit. Remote import validation temporarily restarts the local Codex app-server; during that restart window FoxClaw treats the runtime as non-idle and asks ordinary messages to be resent shortly instead of running them against a restarting bridge. Notifications never include auth contents, tokens, or encrypted bundle payloads.
170
172
 
173
+ For a resource-rich pool where individual candidate maintenance is not important, enable `AUTH_AUTO_DELETE_NEEDS_REPAIR=true` or toggle it with `/config auth_auto_delete on`. Candidates that cannot be recovered and would otherwise be marked for repair are deleted locally, a delete tombstone is sent to peers, and candidate-level sync/delete chatter is collapsed into an auth-pool summary.
174
+
171
175
  Starting in 0.5.2, `/auth sync status` separates sync-system `Last error` from per-auth `Candidate failures`. For example, a remote candidate that returns `token_invalidated` or has an expired access token is recorded under that candidate name only; current `auth.json` health is still determined by validating the current auth usage. `local candidate is already newer or equal` is a normal skip, not an error.
172
176
 
173
177
  Manual `/auth` switches and `/auth reload` recover from same-node local mirrors only and do not send cross-node pull requests. FoxClaw queries peers only during automatic recovery after it detects a real auth problem. Recovery timeout notifications include the request id, candidate name, peer list, and wait duration; if another auth sync message arrived from the same peer during that wait, the notification says the peer was reachable but this request timed out.
@@ -448,6 +448,8 @@ AUTH_SYNC_PEERS=@other_node_bot,@third_node_bot
448
448
  AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
449
449
  # Optional; FoxClaw generates and persists a local node id when omitted.
450
450
  AUTH_SYNC_NODE_ID=workstation-a
451
+ # Optional resource-rich mode: auto-delete unrecoverable candidates across peers.
452
+ AUTH_AUTO_DELETE_NEEDS_REPAIR=false
451
453
  ```
452
454
 
453
455
  Safety boundaries:
@@ -457,6 +459,7 @@ Safety boundaries:
457
459
  - Remote imports wait for global local idleness, temporarily switch to the candidate for app-server usage validation, and only then write the candidate.
458
460
  - A same-name candidate known to belong to a different account id, or to a different identifiable ChatGPT user/email under the same account, is never overwritten.
459
461
  - Cross-node recovery only pulls an already-held valid peer copy and does not rotate refresh tokens during recovery. If no peer has a usable copy, it stops and asks you to maintain auth manually. The background 9-day proactive refresh separately requests the cross-node refresh lease and skips that cycle if the lease is not granted.
462
+ - When `AUTH_AUTO_DELETE_NEEDS_REPAIR=true` is enabled, or the same option is turned on in `/config`, unrecoverable candidates are deleted and propagated to peers with a delete tombstone. Private notifications collapse to an auth-pool summary: total seen, alive, and invalid-deleted.
460
463
 
461
464
  Dual-active behavior:
462
465
 
@@ -92,6 +92,7 @@ AUTH_SYNC_KEY=<至少32字节的共享密钥>
92
92
  AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
93
93
  AUTH_SYNC_NODE_ID=workstation-a
94
94
  AUTH_SYNC_PEERS=@foxclaw_node_b_bot
95
+ AUTH_AUTO_DELETE_NEEDS_REPAIR=false
95
96
  ```
96
97
 
97
98
  节点 B:
@@ -103,6 +104,7 @@ AUTH_SYNC_KEY=<至少32字节的共享密钥>
103
104
  AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
104
105
  AUTH_SYNC_NODE_ID=workstation-b
105
106
  AUTH_SYNC_PEERS=@foxclaw_node_a_bot
107
+ AUTH_AUTO_DELETE_NEEDS_REPAIR=false
106
108
  ```
107
109
 
108
110
  多节点时,`AUTH_SYNC_PEERS` 用英文逗号分隔:
@@ -168,6 +170,8 @@ auth sync 测试完成:已发送 1,收到回应 1。
168
170
 
169
171
  启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。刷新、发送、导入密集发生时会合并成简短汇总,避免一个候选更新拆成开始、收到、镜像写入和完成多条消息;恢复和人工介入提示仍会明确发出。远端导入验证会临时重启本机 Codex app-server;这段窗口里 FoxClaw 会把 runtime 视为非空闲,并让普通消息稍后重发,而不是送进正在重启的 bridge。通知不会包含 auth 内容、token 或同步密文。
170
172
 
173
+ 如果这是资源富裕的账号池、不关心单个候选如何维护,可以启用 `AUTH_AUTO_DELETE_NEEDS_REPAIR=true`,或用 `/config auth_auto_delete on` 运行时打开。无法恢复、原本会标记为需要修复的候选会直接本地删除,并向 peer 发送删除 tombstone;候选级同步/删除消息会压缩成 auth 池摘要。
174
+
171
175
  从 0.5.2 起,`/auth sync status` 会把同步系统级 `最近错误` 和单个 auth 的 `候选失败` 分开显示。比如某个远端候选返回 `token_invalidated` 或 access token 过期时,只会记录到该候选名下面;当前 `auth.json` 是否健康仍以当前 auth 的 usage 验证为准。`local candidate is already newer or equal` 属于正常跳过,不会记为错误。
172
176
 
173
177
  手动 `/auth` 切换和 `/auth reload` 只会尝试同节点本地 mirror 恢复,不会主动向跨节点 peer 发起 pull。只有 FoxClaw 检测到当前 auth 真的出现认证问题并进入自动恢复时,才会向 peer 查询可用副本。恢复超时通知会包含 request id、候选名、peer 列表和等待时长;如果等待期间收到过同 peer 的其他 auth sync 消息,通知会标明 peer 可达但该请求超时。
@@ -448,6 +448,8 @@ AUTH_SYNC_PEERS=@other_node_bot,@third_node_bot
448
448
  AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
449
449
  # 可选;不填时 FoxClaw 会生成并持久化本机 node id
450
450
  AUTH_SYNC_NODE_ID=workstation-a
451
+ # 可选;资源富裕模式下自动跨节点剔除无法恢复的候选
452
+ AUTH_AUTO_DELETE_NEEDS_REPAIR=false
451
453
  ```
452
454
 
453
455
  安全边界:
@@ -457,6 +459,7 @@ AUTH_SYNC_NODE_ID=workstation-a
457
459
  - 远端导入必须等本机全局空闲,再临时切换到待验证 auth、重启 app-server、读取 usage 验证成功后才写入候选。
458
460
  - 同名候选如果已知属于不同 account id,或属于同一 account 下不同的可识别 ChatGPT 用户/邮箱,永远拒绝覆盖。
459
461
  - 跨节点恢复只拉取 peer 已持有的有效副本,不会在恢复过程中直接轮换 refresh token;找不到有效副本时会停止,提示你人工维护授权。后台 9 天主动刷新会单独申请跨节点刷新锁,拿不到锁就跳过本轮。
462
+ - 如果开启 `AUTH_AUTO_DELETE_NEEDS_REPAIR=true` 或在 `/config` 中打开自动剔除,无法恢复的候选会直接删除并向 peer 传播删除 tombstone;私聊通知会压缩为 auth 池摘要:历史总数、存活数、因失效剔除数。
460
463
 
461
464
  双主动流程:
462
465
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.20",
3
+ "version": "0.5.21",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",