@foxden-app/foxclaw 0.4.1 → 0.4.3

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/.env.example CHANGED
@@ -1,7 +1,9 @@
1
- # Required for new installs: one or more bot tokens for this host
2
- # Comma-separated bot tokens. Each bot gets an independent Codex runtime/auth selection.
1
+ # Required for new installs: one or more bot tokens for this host.
2
+ # Comma-separated bot tokens. By default, each bot gets an independent Codex runtime/auth selection.
3
3
  TG_BOT_TOKENS=<telegram_bot_token>
4
- # Backward-compatible single-runtime setup:
4
+ # Backward-compatible single-runtime setup.
5
+ # In TG_BOT_TOKENS mode, if this exact token is also present in TG_BOT_TOKENS,
6
+ # that bot uses the default terminal CODEX_HOME/auth instead of an isolated home.
5
7
  # TG_BOT_TOKEN=<telegram_bot_token>
6
8
 
7
9
  # Required: only this Telegram user can control the bridge
@@ -16,7 +18,8 @@ TG_ALLOWED_TOPIC_ID=
16
18
 
17
19
  # Optional Codex Desktop bridge settings.
18
20
  # In TG_BOT_TOKENS mode isolated Telegram runtimes start app-server directly and
19
- # do not auto-launch Codex Desktop; this remains applicable to the default/Weixin runtime.
21
+ # do not auto-launch Codex Desktop; this remains applicable to the default/shared
22
+ # terminal runtime and Weixin runtime.
20
23
  CODEX_APP_AUTOLAUNCH=true
21
24
  CODEX_APP_LAUNCH_CMD=codex app
22
25
  # Optional: persist the managed app-server pid/port and collect its stdout/stderr
package/README.md CHANGED
@@ -40,7 +40,7 @@ FoxClaw(狸爪)的目标很直接:让你用手机控制本机的 Codex,
40
40
  - Git、Node、`.env` 都玩得转?直接往下看快速设置。
41
41
  - 卡住了?看 [故障排查](./docs/zh/troubleshooting.md)。
42
42
 
43
- 最低要求:一个或多个 Telegram bot token、你的 Telegram 数字用户 ID、Node.js 24+、一份已登录的 `codex` CLI。首次安装大约 10–20 分钟。新安装请使用 `TG_BOT_TOKENS`;`TG_BOT_TOKEN` 只用于兼容旧的单 runtime 配置。
43
+ 最低要求:一个或多个 Telegram bot token、你的 Telegram 数字用户 ID、Node.js 24+、一份已登录的 `codex` CLI。首次安装大约 10–20 分钟。新安装请使用 `TG_BOT_TOKENS`;`TG_BOT_TOKEN` 兼容旧的单 runtime 配置,也可在多 bot 模式中标记一个“默认/终端共享”bot。
44
44
 
45
45
  **30 秒体验**:启动 FoxClaw 后,给你的 Telegram 机器人发一句 `List files in DEFAULT_CWD`。Codex 会在本地检查那个目录,然后把结果发回 Telegram。
46
46
 
@@ -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`——稳定的聊天-线程绑定
@@ -111,7 +111,8 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
111
111
  **可靠性:**
112
112
  - SQLite 持久化:绑定、offset、审批、待处理提示、审计日志
113
113
  - 单实例进程锁,防止同一 bot token 重复 polling
114
- - `TG_BOT_TOKENS` 支持一台机器同时运行多个 Telegram bot;每个 bot 使用独立 Codex app-server、会话和 `/auth` 选择
114
+ - `TG_BOT_TOKENS` 支持一台机器同时运行多个 Telegram bot;默认每个 bot 使用独立 Codex app-server、会话和 `/auth` 选择
115
+ - 如果同时设置 `TG_BOT_TOKEN`,且它的值也出现在 `TG_BOT_TOKENS` 中,匹配的那个 bot 会共享终端默认 `CODEX_HOME` 和 auth,其他 bot 继续隔离
115
116
 
116
117
  </details>
117
118
 
@@ -201,9 +202,13 @@ TG_ALLOWED_TOPIC_ID=42
201
202
 
202
203
  ```dotenv
203
204
  TG_BOT_TOKENS=123456:token_a,234567:token_b
205
+ # Optional: mark token_a as the bot sharing terminal/default CODEX_HOME and auth
206
+ TG_BOT_TOKEN=123456:token_a
204
207
  ```
205
208
 
206
- FoxClaw 仍然只运行一个系统服务,但会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新验证后镜像同步,各 bot 的当前选择互不影响。每个 bot 首次私聊发送 `/help` 和 `/status`;`/auth` 会标明正在操作的 bot runtime,`/status` 会列出全部 bot 的连接、当前 auth 和活动 turn 摘要。
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 摘要。
210
+
211
+ 如果你需要一路 Telegram 与终端互通 session,把同一个 token 同时填入 `TG_BOT_TOKENS` 和 `TG_BOT_TOKEN`。这个 bot 使用默认 `CODEX_HOME`(未设置时通常是 `~/.codex`)和默认 auth,因此能看到终端 Codex 的本地线程;它不再享有隔离 runtime 的“互不影响”保证,切换 auth 会影响终端和其他默认 runtime。
207
212
 
208
213
  **怎么找群组和话题 ID:**
209
214
 
@@ -239,7 +244,7 @@ CODEX_APP_SYNC_ON_OPEN=true
239
244
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
240
245
  ```
241
246
 
242
- FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,每个 bot 都有自己的 app-server 与 Codex home,并在该隔离 runtime 内强制使用文件凭据存储;隔离 bot 不自动拉起 Codex Desktop,避免多个新 home 同时初始化桌面状态。重启时如果对应进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换只重启发起操作的 bot runtime。
247
+ FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,默认每个 bot 都有自己的 app-server 与 Codex home,并在该隔离 runtime 内强制使用文件凭据存储;隔离 bot 不自动拉起 Codex Desktop,避免多个新 home 同时初始化桌面状态。用 `TG_BOT_TOKEN` 标记的默认/终端共享 bot 例外:它使用默认 Codex home、默认 auth 和默认 app-server 配置。重启时如果对应进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换只重启发起操作的 bot runtime。
243
248
 
244
249
  一般不需要手动固定 app-server 端口。
245
250
 
package/README_EN.md CHANGED
@@ -40,7 +40,7 @@ FoxClaw is more than message forwarding. It provides Telegram panels for Codex w
40
40
  - Already comfortable with Git, Node, and `.env` files? Use the quick setup below.
