@foxden-app/foxclaw 0.4.2 → 0.4.4

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/README.md CHANGED
@@ -101,7 +101,7 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
101
101
  **多账号管理:**
102
102
  - Codex 账户管理:`/account`、`/quota`、`/login_device`、`/auth add <name>`
103
103
  - 触发用量限制时自动在本地 `auth.json_*` 之间切换认证——5 小时限制到了自动换号
104
- - `/auth` 面板查看、启用、禁用、切换候选账号
104
+ - `/auth` 面板查看、启用、禁用、切换候选账号;多 bot 模式下会按账号 ID 汇总各 runtime 最近掌握的额度快照
105
105
 
106
106
  **线程与会话:**
107
107
  - `/threads`、`/open`、`/new`、`/where`、`/interrupt`——稳定的聊天-线程绑定
@@ -206,7 +206,7 @@ TG_BOT_TOKENS=123456:token_a,234567:token_b
206
206
  TG_BOT_TOKEN=123456:token_a
207
207
  ```
208
208
 
209
- FoxClaw 仍然只运行一个系统服务。默认情况下,它会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新验证后镜像同步,各 bot 的当前选择互不影响。每个 bot 首次私聊发送 `/help` 和 `/status`;`/auth` 会标明正在操作的 bot runtime,`/status` 会列出全部 bot 的连接、runtime 类型、当前 auth 和活动 turn 摘要。
209
+ FoxClaw 仍然只运行一个系统服务。默认情况下,它会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新在线验证后镜像同步;切换或重载前还会从其他 Codex home 恢复同账号较新凭据。各 bot 的当前选择互不影响。每个 bot 首次私聊发送 `/help` 和 `/status`;`/auth` 会标明正在操作的 bot runtime,`/status` 会列出全部 bot 的连接、runtime 类型、当前 auth 和活动 turn 摘要。
210
210
 
211
211
  如果你需要一路 Telegram 与终端互通 session,把同一个 token 同时填入 `TG_BOT_TOKENS` 和 `TG_BOT_TOKEN`。这个 bot 使用默认 `CODEX_HOME`(未设置时通常是 `~/.codex`)和默认 auth,因此能看到终端 Codex 的本地线程;它不再享有隔离 runtime 的“互不影响”保证,切换 auth 会影响终端和其他默认 runtime。
212
212
 
package/README_EN.md CHANGED
@@ -101,7 +101,7 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
101
101
  **Multi-account management:**
102
102
  - Codex account controls: `/account`, `/quota`, `/login_device`, `/auth add <name>`
103
103
  - Automatic auth rotation across local `auth.json_*` files when a usage limit is hit — seamless account switching
104
- - `/auth` panel to view, enable, disable, and switch between candidate accounts
104
+ - `/auth` panel to view, enable, disable, and switch between candidate accounts; in multi-bot mode it merges recent quota snapshots by account ID across runtimes
105
105
 
106
106
  **Threads and sessions:**
107
107
  - `/threads`, `/open`, `/new`, `/where`, `/interrupt` — sticky chat-to-thread binding
@@ -206,7 +206,7 @@ TG_BOT_TOKENS=123456:token_a,234567:token_b
206
206
  TG_BOT_TOKEN=123456:token_a
207
207
  ```
208
208
 
209
- FoxClaw remains one system service. By default, it starts an independent `codex app-server` and `CODEX_HOME` for each bot. While bot A is running a turn, bot B can switch its own `/auth` selection. Candidate credentials are mirrored only after validated login or refresh; current selections remain independent. Send `/help` and `/status` in a private chat with each bot after installation; `/auth` names the runtime being managed, and `/status` summarizes every bot's connection, runtime type, selected auth, and active turns.
209
+ FoxClaw remains one system service. By default, it starts an independent `codex app-server` and `CODEX_HOME` for each bot. While bot A is running a turn, bot B can switch its own `/auth` selection. Candidate credentials are mirrored only after online-validated login or refresh; before switching or reloading, FoxClaw also restores a newer same-account credential from another Codex home when available. Current selections remain independent. Send `/help` and `/status` in a private chat with each bot after installation; `/auth` names the runtime being managed, and `/status` summarizes every bot's connection, runtime type, selected auth, and active turns.
210
210
 
211
211
  To keep one Telegram bot interoperable with terminal Codex sessions, put the same token in both `TG_BOT_TOKENS` and `TG_BOT_TOKEN`. That bot uses the default `CODEX_HOME` (usually `~/.codex` when unset) and default auth, so it can see local terminal sessions. It no longer has the isolated runtime guarantee: `/auth` switches also affect the terminal and other default runtimes.
212
212
 
@@ -12,6 +12,12 @@ export interface AuthMirrorStatus {
12
12
  sourceLabel: string;
13
13
  syncedAt: string;
14
14
  }
