@foxden-app/foxclaw 0.4.10 → 0.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.4.12 - 2026-06-02
6
+
7
+ ### 中文
8
+ - `/auth` 面板顶部、候选列表和切换按钮省略标准候选名中重复的 `auth.json_` 前缀,让额度与账号标识在窄屏 Telegram 客户端中更容易完整显示。
9
+ - 磁盘文件名、文件名搜索、候选编号、切换和镜像同步行为保持不变;非标准候选名仍原样展示。
10
+
11
+ ### English
12
+ - Omitted the repeated `auth.json_` prefix from the `/auth` panel header, candidate rows, and switch buttons so quota and account labels fit better in narrow Telegram clients.
13
+ - Kept filenames on disk, filename search, candidate numbering, switching, and mirroring unchanged. Non-standard candidate names still render verbatim.
14
+
15
+ ## 0.4.11 - 2026-06-02
16
+
17
+ ### 中文
18
+ - `/auth` 面板改为每页 8 个候选,新增翻页、`全部 / 已启用 / 需关注` 筛选、文件名搜索和直接跳页命令,支持管理较大的本地候选清单。
19
+ - 额度快照保存真实窗口时长与套餐类型,面板不再假定所有账号都有 5 小时和 7 天窗口;单一月额度账号可显示为 `30d:97`。
20
+ - 候选摘要新增正常、额度偏低、额度耗尽、额度未知、长期未刷新、API key 和无效 auth 文件状态;超过约 8 天未刷新只作为维护提醒,不会触发批量保活刷新。
21
+
22
+ ### English
23
+ - Paginated `/auth` at 8 candidates per page and added paging, `All / Enabled / Attention` filters, filename search, and direct page commands for larger local inventories.
24
+ - Persisted observed quota-window lengths and plan types so the panel no longer assumes every account has 5-hour and 7-day windows; a single monthly allowance can render as `30d:97`.
25
+ - Added ready, low-quota, exhausted, unknown, not-recently-refreshed, API-key, and invalid-auth-file summaries. The roughly 8-day stale hint remains informational and never triggers bulk keepalive refreshes.
26
+
5
27
  ## 0.4.10 - 2026-06-02
6
28
 
7
29
  ### 中文
package/README.md CHANGED
@@ -16,7 +16,7 @@ FoxClaw(狸爪)的目标很直接:让你用手机控制本机的 Codex,
16
16
 
17
17
  1. **开源且接口完整** — Codex 是 OpenAI 开源的 CLI agent,并提供 `codex app-server`。FoxClaw 不是模拟终端输入,而是通过更完整的接口读取线程、切换模型、处理审批和恢复会话。
18
18
  2. **当前编码体验强** — FoxClaw 直接读取你本机 Codex 可用的模型列表;如果你的 Codex 环境已经有 GPT-5.5,就可以在手机端选择和使用它。对高强度编程工作流来说,Codex/GPT-5.5 的体感已经是很多人优先选择它的原因。
19
- 3. **多账号额度切换** — 免费额度、一个月 Plus/Team 试用、多账号小额度都可以纳入本地 `auth.json_*` 候选。5 小时限制一到,FoxClaw 自动切换下一个可用账号、重启 app-server 并重试刚才的请求。
19
+ 3. **多账号额度切换** — 免费额度、Plus/Team 试用、多账号小额度都可以纳入本地 `auth.json_*` 候选。当前账号触发用量限制时,FoxClaw 自动切换下一个可用账号、重启 app-server 并重试刚才的请求。
20
20
 
21
21
  **适合这些场景:**
22
22
 
@@ -101,8 +101,8 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
101
101
 
102
102
  **多账号管理:**
103
103
  - Codex 账户管理:`/account`、`/quota`、`/login_device`、`/auth add <name>`
104
- - 触发用量限制时自动在本地 `auth.json_*` 之间切换认证——5 小时限制到了自动换号
105
- - `/auth` 面板查看、启用、禁用、切换候选账号;多 bot 模式下会按账号 ID 汇总各 runtime 最近掌握的额度快照
104
+ - 触发用量限制时自动在本地 `auth.json_*` 之间切换认证
105
+ - `/auth` 面板分页查看、筛选、启用、禁用、搜索和切换候选账号;额度按真实窗口展示,多 bot 模式下会按账号 ID 汇总各 runtime 最近掌握的额度快照
106
106
 
107
107
  **线程与会话:**
108
108
  - `/threads`、`/open`、`/new`、`/where`、`/interrupt`——稳定的聊天-线程绑定
@@ -119,7 +119,7 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
119
119
 
120
120
  ## 多账号切换
121
121
 
122
- FoxClaw 的一大特色是自动多账号切换。当一个账号的 5 小时用量限制触发时,FoxClaw 会自动切换到下一个可用账号继续工作。
122
+ FoxClaw 的一大特色是自动多账号切换。当一个账号触发用量限制时,FoxClaw 会自动切换到下一个可用账号继续工作。
123
123
 
124
124
  设置方式:
125
125
 
@@ -128,6 +128,8 @@ FoxClaw 的一大特色是自动多账号切换。当一个账号的 5 小时用
128
128
  3. 用 `/auth` 查看所有候选账号状态。
129
129
  4. 用 `/auth enable <n>` / `/auth disable <n>` 控制哪些账号参与自动轮换。
130
130
 
131
+ 候选较多时,`/auth` 每页显示 8 个账号,并提供翻页、`全部 / 已启用 / 需关注` 筛选。也可以用 `/auth list <关键词>` 搜索文件名,或用 `/auth page <页码>` 直接跳页。额度窗口按 Codex 实际返回值展示,例如 `5h:20|7d:25` 或单月窗口 `30d:97`。面板会省略候选名中重复的 `auth.json_` 前缀,磁盘文件名保持不变。
132
+
131
133
  当 Codex 报告用量限制错误时,FoxClaw 会自动:
132
134
  - 切换到下一个未失败的候选账号
133
135
  - 重启 app-server 加载新认证
@@ -258,7 +260,7 @@ FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pi
258
260
  - `/status`、`/account`、`/quota`、`/update`
259
261
  - `/quota_nudge <credits|usage_limit> confirm`
260
262
  - `/login_device`、`/login_cancel [id]`、`/logout confirm`
261
- - `/auth [list|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|add <name>]`
263
+ - `/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|add <name>]`
262
264
  - `/threads [query]`、`/threads archived`、`/open <n>`
263
265
  - `/goal [objective|pause|resume|done|budget <tokens|off>|clear confirm]`
264
266
  - `/history [limit]`、`/files <query>`、`/remote`
package/README_EN.md CHANGED
@@ -16,7 +16,7 @@ No public server required. FoxClaw runs on your own computer, talks to `codex ap
16
16
 
17
17
  1. **Open source with complete APIs** — Codex is OpenAI's open-source CLI agent and ships `codex app-server`. FoxClaw does not scrape a terminal; it uses the app-server interface to read threads, switch models, handle approvals, and resume sessions.
18
18
  2. **Strong current coding experience** — FoxClaw reads the model list from your local Codex app-server. If your Codex environment has GPT-5.5 available, you can select and use it from your phone. For many heavy coding workflows, Codex/GPT-5.5 is already the reason to choose this stack.
19
- 3. **Multi-account quota rotation** — Free quota, trial Plus/Team accounts, and small account-specific allowances can all live as local `auth.json_*` candidates. When one account hits a 5-hour usage limit, FoxClaw switches to the next available account, restarts app-server, and retries the failed request.
19
+ 3. **Multi-account quota rotation** — Free quota, trial Plus/Team accounts, and small account-specific allowances can all live as local `auth.json_*` candidates. When one account hits a usage limit, FoxClaw switches to the next available account, restarts app-server, and retries the failed request.
20
20
 
21
21
  **Built for these scenarios:**
22
22
 
@@ -102,7 +102,7 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
102
102
  **Multi-account management:**
103
103
  - Codex account controls: `/account`, `/quota`, `/login_device`, `/auth add <name>`
104
104
  - Automatic auth rotation across local `auth.json_*` files when a usage limit is hit — seamless account switching
105
- - `/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
+ - Paginated `/auth` panel to filter, enable, disable, search, and switch candidate accounts; quota uses observed window lengths, and multi-bot mode merges recent snapshots by account ID across runtimes
106
106
 
107
107
  **Threads and sessions:**
108
108
  - `/threads`, `/open`, `/new`, `/where`, `/interrupt` — sticky chat-to-thread binding
@@ -119,7 +119,7 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
119
119
 
120
120
  ## Multi-Account Rotation
121
121
 
122
- A key FoxClaw feature is automatic multi-account switching. When one account's 5-hour usage limit is triggered, FoxClaw automatically switches to the next available account and continues working.
122
+ A key FoxClaw feature is automatic multi-account switching. When one account's usage limit is triggered, FoxClaw automatically switches to the next available account and continues working.
123
123
 
124
124
  Setup:
125
125
 
@@ -128,6 +128,8 @@ Setup:
128
128
  3. Use `/auth` to view all candidate account statuses.
129
129
  4. Use `/auth enable <n>` / `/auth disable <n>` to control which accounts participate in auto-rotation.
130
130
 