41
41
  - Something failed? Check [Troubleshooting](./docs/troubleshooting.md).
42
42
 
43
- The minimum install needs one or more Telegram bot tokens, your numeric Telegram user id, Node.js 24+, and a logged-in `codex` CLI. A first install usually takes 10–20 minutes. New installs should use `TG_BOT_TOKENS`; `TG_BOT_TOKEN` exists only for legacy single-runtime compatibility.
43
+ The minimum install needs one or more Telegram bot tokens, your numeric Telegram user id, Node.js 24+, and a logged-in `codex` CLI. A first install usually takes 10–20 minutes. New installs should use `TG_BOT_TOKENS`; `TG_BOT_TOKEN` remains compatible with legacy single-runtime setups and can mark one multi-bot runtime as default/shared-terminal.
44
44
 
45
45
  **30-second demo**: after FoxClaw is running, send `List files in DEFAULT_CWD` to your Telegram bot. FoxClaw asks local Codex to inspect that folder on your computer and sends the answer back to Telegram.
46
46
 
@@ -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
@@ -111,7 +111,8 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
111
111
  **Reliability:**
112
112
  - SQLite persistence for bindings, offsets, approvals, pending input prompts, and audit logs
113
113
  - Single-instance process lock to prevent duplicate Telegram polling on the same bot token
114
- - `TG_BOT_TOKENS` runs multiple Telegram bots on one host with independent Codex app-servers, sessions, and `/auth` selections
114
+ - `TG_BOT_TOKENS` runs multiple Telegram bots on one host with independent Codex app-servers, sessions, and `/auth` selections by default
115
+ - If `TG_BOT_TOKEN` is also set and exactly matches one token in `TG_BOT_TOKENS`, that bot shares the terminal/default `CODEX_HOME` and auth while the other bots stay isolated
115
116
 
116
117
  </details>
117
118
 
@@ -201,9 +202,13 @@ Parallel bot example:
201
202
 
202
203
  ```dotenv
203
204
  TG_BOT_TOKENS=123456:token_a,234567:token_b
205
+ # Optional: mark token_a as the bot sharing terminal/default CODEX_HOME and auth
206
+ TG_BOT_TOKEN=123456:token_a
204
207
  ```
205
208
 
206
- FoxClaw remains one system service, but 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, 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 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.
210
+
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.
207
212
 
208
213
  **How to find group and topic IDs:**
209
214
 