15
+ export interface AuthMirrorRecovery {
16
+ candidateName: string;
17
+ sourceRuntimeId: string;
18
+ sourceCandidateName: string;
19
+ lastRefreshMs: number;
20
+ }
15
21
  export interface AuthMirrorValidationContext {
16
22
  candidateName: string;
17
23
  accountId: string;
@@ -21,6 +27,10 @@ export interface AuthMirrorValidationResult {
21
27
  ok: boolean;
22
28
  reason?: string | null;
23
29
  }
30
+ export interface ChatGptAuthMetadata {
31
+ accountId: string;
32
+ lastRefreshMs: number;
33
+ }
24
34
  export declare class AuthCandidateMirror {
25
35
  private readonly canonicalDir;
26
36
  private readonly runtimes;
@@ -28,6 +38,7 @@ export declare class AuthCandidateMirror {
28
38
  private readonly statusPath;
29
39
  private timer;
30
40
  private readonly lastSyncedRefresh;
41
+ private readonly lastValidationFailures;
31
42
  private activeOperations;
32
43
  private lastStatus;
33
44
  constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger, statusPath?: string | null);
@@ -37,13 +48,16 @@ export declare class AuthCandidateMirror {
37
48
  isIdle(): boolean;
38
49
  getStatus(): AuthMirrorStatus | null;
39
50
  syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
51
+ recoverRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorRecovery | null>;
40
52
  private scan;
41
53
  private propagateValidatedCandidate;
42
54
  private validateRuntimeCandidate;
43
55
  private withActivity;
44
56
  private ensureCanonicalDefaultCandidate;
45
57
  private collectCandidateNames;
58
+ private collectAuthRecords;
46
59
  private reconcileCandidateAtStartup;
47
60
  private resolveCanonicalCurrentCandidate;
48
61
  }
49
62
  export declare function isAuthCandidateName(name: string): boolean;
63
+ export declare function readChatGptAuthMetadata(filePath: string): Promise<ChatGptAuthMetadata | null>;
@@ -8,6 +8,7 @@ export class AuthCandidateMirror {
8
8
  statusPath;
9
9
  timer = null;
10
10
  lastSyncedRefresh = new Map();
11
+ lastValidationFailures = new Map();
11
12
  activeOperations = 0;
12
13
  lastStatus = null;
13
14
  constructor(canonicalDir, runtimes, logger, statusPath = null) {
@@ -71,6 +72,39 @@ export class AuthCandidateMirror {
71
72
  return false;
72
73
  return this.withActivity(() => this.propagateValidatedCandidate(runtime, candidateName));
73
74
  }
75
+ async recoverRuntimeCandidate(runtimeId, candidateName) {
76
+ if (!isAuthCandidateName(candidateName))
77
+ return null;
78
+ const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
79
+ if (!runtime)
80
+ return null;
81
+ return this.withActivity(async () => {
82
+ const destinationPath = path.join(runtime.authDir, candidateName);
83
+ const destination = await readChatGptAuthRecord(destinationPath);
84
+ if (!destination)
85
+ return null;
86
+ const sources = await this.collectAuthRecords();
87
+ const newest = sources
88
+ .filter(entry => entry.record.accountId === destination.accountId)
89
+ .reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
90
+ if (!newest || newest.record.lastRefreshMs <= destination.lastRefreshMs) {
91
+ return null;
92
+ }
93
+ await atomicWrite(destinationPath, newest.record.raw);
94
+ this.logger.info('auth.mirror.recovered', {
95
+ runtimeId,
96
+ candidateName,
97
+ sourceRuntimeId: newest.runtimeId,
98
+ sourceCandidateName: newest.candidateName,
99
+ });
100
+ return {
101
+ candidateName,
102
+ sourceRuntimeId: newest.runtimeId,
103
+ sourceCandidateName: newest.candidateName,
104
+ lastRefreshMs: newest.record.lastRefreshMs,
105
+ };
106
+ });
107
+ }
74
108
  async scan() {
75
109
  await this.withActivity(async () => {
76
110
  for (const runtime of this.runtimes) {
@@ -98,13 +132,20 @@ export class AuthCandidateMirror {
98
132
  }
99
133
  const validation = await this.validateRuntimeCandidate(runtime, name, record);
100
134
  if (!validation.ok) {
101
- this.logger.warn('auth.mirror.validation_failed', {
102
- runtimeId: runtime.id,
103
- name,
104
- reason: validation.reason ?? 'unknown',
105
- });
135
+ const reason = validation.reason ?? 'unknown';
136
+ const failureKey = `${runtime.id}:${name}`;
137
+ const failureValue = `${record.lastRefreshMs}:${reason}`;
138
+ if (this.lastValidationFailures.get(failureKey) !== failureValue) {
139
+ this.lastValidationFailures.set(failureKey, failureValue);
140
+ this.logger.warn('auth.mirror.validation_failed', {
141
+ runtimeId: runtime.id,
142
+ name,
143
+ reason,
144
+ });
145
+ }
106
146
  return false;
107
147
  }
148
+ this.lastValidationFailures.delete(`${runtime.id}:${name}`);
108
149
  await atomicWrite(canonicalPath, record.raw);
109
150
  for (const target of this.runtimes) {
110
151
  if (target.id !== runtime.id) {
@@ -174,6 +215,23 @@ export class AuthCandidateMirror {
174
215
  }
175
216
  return [...all];
176
217
  }
218
+ async collectAuthRecords() {
219
+ const directories = [
220
+ { runtimeId: 'canonical', authDir: this.canonicalDir },
221
+ ...this.runtimes.map(runtime => ({ runtimeId: runtime.id, authDir: runtime.authDir })),
222
+ ];
223
+ const records = [];
224
+ for (const directory of directories) {
225
+ const names = new Set(['auth.json', ...await listAuthCandidateNames(directory.authDir)]);
226
+ for (const candidateName of names) {
227
+ const record = await readChatGptAuthRecord(path.join(directory.authDir, candidateName));
228
+ if (record) {
229
+ records.push({ runtimeId: directory.runtimeId, candidateName, record });
230
+ }
231
+ }
232
+ }
233
+ return records;
234
+ }
177
235
  async reconcileCandidateAtStartup(name) {
178
236
  const paths = [
179
237
  path.join(this.canonicalDir, name),
@@ -216,12 +274,31 @@ async function listAuthCandidateNames(dir) {
216
274
  async function readChatGptAuthRecord(filePath) {
217
275
  try {
218
276
  const raw = await fs.readFile(filePath, 'utf8');
277
+ const metadata = parseChatGptAuthMetadata(raw);
278
+ if (!metadata)
279
+ return null;
280
+ return { raw, ...metadata };
281
+ }
282
+ catch {
283
+ return null;
284
+ }
285
+ }
286
+ export async function readChatGptAuthMetadata(filePath) {
287
+ try {
288
+ return parseChatGptAuthMetadata(await fs.readFile(filePath, 'utf8'));
289
+ }
290
+ catch {
291
+ return null;
292
+ }
293
+ }
294
+ function parseChatGptAuthMetadata(raw) {
295
+ try {
219
296
  const parsed = JSON.parse(raw);
220
297
  const accountId = typeof parsed.tokens?.account_id === 'string' ? parsed.tokens.account_id : '';
221
298
  const lastRefreshMs = typeof parsed.last_refresh === 'string' ? Date.parse(parsed.last_refresh) : NaN;
222
299
  if (!accountId || !Number.isFinite(lastRefreshMs))
223
300
  return null;
224
- return { raw, accountId, lastRefreshMs };
301
+ return { accountId, lastRefreshMs };
225
302
  }
226
303
  catch {
227
304
  return null;
@@ -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
+ recoverAuthCandidate?: (runtimeId: string, candidateName: string) => Promise<boolean>;
12
13
  statusUpdated?: (status: RuntimeStatus) => void;
13
14
  getServiceStatus?: () => Promise<{
14
15
  bots: NonNullable<RuntimeStatus['bots']>;
@@ -285,6 +286,8 @@ export declare class BridgeSessionCore {
285
286
  private readCodexAuthSwitchLabels;
286
287
  private codexAuthSwitchParams;
287
288
  private switchCodexAuthAndRestart;
289
+ private recoverCodexAuthCandidate;
290
+ private syncCodexAuthCandidate;
288
291
  private buildNativeCollaborationMode;
289
292
  private buildCodexUsageStatusLines;
290
293
  private buildCodexLocalUsageStatusLines;
@@ -295,6 +298,9 @@ export declare class BridgeSessionCore {
295
298
  private refreshCodexLocalUsageStats;
296
299
  private codexLocalUsageSnapshotPath;
297
300
  private refreshCurrentCodexAuthQuota;
301
+ private applySharedCodexAuthQuotaSnapshots;
302
+ private readCodexAuthCandidateAccountIds;
303
+ private codexAuthQuotaSnapshotMatchesAccount;
298
304
  private readCodexAuthQuotaSnapshots;
299
305
  private writeCodexAuthQuotaSnapshots;
300
306
  private codexAuthQuotaSnapshotPath;
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { normalizeLocale, t } from '../i18n.js';
6
+ import { readChatGptAuthMetadata } from '../auth/mirror.js';
6
7
  import { parseCommand } from './commands.js';
7
8
  import { buildAccessSettingsKeyboard, buildModelSettingsKeyboard, buildSetupPanelKeyboard, buildThreadListKeyboard, buildThreadsKeyboard, clampEffortToModel, formatAccessPresetLabel, formatActiveTurnMessageModeLabel, formatAccessSettingsMessage, formatApprovalPolicyLabel, formatCollaborationModeLabel, formatModelSettingsMessage, formatSandboxModeLabel, formatServiceTierStatusLabel, formatSetupPanelMessage, formatThreadContextSummary, formatThreadsMessage, formatWeixinAccessCopyPaste, formatWeixinModelCopyPaste, formatWeixinThreadsCopyPaste, formatWeixinWhereNavCopyPaste, formatWhereMessage, normalizeRequestedEffort, resolveCurrentModel, resolveActiveTurnMessageMode, resolveRequestedModel, } from './presentation.js';
8
9
  import { clampServiceTierToModel, resolveFastTierForModel } from './service_tier.js';
@@ -3761,10 +3762,20 @@ export class BridgeSessionCore {
3761
3762
  return;
3762
3763
  }
3763
3764
  await this.sendMessage(scopeId, t(locale, 'auth_reload_restarting'));
3765
+ const currentCandidate = (await this.listCodexAuthState()).candidates.find(candidate => candidate.isCurrent) ?? null;
3766
+ const recovered = currentCandidate
3767
+ ? await this.recoverCodexAuthCandidate(currentCandidate.name)
3768
+ : false;
3764
3769
  this.pendingTurnErrors.clear();
3765
3770
  this.attachedThreads.clear();
3766
3771
  await this.app.restart();
3772
+ if (currentCandidate) {
3773
+ await this.syncCodexAuthCandidate(currentCandidate.name);
3774
+ }
3767
3775
  const lines = [t(locale, 'auth_reload_done')];
3776
+ if (recovered) {
3777
+ lines.push(t(locale, 'auth_recovered_newer_candidate', { value: currentCandidate.name }));
3778
+ }
3768
3779
  lines.push(...await this.buildCodexUsageStatusLines(locale));
3769
3780
  await this.sendMessage(scopeId, lines.join('\n'));
3770
3781
  }
@@ -4510,9 +4521,15 @@ export class BridgeSessionCore {
4510
4521
  async listCodexAuthState() {
4511
4522
  const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.resolveAuthDir());
4512
4523
  const snapshots = await this.readCodexAuthQuotaSnapshots();
4524
+ const candidateAccountIds = await this.readCodexAuthCandidateAccountIds(state.candidates);
4513
4525
  state.candidates.forEach((candidate) => {
4514
- candidate.quota = snapshots[candidate.name] ?? null;
4526
+ const candidateAccountId = candidateAccountIds.get(candidate.name) ?? null;
4527
+ const snapshot = snapshots[candidate.name] ?? null;
4528
+ candidate.quota = this.codexAuthQuotaSnapshotMatchesAccount(snapshot, candidateAccountId)
4529
+ ? snapshot
4530
+ : null;
4515
4531
  });
4532
+ await this.applySharedCodexAuthQuotaSnapshots(state, candidateAccountIds);
4516
4533
  return state;
4517
4534
  }
4518
4535
  resolveAuthDir() {
@@ -4535,15 +4552,48 @@ export class BridgeSessionCore {
4535
4552
  };
4536
4553
  }
4537
4554
  async switchCodexAuthAndRestart(scopeId, locale, candidate, automatic) {
4555
+ const recovered = await this.recoverCodexAuthCandidate(candidate.name);
4538
4556
  const result = await switchCodexAuth(candidate.path, this.resolveAuthDir());
4539
4557
  this.authRotationFailedTargets.delete(candidate.path);
4540
4558
  this.pendingTurnErrors.clear();
4541
4559
  this.attachedThreads.clear();
4542
4560
  await this.app.restart();
4561
+ await this.syncCodexAuthCandidate(candidate.name);
4543
4562
  const lines = [t(locale, automatic ? 'auth_auto_done' : 'auth_switch_done', this.codexAuthSwitchParams(locale, result.fromLabel, result.toLabel))];
4563
+ if (recovered) {
4564
+ lines.push(t(locale, 'auth_recovered_newer_candidate', { value: candidate.name }));
4565
+ }
4544
4566
  lines.push(...await this.buildCodexUsageStatusLines(locale));
4545
4567
  await this.sendMessage(scopeId, lines.join('\n'));
4546
4568
  }
4569
+ async recoverCodexAuthCandidate(candidateName) {
4570
+ if (!this.coordinator?.recoverAuthCandidate) {
4571
+ return false;
4572
+ }
4573
+ try {
4574
+ return await this.coordinator.recoverAuthCandidate(this.authRuntimeId(), candidateName);
4575
+ }
4576
+ catch (error) {
4577
+ this.logger.warn('codex.auth_candidate_recovery_failed', {
4578
+ candidate: candidateName,
4579
+ runtimeId: this.authRuntimeId(),
4580
+ error: toErrorMeta(error),
4581
+ });
4582
+ return false;
4583
+ }
4584
+ }
4585
+ async syncCodexAuthCandidate(candidateName) {
4586
+ try {
4587
+ await this.coordinator?.authCandidateUpdated?.(this.authRuntimeId(), candidateName);
4588
+ }
4589
+ catch (error) {
4590
+ this.logger.warn('codex.auth_candidate_sync_failed', {
4591
+ candidate: candidateName,
4592
+ runtimeId: this.authRuntimeId(),
4593
+ error: toErrorMeta(error),
4594
+ });
4595
+ }
4596
+ }
4547
4597
  async buildNativeCollaborationMode(settings, cwd, modeOverride) {
4548
4598
  const mode = resolveCollaborationMode(modeOverride === undefined ? settings?.collaborationMode ?? null : modeOverride);
4549
4599
  try {
@@ -4741,19 +4791,68 @@ export class BridgeSessionCore {
4741
4791
  return;
4742
4792
  }
4743
4793
  try {
4794
+ const metadata = await readChatGptAuthMetadata(candidate.path);
4744
4795
  const snapshot = selectCodexRateLimitSnapshot(await this.app.readAccountRateLimits());
4745
4796
  if (!snapshot) {
4746
4797
  return;
4747
4798
  }
4748
- const quota = authQuotaSnapshotFromRateLimit(snapshot);
4799
+ const quota = authQuotaSnapshotFromRateLimit(snapshot, metadata?.accountId ?? null);
4749
4800
  candidate.quota = quota;
4750
4801
  this.authQuotaSnapshots[candidate.name] = quota;
4802
+ if (metadata?.accountId) {
4803
+ this.store.setCodexAuthQuotaSnapshot(this.authRuntimeId(), candidate.name, metadata.accountId, quota);
4804
+ }
4751
4805
  await this.writeCodexAuthQuotaSnapshots();
4806
+ await this.applySharedCodexAuthQuotaSnapshots(state);
4807
+ await this.syncCodexAuthCandidate(candidate.name);
4752
4808
  }
4753
4809
  catch (error) {
4754
4810
  this.logger.warn('codex.auth_quota_refresh_failed', { error: formatUserError(error) });
4755
4811
  }
4756
4812
  }
4813
+ async applySharedCodexAuthQuotaSnapshots(state, candidateAccountIds) {
4814
+ const accountIds = candidateAccountIds ?? await this.readCodexAuthCandidateAccountIds(state.candidates);
4815
+ const uniqueAccountIds = [...new Set(accountIds.values())];
4816
+ if (uniqueAccountIds.length === 0) {
4817
+ return;
4818
+ }
4819
+ const snapshotsByAccount = new Map();
4820
+ for (const record of this.store.listCodexAuthQuotaSnapshots(uniqueAccountIds)) {
4821
+ if (!isFiniteCodexAuthQuotaSnapshotRecord(record)) {
4822
+ continue;
4823
+ }
4824
+ snapshotsByAccount.set(record.accountId, mergeCodexAuthQuotaSnapshots(snapshotsByAccount.get(record.accountId) ?? null, codexAuthQuotaSnapshotFromRecord(record)));
4825
+ }
4826
+ for (const candidate of state.candidates) {
4827
+ const accountId = accountIds.get(candidate.name);
4828
+ if (!accountId) {
4829
+ continue;
4830
+ }
4831
+ candidate.quota = mergeCodexAuthQuotaSnapshots(candidate.quota, snapshotsByAccount.get(accountId) ?? null);
4832
+ }
4833
+ }
4834
+ async readCodexAuthCandidateAccountIds(candidates) {
4835
+ const entries = await Promise.all(candidates.map(async (candidate) => {
4836
+ const metadata = await readChatGptAuthMetadata(candidate.path);
4837
+ return [candidate.name, metadata?.accountId ?? null];
4838
+ }));
4839
+ const accountIds = new Map();
4840
+ for (const [name, accountId] of entries) {
4841
+ if (accountId) {
4842
+ accountIds.set(name, accountId);
4843
+ }
4844
+ }
4845
+ return accountIds;
4846
+ }
4847
+ codexAuthQuotaSnapshotMatchesAccount(snapshot, accountId) {
4848
+ if (!snapshot) {
4849
+ return false;
4850
+ }
4851
+ if (!snapshot.accountId) {
4852
+ return accountId === null;
4853
+ }
4854
+ return accountId !== null && snapshot.accountId === accountId;
4855
+ }
4757
4856
  async readCodexAuthQuotaSnapshots() {
4758
4857
  if (this.authQuotaSnapshotsLoaded) {
4759
4858
  return this.authQuotaSnapshots;
@@ -7934,13 +8033,50 @@ function formatRemainingUsagePercent(usedPercent) {
7934
8033
  const remainingPercent = remainingUsagePercent(usedPercent);
7935
8034
  return remainingPercent === null ? '?' : formatUsagePercent(remainingPercent);
7936
8035
  }
7937
- function authQuotaSnapshotFromRateLimit(snapshot) {
8036
+ function authQuotaSnapshotFromRateLimit(snapshot, accountId = null) {
7938
8037
  return {
7939
8038
  capturedAtMs: Date.now(),
8039
+ accountId,
7940
8040
  primaryRemainingPercent: snapshot.primary ? remainingUsagePercent(snapshot.primary.usedPercent) : null,
7941
8041
  secondaryRemainingPercent: snapshot.secondary ? remainingUsagePercent(snapshot.secondary.usedPercent) : null,
7942
8042
  };
7943
8043
  }
8044
+ function codexAuthQuotaSnapshotFromRecord(record) {
8045
+ return {
8046
+ capturedAtMs: record.capturedAtMs,
8047
+ accountId: record.accountId,
8048
+ primaryRemainingPercent: record.primaryRemainingPercent,
8049
+ secondaryRemainingPercent: record.secondaryRemainingPercent,
8050
+ };
8051
+ }
8052
+ function mergeCodexAuthQuotaSnapshots(current, incoming) {
8053
+ if (!current) {
8054
+ return incoming;
8055
+ }
8056
+ if (!incoming) {
8057
+ return current;
8058
+ }
8059
+ return {
8060
+ capturedAtMs: Math.max(current.capturedAtMs, incoming.capturedAtMs),
8061
+ accountId: current.accountId ?? incoming.accountId ?? null,
8062
+ primaryRemainingPercent: fresherNullableQuotaValue(current.primaryRemainingPercent, current.capturedAtMs, incoming.primaryRemainingPercent, incoming.capturedAtMs),
8063
+ secondaryRemainingPercent: fresherNullableQuotaValue(current.secondaryRemainingPercent, current.capturedAtMs, incoming.secondaryRemainingPercent, incoming.capturedAtMs),
8064
+ };
8065
+ }
8066
+ function fresherNullableQuotaValue(currentValue, currentCapturedAtMs, incomingValue, incomingCapturedAtMs) {
8067
+ if (currentValue === null) {
8068
+ return incomingValue;
8069
+ }
8070
+ if (incomingValue === null) {
8071
+ return currentValue;
8072
+ }
8073
+ return incomingCapturedAtMs >= currentCapturedAtMs ? incomingValue : currentValue;
8074
+ }
8075
+ function isFiniteCodexAuthQuotaSnapshotRecord(record) {
8076
+ return Number.isFinite(record.capturedAtMs)
8077
+ && isNullableFiniteNumber(record.primaryRemainingPercent)
8078
+ && isNullableFiniteNumber(record.secondaryRemainingPercent);
8079
+ }
7944
8080
  function remainingUsagePercent(usedPercent) {
7945
8081
  if (!Number.isFinite(usedPercent)) {
7946
8082
  return null;
@@ -7962,6 +8098,7 @@ function isCodexAuthQuotaSnapshot(value) {
7962
8098
  const snapshot = value;
7963
8099
  return typeof snapshot.capturedAtMs === 'number'
7964
8100
  && Number.isFinite(snapshot.capturedAtMs)
8101
+ && (snapshot.accountId === undefined || snapshot.accountId === null || typeof snapshot.accountId === 'string')
7965
8102
  && isNullableFiniteNumber(snapshot.primaryRemainingPercent)
7966
8103
  && isNullableFiniteNumber(snapshot.secondaryRemainingPercent);
7967
8104
  }
package/dist/i18n.d.ts CHANGED
@@ -157,6 +157,7 @@ declare const MESSAGES: {
157
157
  readonly auth_choice_recorded: "Auth selected";
158
158
  readonly auth_switching: "Switching Codex auth: {from} -> {to}...";
159
159
  readonly auth_switch_done: "Codex auth switched: {from} -> {to}.";
160
+ readonly auth_recovered_newer_candidate: "Recovered a newer same-account credential for {value} from another Codex home before reload.";
160
161
  readonly auth_auto_switching: "Codex auth problem detected ({error}). Switching: {from} -> {to}...";
161
162
  readonly auth_auto_done: "Auto-switched Codex auth: {from} -> {to}.";
162
163
  readonly auth_auto_retrying: "Retrying the same request with the new auth...";
@@ -732,6 +733,7 @@ declare const MESSAGES: {
732
733
  readonly auth_choice_recorded: "已选择 auth";
733
734
  readonly auth_switching: "正在切换 Codex auth:{from} -> {to}...";
734
735
  readonly auth_switch_done: "Codex auth 已切换:{from} -> {to}。";
736
+ readonly auth_recovered_newer_candidate: "重载前已从其他 Codex home 恢复 {value} 的同账号较新凭据。";
735
737
  readonly auth_auto_switching: "检测到 Codex auth 问题({error}),正在切换:{from} -> {to}...";
736
738
  readonly auth_auto_done: "已自动切换 Codex auth:{from} -> {to}。";
737
739
  readonly auth_auto_retrying: "正在用新的 auth 重试同一条请求...";
package/dist/i18n.js CHANGED
@@ -155,6 +155,7 @@ const MESSAGES = {
155
155
  auth_choice_recorded: 'Auth selected',
156
156
  auth_switching: 'Switching Codex auth: {from} -> {to}...',
157
157
  auth_switch_done: 'Codex auth switched: {from} -> {to}.',
158
+ auth_recovered_newer_candidate: 'Recovered a newer same-account credential for {value} from another Codex home before reload.',
158
159
  auth_auto_switching: 'Codex auth problem detected ({error}). Switching: {from} -> {to}...',
159
160
  auth_auto_done: 'Auto-switched Codex auth: {from} -> {to}.',
160
161
  auth_auto_retrying: 'Retrying the same request with the new auth...',
@@ -730,6 +731,7 @@ const MESSAGES = {
730
731
  auth_choice_recorded: '已选择 auth',
731
732
  auth_switching: '正在切换 Codex auth:{from} -> {to}...',
732
733
  auth_switch_done: 'Codex auth 已切换:{from} -> {to}。',
734
+ auth_recovered_newer_candidate: '重载前已从其他 Codex home 恢复 {value} 的同账号较新凭据。',
733
735
  auth_auto_switching: '检测到 Codex auth 问题({error}),正在切换:{from} -> {to}...',
734
736
  auth_auto_done: '已自动切换 Codex auth:{from} -> {to}。',
735
737
  auth_auto_retrying: '正在用新的 auth 重试同一条请求...',
package/dist/main.js CHANGED
@@ -279,6 +279,7 @@ async function runServeCli() {
279
279
  && (!activeWeixinCore || activeWeixinCore.isIdleForServiceUpdate())
280
280
  && mirror.isIdle(),
281
281
  authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
282
+ recoverAuthCandidate: (runtimeId, candidateName) => mirror.recoverRuntimeCandidate(runtimeId, candidateName).then(recovery => recovery !== null),
282
283
  statusUpdated: () => writeAggregateStatus(),
283
284
  getServiceStatus: async () => ({
284
285
  bots: await Promise.all(runtimes.map(async (runtime) => {
@@ -441,11 +442,10 @@ async function validateRefreshedAuthCandidate(runtime, candidateName) {
441
442
  if (!runtime.app.isConnected()) {
442
443
  return { ok: false, reason: 'source app-server is not connected' };
443
444
  }
444
- const account = await runtime.app.readAccount();
445
- if (!account || account.type !== 'chatgpt' || account.requiresOpenaiAuth) {
446
- return { ok: false, reason: 'source app-server did not report an active ChatGPT account' };
445
+ const rateLimits = await runtime.app.readAccountRateLimits();
446
+ if (!rateLimits) {
447
+ return { ok: false, reason: 'source app-server did not return ChatGPT rate limits' };
447
448
  }
448
- await runtime.app.readAccountRateLimits();
449
449
  return { ok: true };
450
450
  }
451
451
  async function initConfig() {
@@ -24,6 +24,15 @@ export interface PendingUserInputStoredRecord {
24
24
  submittedAt: number | null;
25
25
  resolvedAt: number | null;
26
26
  }
27
+ export interface CodexAuthQuotaSnapshotRecord {
28
+ runtimeId: string;
29
+ candidateName: string;
30
+ accountId: string;
31
+ capturedAtMs: number;
32
+ primaryRemainingPercent: number | null;
33
+ secondaryRemainingPercent: number | null;
34
+ updatedAt: number;
35
+ }
27
36
  export declare class BridgeStore {
28
37
  private db;
29
38
  constructor(dbPath: string);
@@ -77,5 +86,7 @@ export declare class BridgeStore {
77
86
  setWeixinContextToken(scopeId: string, contextToken: string): void;
78
87
  listDisabledCodexAuthCandidateNames(runtimeId?: string): Set<string>;
79
88
  setCodexAuthCandidateDisabled(name: string, disabled: boolean, runtimeId?: string): void;
89
+ setCodexAuthQuotaSnapshot(runtimeId: string, candidateName: string, accountId: string, snapshot: Pick<CodexAuthQuotaSnapshotRecord, 'capturedAtMs' | 'primaryRemainingPercent' | 'secondaryRemainingPercent'>): void;
90
+ listCodexAuthQuotaSnapshots(accountIds: string[]): CodexAuthQuotaSnapshotRecord[];
80
91
  private ensureColumn;
81
92
  }
@@ -123,6 +123,18 @@ export class BridgeStore {
123
123
  updated_at INTEGER NOT NULL,
124
124
  PRIMARY KEY (runtime_id, name)
125
125
  );
126
+ CREATE TABLE IF NOT EXISTS codex_auth_quota_snapshots (
127
+ runtime_id TEXT NOT NULL,
128
+ candidate_name TEXT NOT NULL,
129
+ account_id TEXT NOT NULL,
130
+ captured_at_ms INTEGER NOT NULL,
131
+ primary_remaining_percent REAL,
132
+ secondary_remaining_percent REAL,
133
+ updated_at INTEGER NOT NULL,
134
+ PRIMARY KEY (runtime_id, candidate_name)
135
+ );
136
+ CREATE INDEX IF NOT EXISTS codex_auth_quota_snapshots_account_idx
137
+ ON codex_auth_quota_snapshots(account_id);
126
138
  `);
127
139
  this.ensureColumn('thread_cache', 'name', 'TEXT');
128
140
  this.ensureColumn('thread_cache', 'model_provider', 'TEXT');
@@ -509,6 +521,54 @@ export class BridgeStore {
509
521
  ON CONFLICT(name) DO UPDATE SET disabled = excluded.disabled, updated_at = excluded.updated_at
510
522
  `).run(name, disabled ? 1 : 0, Date.now());
511
523
  }
524
+ setCodexAuthQuotaSnapshot(runtimeId, candidateName, accountId, snapshot) {
525
+ this.db.prepare(`
526
+ INSERT INTO codex_auth_quota_snapshots (
527
+ runtime_id,
528
+ candidate_name,
529
+ account_id,
530
+ captured_at_ms,
531
+ primary_remaining_percent,
532
+ secondary_remaining_percent,
533
+ updated_at
534
+ )
535
+ VALUES (?, ?, ?, ?, ?, ?, ?)
536
+ ON CONFLICT(runtime_id, candidate_name) DO UPDATE SET
537
+ account_id = excluded.account_id,
538
+ captured_at_ms = excluded.captured_at_ms,
539
+ primary_remaining_percent = excluded.primary_remaining_percent,
540
+ secondary_remaining_percent = excluded.secondary_remaining_percent,
541
+ updated_at = excluded.updated_at
542
+ `).run(runtimeId, candidateName, accountId, snapshot.capturedAtMs, snapshot.primaryRemainingPercent, snapshot.secondaryRemainingPercent, Date.now());
543
+ }
544
+ listCodexAuthQuotaSnapshots(accountIds) {
545
+ const uniqueAccountIds = [...new Set(accountIds.filter(Boolean))];
546
+ if (uniqueAccountIds.length === 0) {
547
+ return [];
548
+ }
549
+ const placeholders = uniqueAccountIds.map(() => '?').join(', ');
550
+ const rows = this.db.prepare(`
551
+ SELECT
552
+ runtime_id,
553
+ candidate_name,
554
+ account_id,
555
+ captured_at_ms,
556
+ primary_remaining_percent,
557
+ secondary_remaining_percent,
558
+ updated_at
559
+ FROM codex_auth_quota_snapshots
560
+ WHERE account_id IN (${placeholders})
561
+ `).all(...uniqueAccountIds);
562
+ return rows.map(row => ({
563
+ runtimeId: String(row.runtime_id),
564
+ candidateName: String(row.candidate_name),
565
+ accountId: String(row.account_id),
566
+ capturedAtMs: Number(row.captured_at_ms),
567
+ primaryRemainingPercent: nullableNumber(row.primary_remaining_percent),
568
+ secondaryRemainingPercent: nullableNumber(row.secondary_remaining_percent),
569
+ updatedAt: Number(row.updated_at),
570
+ }));
571
+ }
512
572
  ensureColumn(table, column, definition) {
513
573
  const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
514
574
  if (columns.some(entry => entry.name === column)) {
@@ -517,6 +577,13 @@ export class BridgeStore {
517
577
  this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
518
578
  }
519
579
  }
580
+ function nullableNumber(value) {
581
+ if (value === null || value === undefined) {
582
+ return null;
583
+ }
584
+ const numberValue = Number(value);
585
+ return Number.isFinite(numberValue) ? numberValue : null;
586
+ }
520
587
  function normalizeCollaborationMode(value) {
521
588
  return value === 'default' || value === 'plan' ? value : null;
522
589
  }
@@ -359,7 +359,7 @@ FoxClaw recognizes candidate names in these forms:
359
359
  - `auth.json.<name>`
360
360
  - `auth.json-<name>`
361
361
 
362
- `auth.json` is what Codex currently uses. When switching accounts, FoxClaw points `auth.json` at one candidate. Candidate contents are Codex-generated JSON and should not be hand-written. In multi-bot mode, FoxClaw mirrors a candidate only when its account identity matches and its refresh timestamp is newer, preventing a same-name candidate from overwriting a different account.
362
+ `auth.json` is what Codex currently uses. When switching accounts, FoxClaw points `auth.json` at one candidate. Candidate contents are Codex-generated JSON and should not be hand-written. In multi-bot mode, FoxClaw mirrors a candidate only when its account identity matches, its refresh timestamp is newer, and the active app-server verifies it against the ChatGPT usage endpoint, preventing a same-name candidate from overwriting a different account. Before an auth switch or reload, FoxClaw also searches the other Codex homes for a newer credential with the same account ID, restores it into the requesting runtime, then verifies and mirrors it after restart.
363
363
 
364
364
  If you already have a working `auth.json`, you can save it as a candidate:
365
365
 
@@ -389,7 +389,7 @@ If the login is cancelled or fails, FoxClaw tries to restore the previous auth t
389
389
 
390
390
  ### 6.3 The `/auth` Panel
391
391
 
392
- `/auth` lists candidate accounts, the current account, and the auth directory. It also provides buttons for switching, disabling, login, and reload. In multi-bot mode the panel names the `@botname` runtime being managed, because private chats, groups, and topics on one bot share that bot's current auth. The `5h|7d` numbers before each filename are the last recorded remaining percentages for the two quota windows; the current auth is refreshed when the panel opens, while other candidates are not switched merely to query quota.
392
+ `/auth` lists candidate accounts, the current account, and the auth directory. It also provides buttons for switching, disabling, login, and reload. In multi-bot mode the panel names the `@botname` runtime being managed, because private chats, groups, and topics on one bot share that bot's current auth. The `5h|7d` numbers before each filename are the last recorded remaining percentages for the two quota windows; the current auth is refreshed when the panel opens, while other candidates are not switched merely to query quota. When multiple bot runtimes have recently used the same ChatGPT account, FoxClaw combines their cached quota snapshots by verified account ID, so one bot's `/auth` panel can show quota information learned by another bot without mixing different accounts.
393
393
 
394
394
  Approximation:
395
395
 
@@ -359,7 +359,7 @@ FoxClaw 识别这些候选文件名:
359
359
  - `auth.json.<name>`
360
360
  - `auth.json-<name>`
361
361
 
362
- `auth.json` 是 Codex 当前使用的文件。切换账号时,FoxClaw 会把 `auth.json` 指向某个候选文件。候选文件内容是 Codex CLI 生成的 JSON,不建议手写这些字段。多 bot 镜像只在能校验同一账号标识且刷新时间更新时复制候选,避免同名候选意外覆盖成另一个账号。
362
+ `auth.json` 是 Codex 当前使用的文件。切换账号时,FoxClaw 会把 `auth.json` 指向某个候选文件。候选文件内容是 Codex CLI 生成的 JSON,不建议手写这些字段。多 bot 镜像只在账号标识一致、刷新时间更新,并且由当前 app-server 通过 ChatGPT 用量接口在线验证时复制候选,避免同名候选意外覆盖成另一个账号。切换或重载 auth 之前,FoxClaw 还会在其他 Codex home 中查找同账号 ID 的较新凭据,先恢复到发起操作的 runtime,再在重启后验证并镜像。
363
363
 
364
364
  如果你已经有一个可用的 `auth.json`,可以先备份成候选:
365
365
 
@@ -389,7 +389,7 @@ cp -L ~/.codex/auth.json ~/.codex/auth.json_personal
389
389
 
390
390
  ### 6.3 `/auth` 面板
391
391
 
392
- `/auth` 会列出候选账号、当前账号和 auth 目录,并提供按钮切换、禁用、登录和重载。多 bot 模式中,面板顶部还会显示当前正在管理的 `@botname`,因为该 bot 内的私聊、群聊和话题共享同一个当前 auth。每个候选名前的 `5h|7d` 数字表示上次记录到的两个额度窗口剩余百分比;当前 auth 会在打开面板时刷新,其他候选不会为了查询额度被自动切换。
392
+ `/auth` 会列出候选账号、当前账号和 auth 目录,并提供按钮切换、禁用、登录和重载。多 bot 模式中,面板顶部还会显示当前正在管理的 `@botname`,因为该 bot 内的私聊、群聊和话题共享同一个当前 auth。每个候选名前的 `5h|7d` 数字表示上次记录到的两个额度窗口剩余百分比;当前 auth 会在打开面板时刷新,其他候选不会为了查询额度被自动切换。如果多个 bot runtime 最近使用过同一个 ChatGPT 账号,FoxClaw 会按已验证的账号 ID 合并它们缓存到的额度快照,因此一个 bot 的 `/auth` 面板可以显示另一个 bot 掌握到的额度信息,同时不会把不同账号混在一起。
393
393
 
394
394
  示意:
395
395
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -66,6 +66,7 @@ Telegram behavior:
66
66
  - `TG_ALLOWED_CHAT_ID` and `TG_ALLOWED_TOPIC_ID` choose the default group or topic scope; they do not disable private chat
67
67
  - when `TG_BOT_TOKENS` contains multiple bots, one FoxClaw service starts independent Codex app-servers and auth selections per bot
68
68
  - isolated Telegram runtimes use independent `CODEX_HOME` directories and file-backed credentials; they do not share Codex sessions
69
+ - refreshed ChatGPT candidates are mirrored only after online usage-endpoint validation; before auth switch or reload, a runtime restores any newer same-account credential found in another Codex home
69
70
  - if `TG_BOT_TOKEN` is also set to one exact token from `TG_BOT_TOKENS`, that bot uses the default/shared-terminal runtime instead of an isolated Telegram home
70
71
  - if Weixin is enabled alongside multiple Telegram bots, it remains on the default Codex runtime instead of borrowing a Telegram bot runtime
71
72
  - in a group with multiple configured bots, address a bot by mention, reply, or suffixed command such as `/status@botname`
@@ -146,7 +147,7 @@ After either bootstrap path:
146
147
  - the bot is an admin in the group
147
148
  - the configured `TG_ALLOWED_CHAT_ID` and `TG_ALLOWED_TOPIC_ID` match the target group/topic
148
149
  5. If group or topic mode is enabled, also verify that private chat still responds for the configured `TG_ALLOWED_USER_ID`.
149
- 6. With multiple bots, send `/status` and `/auth` privately to each bot; verify the status lists each app-server and the auth panel names the intended bot runtime.
150
+ 6. With multiple bots, send `/status` and `/auth` privately to each bot; verify the status lists each app-server, the auth panel names the intended bot runtime, and `/auth` quota snapshots are combined only for matching ChatGPT account IDs.
150
151
 
151
152
  ## First Telegram Message Check
152
153