131
+ For larger inventories, `/auth` shows 8 candidates per page with paging and `All / Enabled / Attention` filters. Use `/auth list <keyword>` to search filenames or `/auth page <n>` to jump directly. Quota windows use the values reported by Codex, for example `5h:20|7d:25` or a single monthly window such as `30d:97`. The panel omits the repeated `auth.json_` prefix from candidate labels without renaming files on disk.
132
+
131
133
  When Codex reports a usage-limit error, FoxClaw automatically:
132
134
  - Switches to the next non-failed candidate account
133
135
  - Restarts app-server to load the new auth
@@ -258,7 +260,7 @@ No static Codex app-server port is required in normal installs.
258
260
  - `/status`, `/account`, `/quota`, `/update`
259
261
  - `/quota_nudge <credits|usage_limit> confirm`
260
262
  - `/login_device`, `/login_cancel [id]`, `/logout confirm`
261
- - `/auth [list|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|add <name>]`
263
+ - `/auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|add <name>]`
262
264
  - `/threads [query]`, `/threads archived`, `/open <n>`
263
265
  - `/goal [objective|pause|resume|done|budget <tokens|off>|clear confirm]`
264
266
  - `/history [limit]`, `/files <query>`, `/remote`
@@ -279,6 +279,7 @@ export declare class BridgeSessionCore {
279
279
  private listSkillsForScope;
280
280
  private requireReadyBinding;
281
281
  private handleAuthPanelActionCallback;
282
+ private handleAuthListViewCallback;
282
283
  private handleAuthToggleCallback;
283
284
  private handleAuthSwitchCallback;
284
285
  private maybeRunPendingAuthRotation;
@@ -27,6 +27,9 @@ const DEFAULT_COLLABORATION_MODE = 'default';
27
27
  const CODEX_LOCAL_USAGE_REFRESH_MS = 30 * 60_000;
28
28
  const CODEX_LOCAL_USAGE_SNAPSHOT_FILENAME = 'codex-local-usage.json';
29
29
  const CODEX_AUTH_QUOTA_SNAPSHOT_FILENAME = 'codex-auth-quota.json';
30
+ const CODEX_AUTH_LIST_PAGE_SIZE = 8;
31
+ const CODEX_AUTH_LOW_QUOTA_PERCENT = 10;
32
+ const CODEX_AUTH_STALE_CREDENTIAL_DAYS = 8;
30
33
  const USER_INPUT_SUBMITTED_NOTICE_MS = 90_000;
31
34
  const SELF_UPDATE_STATUS_POLL_MS = 1000;
32
35
  const PLAN_IMPLEMENTATION_CODING_MESSAGE = 'Implement the plan.';
@@ -1010,6 +1013,21 @@ export class BridgeSessionCore {
1010
1013
  await this.handleAuthToggleCallback(event, authToggleMatch[1], Number.parseInt(authToggleMatch[2], 10), locale);
1011
1014
  return;
1012
1015
  }
1016
+ const authPageMatch = /^auth:([a-f0-9]+):page:(prev|next)$/.exec(event.data);
1017
+ if (authPageMatch) {
1018
+ await this.handleAuthListViewCallback(event, authPageMatch[1], authPageMatch[2], locale);
1019
+ return;
1020
+ }
1021
+ const authFilterMatch = /^auth:([a-f0-9]+):filter:(all|enabled|attention)$/.exec(event.data);
1022
+ if (authFilterMatch) {
1023
+ await this.handleAuthListViewCallback(event, authFilterMatch[1], authFilterMatch[2], locale);
1024
+ return;
1025
+ }
1026
+ const authClearSearchMatch = /^auth:([a-f0-9]+):clear_search$/.exec(event.data);
1027
+ if (authClearSearchMatch) {
1028
+ await this.handleAuthListViewCallback(event, authClearSearchMatch[1], 'clear_search', locale);
1029
+ return;
1030
+ }
1013
1031
  const authActionMatch = /^auth:([a-f0-9]+):(login_device|reload|refresh_all_confirm|refresh_all_cancel|refresh_all)$/.exec(event.data);
1014
1032
  if (authActionMatch) {
1015
1033
  await this.handleAuthPanelActionCallback(event, authActionMatch[1], authActionMatch[2], locale);
@@ -3893,21 +3911,16 @@ export class BridgeSessionCore {
3893
3911
  await this.handleAuthToggleCommand(scopeId, locale, args.slice(1), action === 'disable');
3894
3912
  return;
3895
3913
  }
3896
- if (action !== 'list') {
3914
+ const listRequest = parseCodexAuthListRequest(action, args);
3915
+ if (!listRequest) {
3897
3916
  await this.sendMessage(scopeId, t(locale, 'usage_auth'));
3898
3917
  return;
3899
3918
  }
3900
3919
  const state = await this.listCodexAuthState();
3901
3920
  await this.refreshCurrentCodexAuthQuota(state);
3902
- const record = {
3903
- localId: crypto.randomBytes(8).toString('hex'),
3904
- chatId: scopeId,
3905
- messageId: null,
3906
- candidates: state.candidates,
3907
- createdAt: Date.now(),
3908
- };
3921
+ const record = createPendingAuthChoiceList(scopeId, state.candidates, listRequest);
3909
3922
  this.pendingAuthChoiceLists.set(record.localId, record);
3910
- const messageId = await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null), authChoiceKeyboard(locale, record));
3923
+ const messageId = await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null, record), authChoiceKeyboard(locale, record));
3911
3924
  record.messageId = messageId;
3912
3925
  }
3913
3926
  async handleAuthRefreshAllCommand(scopeId, locale, confirmed = false) {
@@ -3918,13 +3931,7 @@ export class BridgeSessionCore {
3918
3931
  if (!confirmed) {
3919
3932
  const state = await this.listCodexAuthState();
3920
3933
  await this.applySharedCodexAuthQuotaSnapshots(state);
3921
- const record = {
3922
- localId: crypto.randomBytes(8).toString('hex'),
3923
- chatId: scopeId,
3924
- messageId: null,
3925
- candidates: state.candidates,
3926
- createdAt: Date.now(),
3927
- };
3934
+ const record = createPendingAuthChoiceList(scopeId, state.candidates);
3928
3935
  this.pendingAuthChoiceLists.set(record.localId, record);
3929
3936
  const messageId = await this.sendMessage(scopeId, t(locale, 'auth_refresh_all_confirm_message'), authRefreshAllConfirmKeyboard(locale, record));
3930
3937
  record.messageId = messageId;
@@ -3934,15 +3941,9 @@ export class BridgeSessionCore {
3934
3941
  const result = await this.refreshAllCodexAuthCandidates();
3935
3942
  const state = await this.listCodexAuthState();
3936
3943
  await this.applySharedCodexAuthQuotaSnapshots(state);
3937
- const record = {
3938
- localId: crypto.randomBytes(8).toString('hex'),
3939
- chatId: scopeId,
3940
- messageId: null,
3941
- candidates: state.candidates,
3942
- createdAt: Date.now(),
3943
- };
3944
+ const record = createPendingAuthChoiceList(scopeId, state.candidates);
3944
3945
  this.pendingAuthChoiceLists.set(record.localId, record);
3945
- const messageId = await this.sendMessage(scopeId, `${formatAuthRefreshAllResult(locale, result)}\n\n${renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null)}`, authChoiceKeyboard(locale, record));
3946
+ const messageId = await this.sendMessage(scopeId, `${formatAuthRefreshAllResult(locale, result)}\n\n${renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null, record)}`, authChoiceKeyboard(locale, record));
3946
3947
  record.messageId = messageId;
3947
3948
  }
3948
3949
  async handleAuthUseCommand(scopeId, locale, args) {
@@ -4435,7 +4436,7 @@ export class BridgeSessionCore {
4435
4436
  record.candidates = state.candidates;
4436
4437
  record.createdAt = Date.now();
4437
4438
  if (record.messageId !== null) {
4438
- await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null), authChoiceKeyboard(locale, record));
4439
+ await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
4439
4440
  }
4440
4441
  return;
4441
4442
  }
@@ -4454,13 +4455,46 @@ export class BridgeSessionCore {
4454
4455
  record.candidates = state.candidates;
4455
4456
  record.createdAt = Date.now();
4456
4457
  if (record.messageId !== null) {
4457
- await this.editMessage(event.scopeId, record.messageId, `${formatAuthRefreshAllResult(locale, result)}\n\n${renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null)}`, authChoiceKeyboard(locale, record));
4458
+ await this.editMessage(event.scopeId, record.messageId, `${formatAuthRefreshAllResult(locale, result)}\n\n${renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record)}`, authChoiceKeyboard(locale, record));
4458
4459
  }
4459
4460
  return;
4460
4461
  }
4461
4462
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_reload_restarting'));
4462
4463
  await this.handleAuthReloadCommand(event.scopeId, locale);
4463
4464
  }
4465
+ async handleAuthListViewCallback(event, localId, action, locale) {
4466
+ const record = this.pendingAuthChoiceLists.get(localId);
4467
+ if (!record) {
4468
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_expired'));
4469
+ return;
4470
+ }
4471
+ if (record.chatId !== event.scopeId || (record.messageId !== null && record.messageId !== event.messageId)) {
4472
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_mismatch'));
4473
+ return;
4474
+ }
4475
+ if (action === 'prev') {
4476
+ record.offset = Math.max(0, record.offset - record.pageSize);
4477
+ }
4478
+ else if (action === 'next') {
4479
+ record.offset += record.pageSize;
4480
+ }
4481
+ else if (action === 'clear_search') {
4482
+ record.searchTerm = null;
4483
+ record.offset = 0;
4484
+ }
4485
+ else {
4486
+ record.filter = action;
4487
+ record.offset = 0;
4488
+ }
4489
+ const state = await this.listCodexAuthState();
4490
+ record.candidates = state.candidates;
4491
+ record.createdAt = Date.now();
4492
+ clampCodexAuthListOffset(record);
4493
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
4494
+ if (record.messageId !== null) {
4495
+ await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
4496
+ }
4497
+ }
4464
4498
  async handleAuthToggleCallback(event, localId, index, locale) {
4465
4499
  const record = this.pendingAuthChoiceLists.get(localId);
4466
4500
  if (!record) {
@@ -4480,9 +4514,10 @@ export class BridgeSessionCore {
4480
4514
  this.store.setCodexAuthCandidateDisabled(candidate.name, disabled, this.authRuntimeId());
4481
4515
  const state = await this.listCodexAuthState();
4482
4516
  record.candidates = state.candidates;
4517
+ clampCodexAuthListOffset(record);
4483
4518
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, disabled ? 'auth_candidate_disabled_short' : 'auth_candidate_enabled_short'));
4484
4519
  if (record.messageId !== null) {
4485
- await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null), authChoiceKeyboard(locale, record));
4520
+ await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
4486
4521
  }