@@ -239,7 +244,7 @@ CODEX_APP_SYNC_ON_OPEN=true
239
244
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
240
245
  ```
241
246
 
242
- FoxClaw starts `codex app-server` as a detached, bridge-managed process and records its pid and port. With `TG_BOT_TOKENS`, each bot has its own app-server and Codex home, and its isolated runtime is forced to use file-backed credentials. Isolated Telegram runtimes do not auto-launch Codex Desktop, avoiding simultaneous desktop-state initialization for fresh homes. On restart, FoxClaw reconnects to each live recorded app-server or starts a replacement. `/auth_reload` and auth switching restart only the requesting bot runtime.
247
+ FoxClaw starts `codex app-server` as a detached, bridge-managed process and records its pid and port. With `TG_BOT_TOKENS`, each bot has its own app-server and Codex home by default, and its isolated runtime is forced to use file-backed credentials. Isolated Telegram runtimes do not auto-launch Codex Desktop, avoiding simultaneous desktop-state initialization for fresh homes. The default/shared-terminal bot marked with `TG_BOT_TOKEN` is the exception: it uses the default Codex home, default auth, and default app-server settings. On restart, FoxClaw reconnects to each live recorded app-server or starts a replacement. `/auth_reload` and auth switching restart only the requesting bot runtime.
243
248
 
244
249
  No static Codex app-server port is required in normal installs.
245
250
 
@@ -21,6 +21,10 @@ export interface AuthMirrorValidationResult {
21
21
  ok: boolean;
22
22
  reason?: string | null;
23
23
  }
24
+ export interface ChatGptAuthMetadata {
25
+ accountId: string;
26
+ lastRefreshMs: number;
27
+ }
24
28
  export declare class AuthCandidateMirror {
25
29
  private readonly canonicalDir;
26
30
  private readonly runtimes;
@@ -47,3 +51,4 @@ export declare class AuthCandidateMirror {
47
51
  private resolveCanonicalCurrentCandidate;
48
52
  }
49
53
  export declare function isAuthCandidateName(name: string): boolean;
54
+ export declare function readChatGptAuthMetadata(filePath: string): Promise<ChatGptAuthMetadata | null>;
@@ -216,12 +216,31 @@ async function listAuthCandidateNames(dir) {
216
216
  async function readChatGptAuthRecord(filePath) {
217
217
  try {
218
218
  const raw = await fs.readFile(filePath, 'utf8');
219
+ const metadata = parseChatGptAuthMetadata(raw);
220
+ if (!metadata)
221
+ return null;
222
+ return { raw, ...metadata };
223
+ }
224
+ catch {
225
+ return null;
226
+ }
227
+ }
228
+ export async function readChatGptAuthMetadata(filePath) {
229
+ try {
230
+ return parseChatGptAuthMetadata(await fs.readFile(filePath, 'utf8'));
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ }
236
+ function parseChatGptAuthMetadata(raw) {
237
+ try {
219
238
  const parsed = JSON.parse(raw);
220
239
  const accountId = typeof parsed.tokens?.account_id === 'string' ? parsed.tokens.account_id : '';
221
240
  const lastRefreshMs = typeof parsed.last_refresh === 'string' ? Date.parse(parsed.last_refresh) : NaN;
222
241
  if (!accountId || !Number.isFinite(lastRefreshMs))
223
242
  return null;
224
- return { raw, accountId, lastRefreshMs };
243
+ return { accountId, lastRefreshMs };
225
244
  }
226
245
  catch {
227
246
  return null;
package/dist/config.d.ts CHANGED
@@ -16,6 +16,7 @@ export interface AppConfig {
16
16
  tgBotToken: string;
17
17
  tgBotTokens: string[];
18
18
  tgMultiBotMode: boolean;
19
+ tgDefaultRuntimeBotToken: string | null;
19
20
  tgScopeBotId: string | null;
20
21
  tgRequireExplicitGroupAddressing: boolean;
21
22
  tgAllowedUserId: string;
@@ -52,4 +53,5 @@ export interface AppConfig {
52
53
  wxIlinkRouteTag: string | null;
53
54
  }
54
55
  export declare function loadConfig(): AppConfig;
56
+ export declare function selectDefaultRuntimeBotToken(configuredTokens: string[], legacyToken: string | null): string | null;
55
57
  export declare function ensureAppDirs(config: AppConfig): void;
package/dist/config.js CHANGED
@@ -40,6 +40,7 @@ export function loadConfig() {
40
40
  loadEnv();
41
41
  const configuredTokens = parseCommaSeparatedIds(process.env.TG_BOT_TOKENS);
42
42
  const legacyToken = optional('TG_BOT_TOKEN');
43
+ const tgDefaultRuntimeBotToken = selectDefaultRuntimeBotToken(configuredTokens, legacyToken);
43
44
  const tgBotTokens = configuredTokens.length > 0
44
45
  ? configuredTokens
45
46
  : legacyToken
@@ -52,6 +53,7 @@ export function loadConfig() {
52
53
  tgBotToken: tgBotTokens[0],
53
54
  tgBotTokens,
54
55
  tgMultiBotMode: configuredTokens.length > 0,
56
+ tgDefaultRuntimeBotToken,
55
57
  tgScopeBotId: null,
56
58
  tgRequireExplicitGroupAddressing: configuredTokens.length > 1,
57
59
  tgAllowedUserId: required('TG_ALLOWED_USER_ID'),
@@ -87,6 +89,11 @@ export function loadConfig() {
87
89
  ensureAppDirs(config);
88
90
  return config;
89
91
  }
92
+ export function selectDefaultRuntimeBotToken(configuredTokens, legacyToken) {
93
+ if (configuredTokens.length === 0 || !legacyToken)
94
+ return null;
95
+ return configuredTokens.includes(legacyToken) ? legacyToken : null;
96
+ }
90
97
  export function ensureAppDirs(config) {
91
98
  const dirs = [
92
99
  path.dirname(config.storePath),
@@ -295,6 +295,9 @@ export declare class BridgeSessionCore {
295
295
  private refreshCodexLocalUsageStats;
296
296
  private codexLocalUsageSnapshotPath;
297
297
  private refreshCurrentCodexAuthQuota;
298
+ private applySharedCodexAuthQuotaSnapshots;
299
+ private readCodexAuthCandidateAccountIds;
300
+ private codexAuthQuotaSnapshotMatchesAccount;
298
301
  private readCodexAuthQuotaSnapshots;
299
302
  private writeCodexAuthQuotaSnapshots;
300
303
  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';
@@ -403,6 +404,7 @@ export class BridgeSessionCore {
403
404
  lines.push(t(locale, 'status_runtime_bot', {
404
405
  bot: runtime.username ? `@${runtime.username}` : runtime.id,
405
406
  connected: t(locale, runtime.connected ? 'yes' : 'no'),
407
+ runtime: t(locale, runtime.runtimeKind === 'default' ? 'status_runtime_kind_default' : 'status_runtime_kind_isolated'),
406
408
  auth: runtime.currentAuth ?? t(locale, 'none'),
407
409
  turns: runtime.activeTurns,
408
410
  }));
@@ -4509,9 +4511,15 @@ export class BridgeSessionCore {
4509
4511
  async listCodexAuthState() {
4510
4512
  const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.resolveAuthDir());
4511
4513
  const snapshots = await this.readCodexAuthQuotaSnapshots();
4514
+ const candidateAccountIds = await this.readCodexAuthCandidateAccountIds(state.candidates);
4512
4515
  state.candidates.forEach((candidate) => {
4513
- candidate.quota = snapshots[candidate.name] ?? null;
4516
+ const candidateAccountId = candidateAccountIds.get(candidate.name) ?? null;
4517
+ const snapshot = snapshots[candidate.name] ?? null;
4518
+ candidate.quota = this.codexAuthQuotaSnapshotMatchesAccount(snapshot, candidateAccountId)
4519
+ ? snapshot
4520
+ : null;
4514
4521
  });
4522
+ await this.applySharedCodexAuthQuotaSnapshots(state, candidateAccountIds);
4515
4523
  return state;
4516
4524
  }
4517
4525
  resolveAuthDir() {
@@ -4740,19 +4748,67 @@ export class BridgeSessionCore {
4740
4748
  return;
4741
4749
  }
4742
4750
  try {
4751
+ const metadata = await readChatGptAuthMetadata(candidate.path);
4743
4752
  const snapshot = selectCodexRateLimitSnapshot(await this.app.readAccountRateLimits());
4744
4753
  if (!snapshot) {
4745
4754
  return;
4746
4755
  }
4747
- const quota = authQuotaSnapshotFromRateLimit(snapshot);
4756
+ const quota = authQuotaSnapshotFromRateLimit(snapshot, metadata?.accountId ?? null);
4748
4757
  candidate.quota = quota;
4749
4758
  this.authQuotaSnapshots[candidate.name] = quota;
4759
+ if (metadata?.accountId) {
4760
+ this.store.setCodexAuthQuotaSnapshot(this.authRuntimeId(), candidate.name, metadata.accountId, quota);
4761
+ }
4750
4762
  await this.writeCodexAuthQuotaSnapshots();
4763
+ await this.applySharedCodexAuthQuotaSnapshots(state);
4751
4764
  }
4752
4765
  catch (error) {
4753
4766
  this.logger.warn('codex.auth_quota_refresh_failed', { error: formatUserError(error) });
4754
4767
  }
4755
4768
  }
4769
+ async applySharedCodexAuthQuotaSnapshots(state, candidateAccountIds) {
4770
+ const accountIds = candidateAccountIds ?? await this.readCodexAuthCandidateAccountIds(state.candidates);
4771
+ const uniqueAccountIds = [...new Set(accountIds.values())];
4772
+ if (uniqueAccountIds.length === 0) {
4773
+ return;
4774
+ }
4775
+ const snapshotsByAccount = new Map();
4776
+ for (const record of this.store.listCodexAuthQuotaSnapshots(uniqueAccountIds)) {
4777
+ if (!isFiniteCodexAuthQuotaSnapshotRecord(record)) {
4778
+ continue;
4779
+ }
4780
+ snapshotsByAccount.set(record.accountId, mergeCodexAuthQuotaSnapshots(snapshotsByAccount.get(record.accountId) ?? null, codexAuthQuotaSnapshotFromRecord(record)));
4781
+ }
4782
+ for (const candidate of state.candidates) {
4783
+ const accountId = accountIds.get(candidate.name);
4784
+ if (!accountId) {
4785
+ continue;
4786
+ }
4787
+ candidate.quota = mergeCodexAuthQuotaSnapshots(candidate.quota, snapshotsByAccount.get(accountId) ?? null);
4788
+ }
4789
+ }
4790
+ async readCodexAuthCandidateAccountIds(candidates) {
4791
+ const entries = await Promise.all(candidates.map(async (candidate) => {
4792
+ const metadata = await readChatGptAuthMetadata(candidate.path);
4793
+ return [candidate.name, metadata?.accountId ?? null];
4794
+ }));
4795
+ const accountIds = new Map();
4796
+ for (const [name, accountId] of entries) {
4797
+ if (accountId) {
4798
+ accountIds.set(name, accountId);
4799
+ }
4800
+ }
4801
+ return accountIds;
4802
+ }
4803
+ codexAuthQuotaSnapshotMatchesAccount(snapshot, accountId) {
4804
+ if (!snapshot) {
4805
+ return false;
4806
+ }
4807
+ if (!snapshot.accountId) {
4808
+ return accountId === null;
4809
+ }
4810
+ return accountId !== null && snapshot.accountId === accountId;
4811
+ }
4756
4812
  async readCodexAuthQuotaSnapshots() {
4757
4813
  if (this.authQuotaSnapshotsLoaded) {
4758
4814
  return this.authQuotaSnapshots;
@@ -7933,13 +7989,50 @@ function formatRemainingUsagePercent(usedPercent) {
7933
7989
  const remainingPercent = remainingUsagePercent(usedPercent);
7934
7990
  return remainingPercent === null ? '?' : formatUsagePercent(remainingPercent);
7935
7991
  }
7936
- function authQuotaSnapshotFromRateLimit(snapshot) {
7992
+ function authQuotaSnapshotFromRateLimit(snapshot, accountId = null) {
7937
7993
  return {
7938
7994
  capturedAtMs: Date.now(),
7995
+ accountId,
7939
7996
  primaryRemainingPercent: snapshot.primary ? remainingUsagePercent(snapshot.primary.usedPercent) : null,
7940
7997
  secondaryRemainingPercent: snapshot.secondary ? remainingUsagePercent(snapshot.secondary.usedPercent) : null,
7941
7998
  };
7942
7999
  }
8000
+ function codexAuthQuotaSnapshotFromRecord(record) {
8001
+ return {
8002
+ capturedAtMs: record.capturedAtMs,
8003
+ accountId: record.accountId,
8004
+ primaryRemainingPercent: record.primaryRemainingPercent,
8005
+ secondaryRemainingPercent: record.secondaryRemainingPercent,
8006
+ };
8007
+ }
8008
+ function mergeCodexAuthQuotaSnapshots(current, incoming) {
8009
+ if (!current) {
8010
+ return incoming;
8011
+ }
8012
+ if (!incoming) {
8013
+ return current;
8014
+ }
8015
+ return {
8016
+ capturedAtMs: Math.max(current.capturedAtMs, incoming.capturedAtMs),
8017
+ accountId: current.accountId ?? incoming.accountId ?? null,
8018
+ primaryRemainingPercent: fresherNullableQuotaValue(current.primaryRemainingPercent, current.capturedAtMs, incoming.primaryRemainingPercent, incoming.capturedAtMs),
8019
+ secondaryRemainingPercent: fresherNullableQuotaValue(current.secondaryRemainingPercent, current.capturedAtMs, incoming.secondaryRemainingPercent, incoming.capturedAtMs),
8020
+ };
8021
+ }
8022
+ function fresherNullableQuotaValue(currentValue, currentCapturedAtMs, incomingValue, incomingCapturedAtMs) {
8023
+ if (currentValue === null) {
8024
+ return incomingValue;
8025
+ }
8026
+ if (incomingValue === null) {
8027
+ return currentValue;
8028
+ }
8029
+ return incomingCapturedAtMs >= currentCapturedAtMs ? incomingValue : currentValue;
8030
+ }
8031
+ function isFiniteCodexAuthQuotaSnapshotRecord(record) {
8032
+ return Number.isFinite(record.capturedAtMs)
8033
+ && isNullableFiniteNumber(record.primaryRemainingPercent)
8034
+ && isNullableFiniteNumber(record.secondaryRemainingPercent);
8035
+ }
7943
8036
  function remainingUsagePercent(usedPercent) {
7944
8037
  if (!Number.isFinite(usedPercent)) {
7945
8038
  return null;
@@ -7961,6 +8054,7 @@ function isCodexAuthQuotaSnapshot(value) {
7961
8054
  const snapshot = value;
7962
8055
  return typeof snapshot.capturedAtMs === 'number'
7963
8056
  && Number.isFinite(snapshot.capturedAtMs)
8057
+ && (snapshot.accountId === undefined || snapshot.accountId === null || typeof snapshot.accountId === 'string')
7964
8058
  && isNullableFiniteNumber(snapshot.primaryRemainingPercent)
7965
8059
  && isNullableFiniteNumber(snapshot.secondaryRemainingPercent);
7966
8060
  }
package/dist/i18n.d.ts CHANGED
@@ -119,7 +119,9 @@ declare const MESSAGES: {
119
119
  readonly status_codex_credits: "Codex credits: {value}";
120
120
  readonly status_codex_limit_reached: "Codex limit: {value}";
121
121
  readonly status_runtime_overview: "Telegram bot runtimes:";
122
- readonly status_runtime_bot: "- {bot}: connected {connected}, auth {auth}, active turns {turns}";
122
+ readonly status_runtime_bot: "- {bot}: connected {connected}, runtime {runtime}, auth {auth}, active turns {turns}";
123
+ readonly status_runtime_kind_default: "default/shared terminal";
124
+ readonly status_runtime_kind_isolated: "isolated";
123
125
  readonly status_runtime_weixin: "- Weixin default runtime: connected {connected}, active turns {turns}";
124
126
  readonly status_auth_mirror_none: "Last auth mirror: none recorded";
125
127
  readonly status_auth_mirror_synced: "Last auth mirror: {candidate} from {source} at {time}";
@@ -692,7 +694,9 @@ declare const MESSAGES: {
692
694
  readonly status_codex_credits: "Codex 额度:{value}";
693
695
  readonly status_codex_limit_reached: "Codex 限制:{value}";
694
696
  readonly status_runtime_overview: "Telegram bot 运行时:";
695
- readonly status_runtime_bot: "- {bot}:连接 {connected},auth {auth},进行中回复 {turns}";
697
+ readonly status_runtime_bot: "- {bot}:连接 {connected},runtime {runtime},auth {auth},进行中回复 {turns}";
698
+ readonly status_runtime_kind_default: "默认/终端共享";
699
+ readonly status_runtime_kind_isolated: "隔离";
696
700
  readonly status_runtime_weixin: "- 微信默认运行时:连接 {connected},进行中回复 {turns}";
697
701
  readonly status_auth_mirror_none: "最近 auth 镜像:暂无记录";
698
702
  readonly status_auth_mirror_synced: "最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步";
package/dist/i18n.js CHANGED
@@ -117,7 +117,9 @@ const MESSAGES = {
117
117
  status_codex_credits: 'Codex credits: {value}',
118
118
  status_codex_limit_reached: 'Codex limit: {value}',
119
119
  status_runtime_overview: 'Telegram bot runtimes:',
120
- status_runtime_bot: '- {bot}: connected {connected}, auth {auth}, active turns {turns}',
120
+ status_runtime_bot: '- {bot}: connected {connected}, runtime {runtime}, auth {auth}, active turns {turns}',
121
+ status_runtime_kind_default: 'default/shared terminal',
122
+ status_runtime_kind_isolated: 'isolated',
121
123
  status_runtime_weixin: '- Weixin default runtime: connected {connected}, active turns {turns}',
122
124
  status_auth_mirror_none: 'Last auth mirror: none recorded',
123
125
  status_auth_mirror_synced: 'Last auth mirror: {candidate} from {source} at {time}',
@@ -690,7 +692,9 @@ const MESSAGES = {
690
692
  status_codex_credits: 'Codex 额度:{value}',
691
693
  status_codex_limit_reached: 'Codex 限制:{value}',
692
694
  status_runtime_overview: 'Telegram bot 运行时:',
693
- status_runtime_bot: '- {bot}:连接 {connected},auth {auth},进行中回复 {turns}',
695
+ status_runtime_bot: '- {bot}:连接 {connected},runtime {runtime},auth {auth},进行中回复 {turns}',
696
+ status_runtime_kind_default: '默认/终端共享',
697
+ status_runtime_kind_isolated: '隔离',
694
698
  status_runtime_weixin: '- 微信默认运行时:连接 {connected},进行中回复 {turns}',
695
699
  status_auth_mirror_none: '最近 auth 镜像:暂无记录',
696
700
  status_auth_mirror_synced: '最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步',
package/dist/main.js CHANGED
@@ -170,33 +170,46 @@ async function runServeCli() {
170
170
  store = new BridgeStore(config.storePath);
171
171
  if (config.tgMultiBotMode) {
172
172
  const seeds = [];
173
+ const canonicalAuthDir = config.codexAuthDir ?? config.codexHome ?? path.join(os.homedir(), '.codex');
173
174
  for (const token of config.tgBotTokens) {
174
175
  const bot = new TelegramGateway(token, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger, true);
175
176
  const id = await bot.initializeIdentity();
176
177
  if (seeds.some((runtime) => runtime.id === id)) {
177
178
  throw new Error(`TG_BOT_TOKENS contains duplicate Telegram bot identity: ${id}`);
178
179
  }
179
- const home = path.join(DEFAULT_CODEX_TELEGRAM_HOME, id, 'home');
180
- fs.mkdirSync(home, { recursive: true, mode: 0o700 });
180
+ const sharedDefaultRuntime = config.tgDefaultRuntimeBotToken === token;
181
+ const home = sharedDefaultRuntime
182
+ ? (config.codexHome ?? path.join(os.homedir(), '.codex'))
183
+ : path.join(DEFAULT_CODEX_TELEGRAM_HOME, id, 'home');
184
+ const authDir = sharedDefaultRuntime ? canonicalAuthDir : home;
185
+ if (!sharedDefaultRuntime) {
186
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
187
+ }
181
188
  const runtimeConfig = {
182
189
  ...config,
183
190
  tgBotToken: token,
184
191
  tgBotTokens: [token],
185
192
  tgScopeBotId: id,
186
- codexAuthDir: home,
187
- codexHome: home,
188
- codexAppAutolaunch: false,
189
- codexAppServerStatePath: path.join(APP_HOME, 'runtime', `codex-app-server-${id}.json`),
190
- codexAppServerLogPath: path.join(APP_HOME, 'logs', `codex-app-server-${id}.log`),
193
+ codexAuthDir: sharedDefaultRuntime ? config.codexAuthDir : home,
194
+ codexHome: sharedDefaultRuntime ? config.codexHome : home,
195
+ codexAppAutolaunch: sharedDefaultRuntime ? config.codexAppAutolaunch : false,
196
+ codexAppServerStatePath: sharedDefaultRuntime
197
+ ? config.codexAppServerStatePath
198
+ : path.join(APP_HOME, 'runtime', `codex-app-server-${id}.json`),
199
+ codexAppServerLogPath: sharedDefaultRuntime
200
+ ? config.codexAppServerLogPath
201
+ : path.join(APP_HOME, 'logs', `codex-app-server-${id}.log`),
191
202
  };
192
- const app = new CodexAppClient(runtimeConfig.codexCliBin, runtimeConfig.codexAppLaunchCmd, runtimeConfig.codexAppAutolaunch, runtimeConfig.codexAppServerStatePath, runtimeConfig.codexAppServerLogPath, logger, { CODEX_HOME: home }, ['cli_auth_credentials_store="file"']);
193
- seeds.push({ id, home, config: runtimeConfig, bot, app });
203
+ const childEnv = sharedDefaultRuntime
204
+ ? (config.codexHome ? { CODEX_HOME: config.codexHome } : null)
205
+ : { CODEX_HOME: home };
206
+ const app = new CodexAppClient(runtimeConfig.codexCliBin, runtimeConfig.codexAppLaunchCmd, runtimeConfig.codexAppAutolaunch, runtimeConfig.codexAppServerStatePath, runtimeConfig.codexAppServerLogPath, logger, childEnv, sharedDefaultRuntime ? [] : ['cli_auth_credentials_store="file"']);
207
+ seeds.push({ id, home, authDir, sharedDefaultRuntime, config: runtimeConfig, bot, app });
194
208
  }
195
- const canonicalAuthDir = config.codexAuthDir ?? config.codexHome ?? path.join(os.homedir(), '.codex');
196
209
  const mirror = new AuthCandidateMirror(canonicalAuthDir, seeds.map((runtime) => ({
197
210
  id: runtime.id,
198
211
  label: runtime.bot.username ? `@${runtime.bot.username}` : runtime.id,
199
- authDir: runtime.home,
212
+ authDir: runtime.authDir,
200
213
  validate: async (context) => validateRefreshedAuthCandidate(runtime, context.candidateName),
201
214
  notify: async (message) => {
202
215
  const chatId = store.getTelegramPrivateChatId(runtime.id);
@@ -247,6 +260,7 @@ async function runServeCli() {
247
260
  username: statuses[index]?.botUsername ?? runtime.bot.username,
248
261
  connected: running && Boolean(statuses[index]?.connected),
249
262
  activeTurns: running ? (statuses[index]?.activeTurns ?? 0) : 0,
263
+ runtimeKind: runtime.sharedDefaultRuntime ? 'default' : 'isolated',
250
264
  ...(statuses[index]?.codexAppServer ? { codexAppServer: statuses[index].codexAppServer } : {}),
251
265
  })),
252
266
  ...(weixinStatus ? {
@@ -274,6 +288,7 @@ async function runServeCli() {
274
288
  username: status.botUsername ?? runtime.bot.username,
275
289
  connected: status.connected,
276
290
  activeTurns: status.activeTurns,
291
+ runtimeKind: runtime.sharedDefaultRuntime ? 'default' : 'isolated',
277
292
  currentAuth: await runtime.core.getCurrentAuthLabel().catch(() => null),
278
293
  ...(status.codexAppServer ? { codexAppServer: status.codexAppServer } : {}),
279
294
  };
@@ -414,8 +429,8 @@ async function runServeCli() {
414
429
  }
415
430
  }
416
431
  async function validateRefreshedAuthCandidate(runtime, candidateName) {
417
- const authPath = path.join(runtime.home, 'auth.json');
418
- const candidatePath = path.join(runtime.home, candidateName);
432
+ const authPath = path.join(runtime.authDir, 'auth.json');
433
+ const candidatePath = path.join(runtime.authDir, candidateName);
419
434
  const [currentTarget, candidateTarget] = await Promise.all([
420
435
  fs.promises.realpath(authPath).catch(() => null),
421
436
  fs.promises.realpath(candidatePath).catch(() => null),
@@ -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
  }
package/dist/types.d.ts CHANGED
@@ -345,6 +345,7 @@ export interface RuntimeStatus {
345
345
  username: string | null;
346
346
  connected: boolean;
347
347
  activeTurns: number;
348
+ runtimeKind?: 'default' | 'isolated';
348
349
  currentAuth?: string | null;
349
350
  codexAppServer?: RuntimeStatus['codexAppServer'];
350
351
  }>;
@@ -162,6 +162,15 @@ TG_BOT_TOKENS=123456789:token_a,234567890:token_b,345678901:token_c
162
162
 
163
163
  FoxClaw still installs one service, but each bot receives its own app-server, session home, and current auth selection. After startup, privately send `/help` and `/status` to each bot.
164
164
 
165
+ If you want one of those bots to share terminal Codex sessions and the default auth, also set `TG_BOT_TOKEN` to the exact same token:
166
+
167
+ ```dotenv
168
+ TG_BOT_TOKENS=123456789:token_a,234567890:token_b
169
+ TG_BOT_TOKEN=123456789:token_a
170
+ ```
171
+
172
+ Only the matching bot uses the default/shared-terminal runtime; the others stay isolated.
173
+
165
174
  `DEFAULT_CWD` must be a real folder. Examples:
166
175
 
167
176
  ```dotenv
@@ -184,7 +184,7 @@ tail -f ~/.foxclaw/logs/service.log
184
184
 
185
185
  ## Checking Multi-Bot Mode
186
186
 
187
- After configuring `TG_BOT_TOKENS`, `foxclaw status` should contain one bot id, connection status, and independent app-server for each token. Send `/status` privately to any bot to see the runtime summary; send `/auth` to confirm the panel names the current `@botname` and its auth directory.
187
+ After configuring `TG_BOT_TOKENS`, `foxclaw status` should contain one bot id, connection status, and runtime type for each token. Send `/status` privately to any bot to see the runtime summary; send `/auth` to confirm the panel names the current `@botname` and its auth directory. If `TG_BOT_TOKEN` exactly matches one token in `TG_BOT_TOKENS`, that bot should appear as the default/shared-terminal runtime; the others should appear as isolated.
188
188
 
189
189
  Each isolated app-server log is stored at:
190
190
 
@@ -120,7 +120,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
120
120
 
121
121
  Fields:
122
122
 
123
- - `TG_BOT_TOKENS`: one or more `@BotFather` tokens separated by commas. The legacy single-bot `TG_BOT_TOKEN` setting remains compatible.
123
+ - `TG_BOT_TOKENS`: one or more `@BotFather` tokens separated by commas. The legacy single-bot `TG_BOT_TOKEN` setting remains compatible. In multi-bot mode, if the exact `TG_BOT_TOKEN` value also appears in `TG_BOT_TOKENS`, that bot uses the default/shared-terminal runtime.
124
124
  - `TG_ALLOWED_USER_ID`: your numeric Telegram user id.