4487
4522
  }
4488
4523
  async handleAuthSwitchCallback(event, localId, index, locale) {
@@ -4515,8 +4550,9 @@ export class BridgeSessionCore {
4515
4550
  await this.refreshCurrentCodexAuthQuota(state);
4516
4551
  record.candidates = state.candidates;
4517
4552
  record.createdAt = Date.now();
4553
+ clampCodexAuthListOffset(record);
4518
4554
  if (record.messageId !== null) {
4519
- await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null), authChoiceKeyboard(locale, record));
4555
+ await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
4520
4556
  }
4521
4557
  }
4522
4558
  async maybeRunPendingAuthRotation() {
@@ -5009,6 +5045,12 @@ export class BridgeSessionCore {
5009
5045
  async readCodexAuthCandidateAccountIds(candidates) {
5010
5046
  const entries = await Promise.all(candidates.map(async (candidate) => {
5011
5047
  const metadata = await readChatGptAuthMetadata(candidate.path);
5048
+ candidate.credentialKind = metadata
5049
+ ? 'chatgpt'
5050
+ : await isCodexApiKeyAuthCandidate(candidate.path)
5051
+ ? 'api-key'
5052
+ : 'invalid';
5053
+ candidate.credentialLastRefreshMs = metadata?.lastRefreshMs ?? null;
5012
5054
  return [candidate.name, metadata?.accountId ?? null];
5013
5055
  }));
5014
5056
  const accountIds = new Map();
@@ -5038,7 +5080,7 @@ export class BridgeSessionCore {
5038
5080
  if (parsed && typeof parsed === 'object') {
5039
5081
  for (const [name, value] of Object.entries(parsed)) {
5040
5082
  if (isCodexAuthQuotaSnapshot(value)) {
5041
- this.authQuotaSnapshots[name] = value;
5083
+ this.authQuotaSnapshots[name] = normalizeCodexAuthQuotaSnapshot(value);
5042
5084
  }
5043
5085
  }
5044
5086
  }
@@ -7547,6 +7589,8 @@ async function listCodexAuthState(disabledNames = new Set(), explicitAuthDir = n
7547
7589
  isCurrent: currentTargetPath === candidatePath,
7548
7590
  disabled: disabledNames.has(entry.name),
7549
7591
  mtimeMs: stat.mtimeMs,
7592
+ credentialKind: 'invalid',
7593
+ credentialLastRefreshMs: null,
7550
7594
  quota: null,
7551
7595
  });
7552
7596
  }
@@ -7565,6 +7609,15 @@ function isCodexAuthCandidateName(name) {
7565
7609
  }
7566
7610
  return name.startsWith('auth.json_') || name.startsWith('auth.json.') || name.startsWith('auth.json-');
7567
7611
  }
7612
+ async function isCodexApiKeyAuthCandidate(candidatePath) {
7613
+ try {
7614
+ const parsed = JSON.parse(await fs.readFile(candidatePath, 'utf8'));
7615
+ return typeof parsed.openai_api_key === 'string' && parsed.openai_api_key.trim().length > 0;
7616
+ }
7617
+ catch {
7618
+ return false;
7619
+ }
7620
+ }
7568
7621
  function codexAuthCandidateNameFromAddName(raw) {
7569
7622
  const normalized = raw.trim();
7570
7623
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(normalized)) {
@@ -7657,14 +7710,86 @@ async function switchCodexAuth(targetPath, explicitAuthDir = null) {
7657
7710
  toLabel: await authPathDisplayLabel(candidate.path),
7658
7711
  };
7659
7712
  }
7660
- function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopyPaste = false) {
7713
+ function parseCodexAuthListRequest(action, args) {
7714
+ if (action === 'list') {
7715
+ return { searchTerm: args.slice(1).join(' ').trim() || null };
7716
+ }
7717
+ if (action === 'filter') {
7718
+ const filter = args[1]?.toLowerCase();
7719
+ return filter === 'all' || filter === 'enabled' || filter === 'attention'
7720
+ ? { filter }
7721
+ : null;
7722
+ }
7723
+ if (action === 'page') {
7724
+ const page = Number.parseInt(args[1] ?? '', 10);
7725
+ return Number.isFinite(page) && page > 0
7726
+ ? { offset: (page - 1) * CODEX_AUTH_LIST_PAGE_SIZE }
7727
+ : null;
7728
+ }
7729
+ return null;
7730
+ }
7731
+ function createPendingAuthChoiceList(chatId, candidates, view = {}) {
7732
+ const record = {
7733
+ localId: crypto.randomBytes(8).toString('hex'),
7734
+ chatId,
7735
+ messageId: null,
7736
+ candidates,
7737
+ createdAt: Date.now(),
7738
+ offset: Math.max(0, view.offset ?? 0),
7739
+ pageSize: CODEX_AUTH_LIST_PAGE_SIZE,
7740
+ filter: view.filter ?? 'all',
7741
+ searchTerm: view.searchTerm?.trim() || null,
7742
+ };
7743
+ clampCodexAuthListOffset(record);
7744
+ return record;
7745
+ }
7746
+ function clampCodexAuthListOffset(view) {
7747
+ const filteredCount = filterCodexAuthCandidates(view.candidates, view).length;
7748
+ const finalOffset = filteredCount > 0
7749
+ ? Math.floor((filteredCount - 1) / view.pageSize) * view.pageSize
7750
+ : 0;
7751
+ view.offset = Math.min(Math.max(0, view.offset), finalOffset);
7752
+ }
7753
+ function codexAuthListPage(candidates, view) {
7754
+ const pageSize = Math.max(1, view?.pageSize ?? CODEX_AUTH_LIST_PAGE_SIZE);
7755
+ const filter = view?.filter ?? 'all';
7756
+ const searchTerm = view?.searchTerm?.trim() || null;
7757
+ const indexed = filterCodexAuthCandidates(candidates, { filter, searchTerm });
7758
+ const finalOffset = indexed.length > 0
7759
+ ? Math.floor((indexed.length - 1) / pageSize) * pageSize
7760
+ : 0;
7761
+ const offset = Math.min(Math.max(0, view?.offset ?? 0), finalOffset);
7762
+ return {
7763
+ visible: indexed.slice(offset, offset + pageSize),
7764
+ filteredCount: indexed.length,
7765
+ offset,
7766
+ pageIndex: Math.floor(offset / pageSize),
7767
+ pageCount: Math.max(1, Math.ceil(indexed.length / pageSize)),
7768
+ filter,
7769
+ searchTerm,
7770
+ };
7771
+ }
7772
+ function filterCodexAuthCandidates(candidates, view) {
7773
+ const searchTerm = view.searchTerm?.trim().toLowerCase() || null;
7774
+ return candidates
7775
+ .map((candidate, index) => ({ candidate, index }))
7776
+ .filter(({ candidate }) => !searchTerm || candidate.name.toLowerCase().includes(searchTerm))
7777
+ .filter(({ candidate }) => view.filter === 'all'
7778
+ || (view.filter === 'enabled' && !candidate.disabled)
7779
+ || (view.filter === 'attention' && codexAuthCandidateNeedsAttention(candidate)));
7780
+ }
7781
+ function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopyPaste = false, view = null) {
7661
7782
  const lines = [
7662
7783
  t(locale, 'auth_list_title'),
7663
7784
  ];
7664
7785
  if (botLabel) {
7665
7786
  lines.push(t(locale, 'auth_bot', { value: botLabel }));
7666
7787
  }
7667
- lines.push(t(locale, 'auth_current', { value: state.currentLabel ?? t(locale, 'none') }), t(locale, 'auth_dir', { value: state.authDir }));
7788
+ lines.push(t(locale, 'auth_current', {
7789
+ value: state.currentLabel
7790
+ ? formatCodexAuthCandidateDisplayName(state.currentLabel)
7791
+ : t(locale, 'none'),
7792
+ }), t(locale, 'auth_dir', { value: state.authDir }));
7668
7793
  if (state.candidates.length === 0) {
7669
7794
  lines.push(t(locale, 'auth_no_candidates'));
7670
7795
  if (includeWeixinCopyPaste) {
@@ -7674,24 +7799,42 @@ function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopy
7674
7799
  }
7675
7800
  lines.push(t(locale, 'auth_candidate_count', { value: state.candidates.length }));
7676
7801
  lines.push(t(locale, 'auth_quota_legend'));
7677
- state.candidates.forEach((candidate, index) => {
7802
+ const page = codexAuthListPage(state.candidates, view);
7803
+ if (page.searchTerm) {
7804
+ lines.push(t(locale, 'auth_search', { value: page.searchTerm }));
7805
+ }
7806
+ if (page.filter !== 'all') {
7807
+ lines.push(t(locale, 'auth_filter', { value: formatCodexAuthFilter(locale, page.filter) }));
7808
+ }
7809
+ if (page.filteredCount !== state.candidates.length || page.pageCount > 1) {
7810
+ lines.push(t(locale, 'auth_page', {
7811
+ from: page.filteredCount === 0 ? 0 : page.offset + 1,
7812
+ to: page.offset + page.visible.length,
7813
+ filtered: page.filteredCount,
7814
+ total: state.candidates.length,
7815
+ page: page.pageIndex + 1,
7816
+ pages: page.pageCount,
7817
+ }));
7818
+ }
7819
+ if (page.visible.length === 0) {
7820
+ lines.push(t(locale, 'auth_no_matches'));
7821
+ }
7822
+ page.visible.forEach(({ candidate, index }) => {
7678
7823
  const marker = candidate.isCurrent ? ' *' : '';
7679
- const status = candidate.disabled
7680
- ? t(locale, 'auth_candidate_status_disabled')
7681
- : t(locale, 'auth_candidate_status_enabled');
7682
- lines.push(`${index + 1}. ${formatAuthQuotaPrefix(candidate.quota)}|${candidate.name}${marker} [${status}]`);
7824
+ lines.push(`${index + 1}. ${formatAuthQuotaPrefix(locale, candidate.quota)}|${formatCodexAuthCandidateDisplayName(candidate.name)}${marker} ${formatCodexAuthCandidateStatus(locale, candidate)}`);
7683
7825
  });
7684
7826
  if (includeWeixinCopyPaste) {
7685
- lines.push('', t(locale, 'weixin_copy_paste_divider'), t(locale, 'weixin_copy_auth_title'), ...state.candidates.map((_candidate, index) => `/auth use ${index + 1}`), ...state.candidates.map((candidate, index) => candidate.disabled
7827
+ lines.push('', t(locale, 'weixin_copy_paste_divider'), t(locale, 'weixin_copy_auth_title'), ...page.visible.map(({ index }) => `/auth use ${index + 1}`), ...page.visible.map(({ candidate, index }) => candidate.disabled
7686
7828
  ? `/auth enable ${index + 1}`
7687
- : `/auth disable ${index + 1}`), '/login_device', '/auth reload', '/permissions');
7829
+ : `/auth disable ${index + 1}`), '/auth filter all', '/auth filter enabled', '/auth filter attention', '/auth list <keyword>', '/login_device', '/auth reload', '/permissions');
7688
7830
  }
7689
7831
  return lines.join('\n');
7690
7832
  }
7691
7833
  function authChoiceKeyboard(locale, record) {
7692
- const rows = record.candidates.map((candidate, index) => [
7834
+ const page = codexAuthListPage(record.candidates, record);
7835
+ const rows = page.visible.map(({ candidate, index }) => [
7693
7836
  {
7694
- text: clipButtonText(`${candidate.isCurrent ? '✅ ' : '🔐 '}${formatAuthQuotaPrefix(candidate.quota)}|${candidate.name}${candidate.disabled ? ' · off' : ''}`),
7837
+ text: clipButtonText(`${candidate.isCurrent ? '✅ ' : '🔐 '}${formatAuthQuotaPrefix(locale, candidate.quota)}|${formatCodexAuthCandidateDisplayName(candidate.name)}${candidate.disabled ? ' · off' : ''}`),
7695
7838
  callback_data: `auth:${record.localId}:${index}`,
7696
7839
  },
7697
7840
  {
@@ -7699,6 +7842,24 @@ function authChoiceKeyboard(locale, record) {
7699
7842
  callback_data: `auth:${record.localId}:toggle:${index}`,
7700
7843
  },
7701
7844
  ]);
7845
+ const navigationRow = [];
7846
+ if (page.offset > 0) {
7847
+ navigationRow.push({ text: t(locale, 'button_prev_page'), callback_data: `auth:${record.localId}:page:prev` });
7848
+ }
7849
+ if (page.offset + page.visible.length < page.filteredCount) {
7850
+ navigationRow.push({ text: t(locale, 'button_next_page'), callback_data: `auth:${record.localId}:page:next` });
7851
+ }
7852
+ if (navigationRow.length > 0) {
7853
+ rows.push(navigationRow);
7854
+ }
7855
+ rows.push([
7856
+ authFilterButton(locale, record, 'all'),
7857
+ authFilterButton(locale, record, 'enabled'),
7858
+ authFilterButton(locale, record, 'attention'),
7859
+ ]);
7860
+ if (record.searchTerm) {
7861
+ rows.push([{ text: t(locale, 'button_clear_filter'), callback_data: `auth:${record.localId}:clear_search` }]);
7862
+ }
7702
7863
  rows.push([
7703
7864
  { text: t(locale, 'button_permissions'), callback_data: 'nav:permissions' },
7704
7865
  { text: t(locale, 'button_login_device'), callback_data: `auth:${record.localId}:login_device` },
@@ -7706,6 +7867,72 @@ function authChoiceKeyboard(locale, record) {
7706
7867
  rows.push([{ text: t(locale, 'button_auth_reload'), callback_data: `auth:${record.localId}:reload` }]);
7707
7868
  return rows;
7708
7869
  }
7870
+ function formatCodexAuthCandidateDisplayName(name) {
7871
+ const prefix = 'auth.json_';
7872
+ return name.length > prefix.length && name.startsWith(prefix)
7873
+ ? name.slice(prefix.length)
7874
+ : name;
7875
+ }
7876
+ function authFilterButton(locale, record, filter) {
7877
+ const label = t(locale, `button_auth_filter_${filter}`);
7878
+ return {
7879
+ text: record.filter === filter ? `☑️ ${label}` : label,
7880
+ callback_data: `auth:${record.localId}:filter:${filter}`,
7881
+ };
7882
+ }
7883
+ function codexAuthCandidateHealth(candidate) {
7884
+ if (candidate.disabled) {
7885
+ return 'disabled';
7886
+ }
7887
+ if (candidate.credentialKind === 'api-key') {
7888
+ return 'api-key';
7889
+ }
7890
+ if (candidate.credentialKind === 'invalid') {
7891
+ return 'invalid';
7892
+ }
7893
+ const remaining = [
7894
+ candidate.quota?.primaryRemainingPercent,
7895
+ candidate.quota?.secondaryRemainingPercent,
7896
+ ].filter((value) => typeof value === 'number' && Number.isFinite(value));
7897
+ if (remaining.some(value => value <= 0)) {
7898
+ return 'exhausted';
7899
+ }
7900
+ if (remaining.some(value => value <= CODEX_AUTH_LOW_QUOTA_PERCENT)) {
7901
+ return 'low';
7902
+ }
7903
+ if (candidate.credentialLastRefreshMs !== null
7904
+ && candidate.credentialLastRefreshMs < Date.now() - CODEX_AUTH_STALE_CREDENTIAL_DAYS * 24 * 60 * 60_000) {
7905
+ return 'stale';
7906
+ }
7907
+ if (remaining.length === 0) {
7908
+ return 'unknown';
7909
+ }
7910
+ return 'ready';
7911
+ }
7912
+ function codexAuthCandidateNeedsAttention(candidate) {
7913
+ const health = codexAuthCandidateHealth(candidate);
7914
+ return health === 'stale'
7915
+ || health === 'unknown'
7916
+ || health === 'low'
7917
+ || health === 'exhausted'
7918
+ || health === 'invalid';
7919
+ }
7920
+ function formatCodexAuthCandidateStatus(locale, candidate) {
7921
+ const details = [];
7922
+ if (candidate.quota?.planType) {
7923
+ details.push(formatPlanTypeLabel(candidate.quota.planType));
7924
+ }
7925
+ details.push(t(locale, `auth_candidate_health_${codexAuthCandidateHealth(candidate)}`));
7926
+ if (candidate.credentialLastRefreshMs !== null) {
7927
+ details.push(t(locale, 'auth_candidate_last_refresh', {
7928
+ value: formatCompactAge(locale, Date.now() - candidate.credentialLastRefreshMs),
7929
+ }));
7930
+ }
7931
+ return `[${details.join(' · ')}]`;
7932
+ }
7933
+ function formatCodexAuthFilter(locale, filter) {
7934
+ return t(locale, `auth_filter_${filter}`);
7935
+ }
7709
7936
  function authRefreshAllConfirmKeyboard(locale, record) {
7710
7937
  return [
7711
7938
  [{ text: t(locale, 'button_auth_refresh_all_confirm'), callback_data: `auth:${record.localId}:refresh_all_confirm` }],
@@ -8249,6 +8476,33 @@ function formatRateLimitWindowLabel(locale, window, fallback) {
8249
8476
  }
8250
8477
  return locale === 'zh' ? `${formatCompactNumber(minutes)}分钟` : `${formatCompactNumber(minutes)}m window`;
8251
8478
  }
8479
+ function formatCompactRateLimitWindowLabel(locale, minutes, fallback) {
8480
+ if (!minutes || minutes <= 0) {
8481
+ return fallback === 'primary'
8482
+ ? (locale === 'zh' ? '短' : 'P')
8483
+ : (locale === 'zh' ? '长' : 'S');
8484
+ }
8485
+ if (minutes % 1440 === 0) {
8486
+ return `${formatCompactNumber(minutes / 1440)}d`;
8487
+ }
8488
+ if (minutes % 60 === 0) {
8489
+ return `${formatCompactNumber(minutes / 60)}h`;
8490
+ }
8491
+ return `${formatCompactNumber(minutes)}m`;
8492
+ }
8493
+ function formatCompactAge(locale, ageMs) {
8494
+ const minutes = Math.max(0, ageMs) / 60_000;
8495
+ if (minutes >= 1440) {
8496
+ const days = Math.floor(minutes / 1440);
8497
+ return locale === 'zh' ? `${days}天前` : `${days}d ago`;
8498
+ }
8499
+ if (minutes >= 60) {
8500
+ const hours = Math.floor(minutes / 60);
8501
+ return locale === 'zh' ? `${hours}小时前` : `${hours}h ago`;
8502
+ }
8503
+ const roundedMinutes = Math.floor(minutes);
8504
+ return locale === 'zh' ? `${roundedMinutes}分钟前` : `${roundedMinutes}m ago`;
8505
+ }
8252
8506
  function formatUsagePercent(value) {
8253
8507
  if (!Number.isFinite(value)) {
8254
8508
  return '?';
@@ -8263,7 +8517,10 @@ function authQuotaSnapshotFromRateLimit(snapshot, accountId = null) {
8263
8517
  return {
8264
8518
  capturedAtMs: Date.now(),
8265
8519
  accountId,
8520
+ planType: snapshot.planType,
8521
+ primaryWindowDurationMins: snapshot.primary?.windowDurationMins ?? null,
8266
8522
  primaryRemainingPercent: snapshot.primary ? remainingUsagePercent(snapshot.primary.usedPercent) : null,
8523
+ secondaryWindowDurationMins: snapshot.secondary?.windowDurationMins ?? null,
8267
8524
  secondaryRemainingPercent: snapshot.secondary ? remainingUsagePercent(snapshot.secondary.usedPercent) : null,
8268
8525
  };
8269
8526
  }
@@ -8271,7 +8528,10 @@ function codexAuthQuotaSnapshotFromRecord(record) {
8271
8528
  return {
8272
8529
  capturedAtMs: record.capturedAtMs,
8273
8530
  accountId: record.accountId,
8531
+ planType: record.planType,
8532
+ primaryWindowDurationMins: record.primaryWindowDurationMins,
8274
8533
  primaryRemainingPercent: record.primaryRemainingPercent,
8534
+ secondaryWindowDurationMins: record.secondaryWindowDurationMins,
8275
8535
  secondaryRemainingPercent: record.secondaryRemainingPercent,
8276
8536
  };
8277
8537
  }
@@ -8282,25 +8542,19 @@ function mergeCodexAuthQuotaSnapshots(current, incoming) {
8282
8542
  if (!incoming) {
8283
8543
  return current;
8284
8544
  }
8545
+ const freshest = incoming.capturedAtMs >= current.capturedAtMs ? incoming : current;
8546
+ const older = freshest === incoming ? current : incoming;
8285
8547
  return {
8286
- capturedAtMs: Math.max(current.capturedAtMs, incoming.capturedAtMs),
8287
- accountId: current.accountId ?? incoming.accountId ?? null,
8288
- primaryRemainingPercent: fresherNullableQuotaValue(current.primaryRemainingPercent, current.capturedAtMs, incoming.primaryRemainingPercent, incoming.capturedAtMs),
8289
- secondaryRemainingPercent: fresherNullableQuotaValue(current.secondaryRemainingPercent, current.capturedAtMs, incoming.secondaryRemainingPercent, incoming.capturedAtMs),
8548
+ ...freshest,
8549
+ accountId: freshest.accountId ?? older.accountId ?? null,
8290
8550
  };
8291
8551
  }
8292
- function fresherNullableQuotaValue(currentValue, currentCapturedAtMs, incomingValue, incomingCapturedAtMs) {
8293
- if (currentValue === null) {
8294
- return incomingValue;
8295
- }
8296
- if (incomingValue === null) {
8297
- return currentValue;
8298
- }
8299
- return incomingCapturedAtMs >= currentCapturedAtMs ? incomingValue : currentValue;
8300
- }
8301
8552
  function isFiniteCodexAuthQuotaSnapshotRecord(record) {
8302
8553
  return Number.isFinite(record.capturedAtMs)
8554
+ && isNullableString(record.planType)
8555
+ && isNullableFiniteNumber(record.primaryWindowDurationMins)
8303
8556
  && isNullableFiniteNumber(record.primaryRemainingPercent)
8557
+ && isNullableFiniteNumber(record.secondaryWindowDurationMins)
8304
8558
  && isNullableFiniteNumber(record.secondaryRemainingPercent);
8305
8559
  }
8306
8560
  function remainingUsagePercent(usedPercent) {
@@ -8309,13 +8563,18 @@ function remainingUsagePercent(usedPercent) {
8309
8563
  }
8310
8564
  return Math.max(0, Math.min(100, 100 - usedPercent));
8311
8565
  }
8312
- function formatAuthQuotaPrefix(snapshot) {
8566
+ function formatAuthQuotaPrefix(locale, snapshot) {
8313
8567
  if (!snapshot) {
8314
- return '--|--';
8568
+ return '--';
8315
8569
  }
8316
- const primary = snapshot.primaryRemainingPercent === null ? '--' : formatUsagePercent(snapshot.primaryRemainingPercent);
8317
- const secondary = snapshot.secondaryRemainingPercent === null ? '--' : formatUsagePercent(snapshot.secondaryRemainingPercent);
8318
- return `${primary}|${secondary}`;
8570
+ const windows = [
8571
+ [snapshot.primaryWindowDurationMins, snapshot.primaryRemainingPercent, 'primary'],
8572
+ [snapshot.secondaryWindowDurationMins, snapshot.secondaryRemainingPercent, 'secondary'],
8573
+ ];
8574
+ const values = windows
8575
+ .filter(([duration, remaining]) => duration !== null || remaining !== null)
8576
+ .map(([duration, remaining, fallback]) => (`${formatCompactRateLimitWindowLabel(locale, duration, fallback)}:${remaining === null ? '--' : formatUsagePercent(remaining)}`));
8577
+ return values.length > 0 ? values.join('|') : '--';
8319
8578
  }
8320
8579
  function isCodexAuthQuotaSnapshot(value) {
8321
8580
  if (!value || typeof value !== 'object') {
@@ -8325,12 +8584,29 @@ function isCodexAuthQuotaSnapshot(value) {
8325
8584
  return typeof snapshot.capturedAtMs === 'number'
8326
8585
  && Number.isFinite(snapshot.capturedAtMs)
8327
8586
  && (snapshot.accountId === undefined || snapshot.accountId === null || typeof snapshot.accountId === 'string')
8587
+ && (snapshot.planType === undefined || isNullableString(snapshot.planType))
8588
+ && (snapshot.primaryWindowDurationMins === undefined || isNullableFiniteNumber(snapshot.primaryWindowDurationMins))
8328
8589
  && isNullableFiniteNumber(snapshot.primaryRemainingPercent)
8590
+ && (snapshot.secondaryWindowDurationMins === undefined || isNullableFiniteNumber(snapshot.secondaryWindowDurationMins))
8329
8591
  && isNullableFiniteNumber(snapshot.secondaryRemainingPercent);
8330
8592
  }
8593
+ function normalizeCodexAuthQuotaSnapshot(snapshot) {
8594
+ return {
8595
+ capturedAtMs: snapshot.capturedAtMs,
8596
+ accountId: snapshot.accountId ?? null,
8597
+ planType: snapshot.planType ?? null,
8598
+ primaryWindowDurationMins: snapshot.primaryWindowDurationMins ?? null,
8599
+ primaryRemainingPercent: snapshot.primaryRemainingPercent,
8600
+ secondaryWindowDurationMins: snapshot.secondaryWindowDurationMins ?? null,
8601
+ secondaryRemainingPercent: snapshot.secondaryRemainingPercent,
8602
+ };
8603
+ }
8331
8604
  function isNullableFiniteNumber(value) {
8332
8605
  return value === null || (typeof value === 'number' && Number.isFinite(value));
8333
8606
  }
8607
+ function isNullableString(value) {
8608
+ return value === null || typeof value === 'string';
8609
+ }
8334
8610
  function formatCompactNumber(value) {
8335
8611
  return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, '');
8336
8612
  }
package/dist/i18n.d.ts CHANGED
@@ -137,16 +137,32 @@ declare const MESSAGES: {
137
137
  readonly auth_reload_restarting: "Restarting Codex app-server to reload auth...";
138
138
  readonly auth_reload_done: "Codex app-server restarted. Current auth has been reloaded.";
139
139
  readonly auth_reload_blocked_active: "Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.";
140
- readonly usage_auth: "Usage: /auth [list|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|add <name>]";
140
+ readonly usage_auth: "Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|add <name>]";
141
141
  readonly usage_auth_add: "Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.";
142
142
  readonly auth_list_title: "Codex auth files:";
143
143
  readonly auth_bot: "Bot runtime: {value}";
144
144
  readonly auth_current: "Current auth: {value}";
145
145
  readonly auth_dir: "Auth dir: {value}";
146
146
  readonly auth_candidate_count: "Candidates: {value}";
147
- readonly auth_quota_legend: "Quota remaining: 5h|7d|auth";
147
+ readonly auth_quota_legend: "Quota remaining: window:percent|auth";
148
+ readonly auth_search: "Search: {value}";
149
+ readonly auth_filter: "Filter: {value}";
150
+ readonly auth_filter_all: "all";
151
+ readonly auth_filter_enabled: "enabled";
152
+ readonly auth_filter_attention: "attention";
153
+ readonly auth_page: "Showing {from}-{to} of {filtered} matched candidates ({total} total), page {page}/{pages}";
154
+ readonly auth_no_matches: "No auth candidates match this view.";
148
155
  readonly auth_candidate_status_enabled: "enabled";
149
156
  readonly auth_candidate_status_disabled: "disabled";
157
+ readonly auth_candidate_health_disabled: "disabled";
158
+ readonly auth_candidate_health_ready: "ready";
159
+ readonly auth_candidate_health_low: "low quota";
160
+ readonly auth_candidate_health_exhausted: "quota exhausted";
161
+ readonly auth_candidate_health_stale: "not recently refreshed";
162
+ readonly auth_candidate_health_unknown: "quota unknown";
163
+ readonly 'auth_candidate_health_api-key': "API key";
164
+ readonly auth_candidate_health_invalid: "invalid auth file";
165
+ readonly auth_candidate_last_refresh: "refreshed {value}";
150
166
  readonly auth_candidate_enabled: "Enabled auth candidate for auto-rotation: {value}";
151
167
  readonly auth_candidate_disabled: "Disabled auth candidate for auto-rotation: {value}";
152
168
  readonly auth_candidate_enabled_short: "Auth enabled";
@@ -185,6 +201,9 @@ declare const MESSAGES: {
185
201
  readonly button_auth_refresh_all_confirm: "⚠️ Accept risk & refresh";
186
202
  readonly button_auth_enable: "✅";
187
203
  readonly button_auth_disable: "⏸️";
204
+ readonly button_auth_filter_all: "All";
205
+ readonly button_auth_filter_enabled: "Enabled";
206
+ readonly button_auth_filter_attention: "Attention";
188
207
  readonly another_turn_running: "Another turn is already running. Use /interrupt, /takeover, /queue, or wait.";
189
208
  readonly working: "Working...";
190
209
  readonly usage_open: "Usage: /open <n>";
@@ -723,16 +742,32 @@ declare const MESSAGES: {
723
742
  readonly auth_reload_restarting: "正在重启 Codex app-server 以重新读取 auth...";
724
743
  readonly auth_reload_done: "Codex app-server 已重启,当前 auth 已重新读取。";
725
744
  readonly auth_reload_blocked_active: "当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。";
726
- readonly usage_auth: "用法:/auth [list|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|add <名称>]";
745
+ readonly usage_auth: "用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|add <名称>]";
727
746
  readonly usage_auth_add: "用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。";
728
747
  readonly auth_list_title: "Codex auth 文件:";
729
748
  readonly auth_bot: "Bot runtime:{value}";
730
749
  readonly auth_current: "当前 auth:{value}";
731
750
  readonly auth_dir: "Auth 目录:{value}";
732
751
  readonly auth_candidate_count: "候选数量:{value}";
733
- readonly auth_quota_legend: "额度剩余:5h|7d|auth";
752
+ readonly auth_quota_legend: "额度剩余:窗口:百分比|auth";
753
+ readonly auth_search: "搜索:{value}";
754
+ readonly auth_filter: "筛选:{value}";
755
+ readonly auth_filter_all: "全部";
756
+ readonly auth_filter_enabled: "已启用";
757
+ readonly auth_filter_attention: "需关注";
758
+ readonly auth_page: "当前显示 {from}-{to},匹配 {filtered} / 总计 {total},第 {page}/{pages} 页";
759
+ readonly auth_no_matches: "当前视图没有匹配的 auth 候选。";
734
760
  readonly auth_candidate_status_enabled: "启用";
735
761
  readonly auth_candidate_status_disabled: "禁用";
762
+ readonly auth_candidate_health_disabled: "已禁用";
763
+ readonly auth_candidate_health_ready: "正常";
764
+ readonly auth_candidate_health_low: "额度偏低";
765
+ readonly auth_candidate_health_exhausted: "额度耗尽";
766
+ readonly auth_candidate_health_stale: "长期未刷新";
767
+ readonly auth_candidate_health_unknown: "额度未知";
768
+ readonly 'auth_candidate_health_api-key': "API key";
769
+ readonly auth_candidate_health_invalid: "auth 文件无效";
770
+ readonly auth_candidate_last_refresh: "刷新于 {value}";
736
771
  readonly auth_candidate_enabled: "已启用 auth 候选自动轮换:{value}";
737
772
  readonly auth_candidate_disabled: "已禁用 auth 候选自动轮换:{value}";
738
773
  readonly auth_candidate_enabled_short: "auth 已启用";
@@ -771,6 +806,9 @@ declare const MESSAGES: {
771
806
  readonly button_auth_refresh_all_confirm: "⚠️ 接受风险并刷新";
772
807
  readonly button_auth_enable: "✅";
773
808
  readonly button_auth_disable: "⏸️";
809
+ readonly button_auth_filter_all: "全部";
810
+ readonly button_auth_filter_enabled: "已启用";
811
+ readonly button_auth_filter_attention: "需关注";
774
812
  readonly another_turn_running: "已经有一个回复在进行中。请先等待,或使用 /interrupt、/takeover、/queue。";
775
813
  readonly working: "处理中...";
776
814
  readonly usage_open: "用法:/open <编号>";
package/dist/i18n.js CHANGED
@@ -135,16 +135,32 @@ const MESSAGES = {
135
135
  auth_reload_restarting: 'Restarting Codex app-server to reload auth...',
136
136
  auth_reload_done: 'Codex app-server restarted. Current auth has been reloaded.',
137
137
  auth_reload_blocked_active: 'Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.',
138
- usage_auth: 'Usage: /auth [list|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|add <name>]',
138
+ usage_auth: 'Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|add <name>]',
139
139
  usage_auth_add: 'Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.',
140
140
  auth_list_title: 'Codex auth files:',
141
141
  auth_bot: 'Bot runtime: {value}',
142
142
  auth_current: 'Current auth: {value}',
143
143
  auth_dir: 'Auth dir: {value}',
144
144
  auth_candidate_count: 'Candidates: {value}',
145
- auth_quota_legend: 'Quota remaining: 5h|7d|auth',
145
+ auth_quota_legend: 'Quota remaining: window:percent|auth',
146
+ auth_search: 'Search: {value}',
147
+ auth_filter: 'Filter: {value}',
148
+ auth_filter_all: 'all',
149
+ auth_filter_enabled: 'enabled',
150
+ auth_filter_attention: 'attention',
151
+ auth_page: 'Showing {from}-{to} of {filtered} matched candidates ({total} total), page {page}/{pages}',
152
+ auth_no_matches: 'No auth candidates match this view.',
146
153
  auth_candidate_status_enabled: 'enabled',
147
154
  auth_candidate_status_disabled: 'disabled',
155
+ auth_candidate_health_disabled: 'disabled',
156
+ auth_candidate_health_ready: 'ready',
157
+ auth_candidate_health_low: 'low quota',
158
+ auth_candidate_health_exhausted: 'quota exhausted',
159
+ auth_candidate_health_stale: 'not recently refreshed',
160
+ auth_candidate_health_unknown: 'quota unknown',
161
+ 'auth_candidate_health_api-key': 'API key',
162
+ auth_candidate_health_invalid: 'invalid auth file',
163
+ auth_candidate_last_refresh: 'refreshed {value}',
148
164
  auth_candidate_enabled: 'Enabled auth candidate for auto-rotation: {value}',
149
165
  auth_candidate_disabled: 'Disabled auth candidate for auto-rotation: {value}',
150
166
  auth_candidate_enabled_short: 'Auth enabled',
@@ -183,6 +199,9 @@ const MESSAGES = {
183
199
  button_auth_refresh_all_confirm: '⚠️ Accept risk & refresh',
184
200
  button_auth_enable: '✅',
185
201
  button_auth_disable: '⏸️',
202
+ button_auth_filter_all: 'All',
203
+ button_auth_filter_enabled: 'Enabled',
204
+ button_auth_filter_attention: 'Attention',
186
205
  another_turn_running: 'Another turn is already running. Use /interrupt, /takeover, /queue, or wait.',
187
206
  working: 'Working...',
188
207
  usage_open: 'Usage: /open <n>',
@@ -721,16 +740,32 @@ const MESSAGES = {
721
740
  auth_reload_restarting: '正在重启 Codex app-server 以重新读取 auth...',
722
741
  auth_reload_done: 'Codex app-server 已重启,当前 auth 已重新读取。',
723
742
  auth_reload_blocked_active: '当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。',
724
- usage_auth: '用法:/auth [list|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|add <名称>]',
743
+ usage_auth: '用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|add <名称>]',
725
744
  usage_auth_add: '用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。',
726
745
  auth_list_title: 'Codex auth 文件:',
727
746
  auth_bot: 'Bot runtime:{value}',
728
747
  auth_current: '当前 auth:{value}',
729
748
  auth_dir: 'Auth 目录:{value}',
730
749
  auth_candidate_count: '候选数量:{value}',
731
- auth_quota_legend: '额度剩余:5h|7d|auth',
750
+ auth_quota_legend: '额度剩余:窗口:百分比|auth',
751
+ auth_search: '搜索:{value}',
752
+ auth_filter: '筛选:{value}',
753
+ auth_filter_all: '全部',
754
+ auth_filter_enabled: '已启用',
755
+ auth_filter_attention: '需关注',
756
+ auth_page: '当前显示 {from}-{to},匹配 {filtered} / 总计 {total},第 {page}/{pages} 页',
757
+ auth_no_matches: '当前视图没有匹配的 auth 候选。',
732
758
  auth_candidate_status_enabled: '启用',
733
759
  auth_candidate_status_disabled: '禁用',
760
+ auth_candidate_health_disabled: '已禁用',
761
+ auth_candidate_health_ready: '正常',
762
+ auth_candidate_health_low: '额度偏低',
763
+ auth_candidate_health_exhausted: '额度耗尽',
764
+ auth_candidate_health_stale: '长期未刷新',
765
+ auth_candidate_health_unknown: '额度未知',
766
+ 'auth_candidate_health_api-key': 'API key',
767
+ auth_candidate_health_invalid: 'auth 文件无效',
768
+ auth_candidate_last_refresh: '刷新于 {value}',
734
769
  auth_candidate_enabled: '已启用 auth 候选自动轮换:{value}',
735
770
  auth_candidate_disabled: '已禁用 auth 候选自动轮换:{value}',
736
771
  auth_candidate_enabled_short: 'auth 已启用',
@@ -769,6 +804,9 @@ const MESSAGES = {
769
804
  button_auth_refresh_all_confirm: '⚠️ 接受风险并刷新',
770
805
  button_auth_enable: '✅',
771
806
  button_auth_disable: '⏸️',
807
+ button_auth_filter_all: '全部',
808
+ button_auth_filter_enabled: '已启用',
809
+ button_auth_filter_attention: '需关注',
772
810
  another_turn_running: '已经有一个回复在进行中。请先等待,或使用 /interrupt、/takeover、/queue。',
773
811
  working: '处理中...',
774
812
  usage_open: '用法:/open <编号>',
@@ -29,7 +29,10 @@ export interface CodexAuthQuotaSnapshotRecord {
29
29
  candidateName: string;
30
30
  accountId: string;
31
31
  capturedAtMs: number;
32
+ planType: string | null;
33
+ primaryWindowDurationMins: number | null;
32
34
  primaryRemainingPercent: number | null;
35
+ secondaryWindowDurationMins: number | null;
33
36
  secondaryRemainingPercent: number | null;
34
37
  updatedAt: number;
35
38
  }
@@ -86,7 +89,7 @@ export declare class BridgeStore {
86
89
  setWeixinContextToken(scopeId: string, contextToken: string): void;
87
90
  listDisabledCodexAuthCandidateNames(runtimeId?: string): Set<string>;
88
91
  setCodexAuthCandidateDisabled(name: string, disabled: boolean, runtimeId?: string): void;
89
- setCodexAuthQuotaSnapshot(runtimeId: string, candidateName: string, accountId: string, snapshot: Pick<CodexAuthQuotaSnapshotRecord, 'capturedAtMs' | 'primaryRemainingPercent' | 'secondaryRemainingPercent'>): void;
92
+ setCodexAuthQuotaSnapshot(runtimeId: string, candidateName: string, accountId: string, snapshot: Pick<CodexAuthQuotaSnapshotRecord, 'capturedAtMs' | 'planType' | 'primaryWindowDurationMins' | 'primaryRemainingPercent' | 'secondaryWindowDurationMins' | 'secondaryRemainingPercent'>): void;
90
93
  listCodexAuthQuotaSnapshots(accountIds: string[]): CodexAuthQuotaSnapshotRecord[];
91
94
  private ensureColumn;
92
95
  }
@@ -128,7 +128,10 @@ export class BridgeStore {
128
128
  candidate_name TEXT NOT NULL,
129
129
  account_id TEXT NOT NULL,
130
130
  captured_at_ms INTEGER NOT NULL,
131
+ plan_type TEXT,
132
+ primary_window_duration_mins REAL,
131
133
  primary_remaining_percent REAL,
134
+ secondary_window_duration_mins REAL,
132
135
  secondary_remaining_percent REAL,
133
136
  updated_at INTEGER NOT NULL,
134
137
  PRIMARY KEY (runtime_id, candidate_name)
@@ -148,6 +151,9 @@ export class BridgeStore {
148
151
  this.ensureColumn('pending_approvals', 'payload_json', 'TEXT');
149
152
  this.ensureColumn('pending_user_inputs', 'status', "TEXT NOT NULL DEFAULT 'pending'");
150
153
  this.ensureColumn('pending_user_inputs', 'submitted_at', 'INTEGER');
154
+ this.ensureColumn('codex_auth_quota_snapshots', 'plan_type', 'TEXT');
155
+ this.ensureColumn('codex_auth_quota_snapshots', 'primary_window_duration_mins', 'REAL');
156
+ this.ensureColumn('codex_auth_quota_snapshots', 'secondary_window_duration_mins', 'REAL');
151
157
  migrateLegacyBridgeScopeIds(this.db);
152
158
  }
153
159
  getTelegramOffset(botKey) {
@@ -528,18 +534,24 @@ export class BridgeStore {
528
534
  candidate_name,
529
535
  account_id,
530
536
  captured_at_ms,
537
+ plan_type,
538
+ primary_window_duration_mins,
531
539
  primary_remaining_percent,
540
+ secondary_window_duration_mins,
532
541
  secondary_remaining_percent,
533
542
  updated_at
534
543
  )
535
- VALUES (?, ?, ?, ?, ?, ?, ?)
544
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
536
545
  ON CONFLICT(runtime_id, candidate_name) DO UPDATE SET
537
546
  account_id = excluded.account_id,
538
547
  captured_at_ms = excluded.captured_at_ms,
548
+ plan_type = excluded.plan_type,
549
+ primary_window_duration_mins = excluded.primary_window_duration_mins,
539
550
  primary_remaining_percent = excluded.primary_remaining_percent,
551
+ secondary_window_duration_mins = excluded.secondary_window_duration_mins,
540
552
  secondary_remaining_percent = excluded.secondary_remaining_percent,
541
553
  updated_at = excluded.updated_at
542
- `).run(runtimeId, candidateName, accountId, snapshot.capturedAtMs, snapshot.primaryRemainingPercent, snapshot.secondaryRemainingPercent, Date.now());
554
+ `).run(runtimeId, candidateName, accountId, snapshot.capturedAtMs, snapshot.planType, snapshot.primaryWindowDurationMins, snapshot.primaryRemainingPercent, snapshot.secondaryWindowDurationMins, snapshot.secondaryRemainingPercent, Date.now());
543
555
  }
544
556
  listCodexAuthQuotaSnapshots(accountIds) {
545
557
  const uniqueAccountIds = [...new Set(accountIds.filter(Boolean))];
@@ -553,7 +565,10 @@ export class BridgeStore {
553
565
  candidate_name,
554
566
  account_id,
555
567
  captured_at_ms,
568
+ plan_type,
569
+ primary_window_duration_mins,
556
570
  primary_remaining_percent,
571
+ secondary_window_duration_mins,
557
572
  secondary_remaining_percent,
558
573
  updated_at
559
574
  FROM codex_auth_quota_snapshots
@@ -564,7 +579,10 @@ export class BridgeStore {
564
579
  candidateName: String(row.candidate_name),
565
580
  accountId: String(row.account_id),
566
581
  capturedAtMs: Number(row.captured_at_ms),
582
+ planType: nullableString(row.plan_type),
583
+ primaryWindowDurationMins: nullableNumber(row.primary_window_duration_mins),
567
584
  primaryRemainingPercent: nullableNumber(row.primary_remaining_percent),
585
+ secondaryWindowDurationMins: nullableNumber(row.secondary_window_duration_mins),
568
586
  secondaryRemainingPercent: nullableNumber(row.secondary_remaining_percent),
569
587
  updatedAt: Number(row.updated_at),
570
588
  }));
@@ -584,6 +602,9 @@ function nullableNumber(value) {
584
602
  const numberValue = Number(value);
585
603
  return Number.isFinite(numberValue) ? numberValue : null;
586
604
  }
605
+ function nullableString(value) {
606
+ return typeof value === 'string' && value.trim() ? value : null;
607
+ }
587
608
  function normalizeCollaborationMode(value) {
588
609
  return value === 'default' || value === 'plan' ? value : null;
589
610
  }
@@ -389,32 +389,39 @@ 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. 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.
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 panel shows 8 candidates per page and supports paging, `All / Enabled / Attention` filters, and `/auth list <keyword>` filename search for large local inventories. Panel text and buttons omit the repeated `auth.json_` prefix from standard candidate filenames, so `auth.json_personal` on disk renders as `personal`; files are not renamed, and search and commands still operate on the original candidates. `/auth use <n>` always uses the full-list candidate number, independent of panel paging.
393
+
394
+ Each filename is prefixed with observed `window:remaining-percent` values. For example, a Plus account may show `5h:20|7d:25`, while an account with one monthly window may show `30d:97`. The current auth quota is refreshed when the panel opens; 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
395
 
394
396
  Approximation:
395
397
 
396
398
  ```text
397
399
  Codex auth
398
- Current: auth.json_personal
400
+ Current: personal
399
401
  Auth dir: /home/alice/.codex
400
402
  Candidates: 2
401
- Quota remaining: 5h|7d|auth
402
- 1. 20|25|auth.json_personal * [enabled]
403
- 2. --|--|auth.json_team [enabled]
403
+ Quota remaining: window:percent|auth
404
+ 1. 5h:20|7d:25|personal * [Plus · ready · refreshed 2h ago]
405
+ 2. --|team [quota unknown]
404
406
 
405
- [✅ 20|25|auth.json_personal] [✅]
406
- [🔐 --|--|auth.json_team] [✅]
407
+ [✅ 5h:20|7d:25|personal] [✅]
408
+ [🔐 --|team] [✅]
409
+ [☑️ All] [Enabled] [Attention]
407
410
  [🛡️ Access] [🔑 Login]
408
411
  [🔄 Reload auth]
409
412
  ```
410
413
 
411
- The right-side `✅` / `⏸️` button shows the current state. 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.
414
+ 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.
412
415
 
413
416
  `/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.
414
417
 
418
+ OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token replay grace period. Codex refreshes automatically when an access token approaches expiry; when it cannot parse the access-token `exp`, current Codex uses a `last_refresh` fallback of about 8 days. The panel labels candidates without a refresh record in that interval as `not recently refreshed`, but this is only a maintenance hint. It does not prove expiry and does not trigger bulk keepalive refreshes. Do not use `/auth refresh all` as a routine keepalive command.
419
+
415
420
  Equivalent commands:
416
421
 
417
- - `/auth` or `/auth list`: show candidates.
422
+ - `/auth` or `/auth list [keyword]`: show candidates, optionally filtered by filename.
423
+ - `/auth filter <all|enabled|attention>`: show all, enabled, or attention-needed candidates.
424
+ - `/auth page <n>`: open a specific page directly.
418
425
  - `/auth use <n>`: switch to candidate n and restart app-server.
419
426
  - `/auth enable <n>`: let candidate n participate in auto-rotation.
420
427
  - `/auth disable <n>`: skip candidate n during auto-rotation.
@@ -389,32 +389,39 @@ 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 会在打开面板时刷新,其他候选不会为了查询额度被自动切换。如果多个 bot runtime 最近使用过同一个 ChatGPT 账号,FoxClaw 会按已验证的账号 ID 合并它们缓存到的额度快照,因此一个 bot `/auth` 面板可以显示另一个 bot 掌握到的额度信息,同时不会把不同账号混在一起。
392
+ `/auth` 会列出候选账号、当前账号和 auth 目录,并提供按钮切换、禁用、登录和重载。多 bot 模式中,面板顶部还会显示当前正在管理的 `@botname`,因为该 bot 内的私聊、群聊和话题共享同一个当前 auth。面板每页显示 8 个候选,支持翻页、`全部 / 已启用 / 需关注` 筛选和 `/auth list <关键词>` 文件名搜索,适合管理较大的本地候选清单。面板文本和按钮会省略标准候选文件名中重复的 `auth.json_` 前缀,例如磁盘上的 `auth.json_personal` 显示为 `personal`;文件本身不会重命名,搜索和命令仍按原候选工作。命令 `/auth use <n>` 的编号始终对应完整候选列表,不会因为分页变化。
393
+
394
+ 每个候选名前的 `窗口:剩余百分比` 来自最近一次观察到的真实额度窗口,例如 Plus 账号可能显示 `5h:20|7d:25`,只有一个月度窗口的账号可能显示 `30d:97`。当前 auth 会在打开面板时刷新额度;其他候选不会为了查询额度被自动切换。如果多个 bot runtime 最近使用过同一个 ChatGPT 账号,FoxClaw 会按已验证的账号 ID 合并它们缓存到的额度快照,因此一个 bot 的 `/auth` 面板可以显示另一个 bot 掌握到的额度信息,同时不会把不同账号混在一起。
393
395
 
394
396
  示意:
395
397
 
396
398
  ```text
397
399
  Codex auth
398
- Current: auth.json_personal
400
+ Current: personal
399
401
  Auth dir: /home/alice/.codex
400
402
  Candidates: 2
401
- 额度剩余:5h|7d|auth
402
- 1. 20|25|auth.json_personal * [enabled]
403
- 2. --|--|auth.json_team [enabled]
403
+ 额度剩余:窗口:百分比|auth
404
+ 1. 5h:20|7d:25|personal * [Plus · 正常 · 刷新于 2小时前]
405
+ 2. --|team [额度未知]
404
406
 
405
- [✅ 20|25|auth.json_personal] [✅]
406
- [🔐 --|--|auth.json_team] [✅]
407
+ [✅ 5h:20|7d:25|personal] [✅]
408
+ [🔐 --|team] [✅]
409
+ [☑️ 全部] [已启用] [需关注]
407
410
  [🛡️ Access] [🔑 设备登录]
408
411
  [🔄 Reload auth]
409
412
  ```
410
413
 
411
- 右侧 `✅` / `⏸️` 表示当前状态。点一下会切换启用/禁用,列表刷新后图标会随状态变化。点击候选会切换 auth、重启对应 runtime,并在原消息上刷新面板且保留按钮,因此可以立即连续切换。`--|--` 表示该候选还没有额度历史快照。
414
+ 右侧 `✅` / `⏸️` 表示当前是否参与自动轮转。点一下会切换启用/禁用,列表刷新后图标会随状态变化。点击候选会切换 auth、重启对应 runtime,并在原消息上刷新面板且保留按钮,因此可以立即连续切换。`--` 表示该候选还没有额度历史快照。健康摘要会区分正常、额度偏低、额度耗尽、额度未知、长期未刷新、API key 和无效 auth 文件。
412
415
 
413
416
  `/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 并显示摘要。
414
417
 
418
+ OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放宽限期。Codex 会在 access token 临近到期时自动刷新;如果 access token 里无法解析 `exp`,Codex 当前使用 `last_refresh` 超过约 8 天作为兜底刷新条件。面板把超过 8 天没有刷新记录的候选标为“长期未刷新”,但这只是维护提醒,不代表 refresh token 已经过期,也不会触发批量保活刷新。不要把 `/auth refresh all` 当作日常保活命令。
419
+
415
420
  命令等价用法:
416
421
 
417
- - `/auth` 或 `/auth list`:查看候选。
422
+ - `/auth` 或 `/auth list [关键词]`:查看候选,可按文件名搜索。
423
+ - `/auth filter <all|enabled|attention>`:筛选全部、已启用或需关注候选。
424
+ - `/auth page <页码>`:直接查看指定页。
418
425
  - `/auth use <n>`:切到第 n 个候选并重启 app-server。
419
426
  - `/auth enable <n>`:让第 n 个候选参与自动轮转。
420
427
  - `/auth disable <n>`:禁用第 n 个候选,自动轮转会跳过它。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -67,6 +67,8 @@ Telegram behavior:
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
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
70
+ - `/auth` paginates large candidate inventories at 8 rows per page, supports all/enabled/attention filters and filename search, renders the actual observed quota windows rather than assuming every account has 5-hour and 7-day windows, and omits the repeated `auth.json_` prefix from panel labels without renaming files
71
+ - treat the `/auth` `not recently refreshed` state as a maintenance hint only: OpenAI does not publish a fixed ChatGPT refresh-token lifetime or replay grace period, and FoxClaw must not add routine bulk keepalive refreshes
70
72
  - `/auth refresh all` remains a command-only maintenance action, not a panel button; it requires explicit token-rotation risk confirmation, then force-refreshes every ChatGPT candidate through Codex `account/read refreshToken=true`, validates usage, mirrors successful candidates, and restores the original current auth while every runtime is globally idle
71
73
  - 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
72
74
  - if Weixin is enabled alongside multiple Telegram bots, it remains on the default Codex runtime instead of borrowing a Telegram bot runtime