125
125
  - `TG_ALLOWED_CHAT_ID`: leave empty for the first private-chat setup.
126
126
  - `TG_ALLOWED_TOPIC_ID`: leave empty unless binding a Telegram topic.
@@ -335,11 +335,13 @@ Watch mode mirrors live turn progress and approval requests. The watching chat i
335
335
 
336
336
  ## 6. Codex Login And Auth Rotation
337
337
 
338
- This is a key FoxClaw feature. Codex auth is usually stored at `~/.codex/auth.json`. FoxClaw stores multiple accounts as candidate files and switches which candidate the active `auth.json` points to. In `TG_BOT_TOKENS` mode, each bot has an isolated Codex home, app-server, and current candidate, so bots can run and switch accounts independently; isolated Telegram runtimes force file-backed credential storage. Validated login/refresh credentials are safely mirrored between bot homes, but sessions are never shared.
338
+ This is a key FoxClaw feature. Codex auth is usually stored at `~/.codex/auth.json`. FoxClaw stores multiple accounts as candidate files and switches which candidate the active `auth.json` points to. In `TG_BOT_TOKENS` mode, each bot has an isolated Codex home, app-server, and current candidate by default, so bots can run and switch accounts independently; isolated Telegram runtimes force file-backed credential storage. Validated login/refresh credentials are safely mirrored between bot homes, but isolated sessions are never shared.
339
+
340
+ 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` and default auth, so `/threads` can see local terminal sessions; its `/auth` switches also affect the terminal default auth. Bots listed only in `TG_BOT_TOKENS` stay isolated.
339
341
 
340
342
  ### 6.1 File Format
341
343
 
342
- In single-bot compatibility mode, candidate files live in the Codex auth directory, usually `~/.codex/`. If `CODEX_AUTH_DIR` is set, FoxClaw uses that directory. Multi-bot mode treats that directory as its candidate source and stores isolated bot copies under `~/.foxclaw/codex/telegram/bot<id>/home/`.
344
+ In single-bot compatibility mode, candidate files live in the Codex auth directory, usually `~/.codex/`. If `CODEX_AUTH_DIR` is set, FoxClaw uses that directory. Multi-bot mode treats that directory as its candidate source and stores isolated bot copies under `~/.foxclaw/codex/telegram/bot<id>/home/`. A default/shared-terminal bot does not get an isolated copy; it uses the default auth directory directly.
343
345
 
344
346
  Recommended layout:
345
347
 
@@ -387,7 +389,7 @@ If the login is cancelled or fails, FoxClaw tries to restore the previous auth t
387
389
 
388
390
  ### 6.3 The `/auth` Panel
389
391
 
390
- `/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.
391
393
 
392
394
  Approximation:
393
395
 
@@ -19,6 +19,7 @@
19
19
  ## 注意事项
20
20
 
21
21
  - 不要让 agent 把完整 bot token 打印到日志或提交到仓库。
22
+ - FoxClaw 仓库收尾提交默认使用 `中文 | English` 的双语 subject,方便国内和国际协作者同时阅读。
22
23
  - 第一次请先用私聊模式跑通。
23
24
  - 多个 token 会在同一服务中建立多个独立 Codex home、session 与 auth 选择;群组中必须点名或回复目标 bot。
24
25
  - 同时启用微信时,微信仍使用默认 Codex runtime,不共享隔离 Telegram bot 的线程。
@@ -160,6 +160,15 @@ TG_BOT_TOKENS=123456789:token_a,234567890:token_b,345678901:token_c
160
160
 
161
161
  FoxClaw 仍只安装一个服务,但每个 bot 会有独立 app-server、会话目录和当前 auth。服务启动后请分别私聊每个 bot 发送 `/help` 与 `/status`。
162
162
 
163
+ 如果你希望其中一个 bot 和终端 Codex 共享 session 与默认 auth,再把 `TG_BOT_TOKEN` 设置为同一个 token:
164
+
165
+ ```dotenv
166
+ TG_BOT_TOKENS=123456789:token_a,234567890:token_b
167
+ TG_BOT_TOKEN=123456789:token_a
168
+ ```
169
+
170
+ 只有匹配的 bot 使用默认/终端共享 runtime,其他 bot 仍然隔离。
171
+
163
172
  `DEFAULT_CWD` 必须是真实存在的目录,例如:
164
173
 
165
174
  ```dotenv
@@ -185,7 +185,7 @@ tail -f ~/.foxclaw/logs/service.log
185
185
 
186
186
  ## 多 bot 模式核查
187
187
 
188
- 配置 `TG_BOT_TOKENS` 后,`foxclaw status` 的 `bots` 列表应为每个 token 显示一个 bot id、连接状态和独立 app-server。私聊任一 bot 发送 `/status` 会显示全部 runtime 摘要;发送 `/auth` 应显示当前 `@botname` 和该 bot 的 auth 目录。
188
+ 配置 `TG_BOT_TOKENS` 后,`foxclaw status` 的 `bots` 列表应为每个 token 显示一个 bot id、连接状态和 runtime 类型。私聊任一 bot 发送 `/status` 会显示全部 runtime 摘要;发送 `/auth` 应显示当前 `@botname` 和该 bot 的 auth 目录。如果 `TG_BOT_TOKEN` 精确匹配 `TG_BOT_TOKENS` 中的一个 token,该 bot 应显示为默认/终端共享 runtime;其他 bot 应显示为隔离 runtime。
189
189
 
190
190
  每个隔离 app-server 的日志路径为:
191
191
 
@@ -120,7 +120,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
120
120
 
121
121
  字段说明:
122
122
 
123
- - `TG_BOT_TOKENS`:从 `@BotFather` 拿到的一个或多个 bot token,多项用英文逗号分隔。旧版单 bot 配置 `TG_BOT_TOKEN` 仍兼容。
123
+ - `TG_BOT_TOKENS`:从 `@BotFather` 拿到的一个或多个 bot token,多项用英文逗号分隔。旧版单 bot 配置 `TG_BOT_TOKEN` 仍兼容;多 bot 模式下,如果 `TG_BOT_TOKEN` 的值也出现在 `TG_BOT_TOKENS` 中,匹配的 bot 会使用默认/终端共享 runtime。
124
124
  - `TG_ALLOWED_USER_ID`:你的 Telegram 数字用户 ID。
125
125
  - `TG_ALLOWED_CHAT_ID`:第一次保持为空,表示私聊模式。
126
126
  - `TG_ALLOWED_TOPIC_ID`:第一次保持为空,表示不绑定 Telegram 话题。
@@ -335,11 +335,13 @@ FoxClaw 的聊天是“绑定线程”的。你在手机上打开某个 Codex
335
335
 
336
336
  ## 6. Codex 登录和 auth 轮转
337
337
 
338
- 这是 FoxClaw 的特色功能。Codex 的登录状态通常保存在 `~/.codex/auth.json`。FoxClaw 把多个账号保存成候选文件,并通过切换 `auth.json` 指向哪个候选来换号。启用 `TG_BOT_TOKENS` 多 bot 模式后,每个 bot 使用独立 Codex home、独立 app-server 和独立当前候选,因此可以并行运行、单独切号;隔离 Telegram runtime 会强制使用文件凭据存储。已验证的登录/刷新凭据会安全镜像到其他 bot home,但不会共享 session。
338
+ 这是 FoxClaw 的特色功能。Codex 的登录状态通常保存在 `~/.codex/auth.json`。FoxClaw 把多个账号保存成候选文件,并通过切换 `auth.json` 指向哪个候选来换号。启用 `TG_BOT_TOKENS` 多 bot 模式后,默认每个 bot 使用独立 Codex home、独立 app-server 和独立当前候选,因此可以并行运行、单独切号;隔离 Telegram runtime 会强制使用文件凭据存储。已验证的登录/刷新凭据会安全镜像到其他 bot home,但不会共享 session。
339
+
340
+ 如果你想保留一路和终端互通 session 的 Telegram bot,把该 token 同时写入 `TG_BOT_TOKENS` 和 `TG_BOT_TOKEN`。这个 bot 使用默认 `CODEX_HOME` 和默认 auth,因此能看到终端 Codex 的本地线程;它的 `/auth` 切换也会影响终端默认 auth。其他只出现在 `TG_BOT_TOKENS` 的 bot 仍然完全隔离。
339
341
 
340
342
  ### 6.1 文件格式
341
343
 
342
- 单 bot 兼容模式的候选文件放在 Codex auth 目录,默认是 `~/.codex/`。如果你设置了 `CODEX_AUTH_DIR`,则使用那个目录。多 bot 模式以这个目录作为候选源,并在 `~/.foxclaw/codex/telegram/bot<id>/home/` 下为每个 bot 保存隔离副本。
344
+ 单 bot 兼容模式的候选文件放在 Codex auth 目录,默认是 `~/.codex/`。如果你设置了 `CODEX_AUTH_DIR`,则使用那个目录。多 bot 模式以这个目录作为候选源,并在 `~/.foxclaw/codex/telegram/bot<id>/home/` 下为隔离 bot 保存副本。默认/终端共享 bot 不创建隔离副本,而是直接使用这个默认 auth 目录。
343
345
 
344
346
  推荐命名:
345
347
 
@@ -387,7 +389,7 @@ cp -L ~/.codex/auth.json ~/.codex/auth.json_personal
387
389
 
388
390
  ### 6.3 `/auth` 面板
389
391
 
390
- `/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 掌握到的额度信息,同时不会把不同账号混在一起。
391
393
 
392
394
  示意:
393
395
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: foxclaw
3
- description: Deploy, configure, validate, develop, and release FoxClaw. Use when Codex needs to clone or update the FoxClaw repo, collect Telegram values, write `.env`, enable launchd/systemd, guide first-message tests, or perform FoxClaw repo wrap-up actions such as Chinese commit messages, push, npm publish, and local install/service update.
3
+ description: Deploy, configure, validate, develop, and release FoxClaw. Use when Codex needs to clone or update the FoxClaw repo, collect Telegram values, write `.env`, enable launchd/systemd, guide first-message tests, or perform FoxClaw repo wrap-up actions such as bilingual Chinese | English commit messages, push, npm publish, and local install/service update.
4
4
  ---
5
5
 
6
6
  # FoxClaw
@@ -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
+ - 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
69
70
  - if Weixin is enabled alongside multiple Telegram bots, it remains on the default Codex runtime instead of borrowing a Telegram bot runtime
70
71
  - in a group with multiple configured bots, address a bot by mention, reply, or suffixed command such as `/status@botname`
71
72
 
@@ -145,7 +146,7 @@ After either bootstrap path:
145
146
  - the bot is an admin in the group
146
147
  - the configured `TG_ALLOWED_CHAT_ID` and `TG_ALLOWED_TOPIC_ID` match the target group/topic
147
148
  5. If group or topic mode is enabled, also verify that private chat still responds for the configured `TG_ALLOWED_USER_ID`.
148
- 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.
149
+ 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.
149
150
 
150
151
  ## First Telegram Message Check
151
152
 
@@ -169,7 +170,7 @@ Do not describe the setup as "done" until this smoke test has either passed or b
169
170
 
170
171
  ## Development Wrap-Up
171
172
 
172
- Use this checklist when the user asks for standard closing actions, release wrap-up, local install updates, npm publish, or says things like "收尾动作", "中文 commit msg", "push", "npm publish", or "本地安装更新".
173
+ Use this checklist when the user asks for standard closing actions, release wrap-up, local install updates, npm publish, or says things like "收尾动作", "中文 commit msg", "中英双语 commit msg", "push", "npm publish", or "本地安装更新".
173
174
 
174
175
  1. Inspect scope before staging:
175
176
  - `git status -sb`
@@ -183,7 +184,9 @@ Use this checklist when the user asks for standard closing actions, release wrap
183
184
  - `npm run build`
184
185
  4. Commit intentionally:
185
186
  - Stage only the changed files that belong to the task.
186
- - Use a Chinese commit message when the user asked in Chinese or explicitly said "中文 commit msg".
187
+ - For this repo, prefer a bilingual one-line subject in the form `中文 | English`.
188
+ - Put Chinese first, keep the English half semantically equivalent, and keep both halves concise.
189
+ - For release commits, use the same format, for example `发布 0.4.0:支持多机器人隔离 | Release 0.4.0: support multi-bot isolation`.
187
190
  - Never stage unrelated local changes.
188
191
  5. Push the current branch after a successful commit:
189
192
  - `git push origin <branch>`
@@ -37,7 +37,7 @@ Use this skill to publish an npm package from a repo. Prefer CI trusted publishi
37
37
  5. Commit and push source changes before `npm publish` when the user asked to push or when this is a normal release:
38
38
  ```bash
39
39
  git add <changed-files>
40
- git commit -m "<release message>"
40
+ git commit -m "<Chinese | English release message>"
41
41
  git push
42
42
  ```
43
43
 
@@ -56,7 +56,7 @@ Use this path when the repo has `.github/workflows/publish.yml` and npmjs.com ha
56
56
  ```bash
57
57
  npm version patch --no-git-tag-version
58
58
  git add package.json package-lock.json
59
- git commit -m "<Chinese release message when appropriate>"
59
+ git commit -m "<Chinese | English release message when appropriate>"
60
60
  git push origin <branch>
61
61
  ```
62
62