@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 +7 -4
- package/README.md +10 -5
- package/README_EN.md +10 -5
- package/dist/auth/mirror.d.ts +5 -0
- package/dist/auth/mirror.js +20 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +7 -0
- package/dist/controller/controller.d.ts +3 -0
- package/dist/controller/controller.js +97 -3
- package/dist/i18n.d.ts +6 -2
- package/dist/i18n.js +6 -2
- package/dist/main.js +28 -13
- package/dist/store/database.d.ts +11 -0
- package/dist/store/database.js +67 -0
- package/dist/types.d.ts +1 -0
- package/docs/install-for-beginners.md +9 -0
- package/docs/troubleshooting.md +1 -1
- package/docs/user-manual.md +6 -4
- package/docs/zh/foxclaw-skill.md +1 -0
- package/docs/zh/install-for-beginners.md +9 -0
- package/docs/zh/troubleshooting.md +1 -1
- package/docs/zh/user-manual.md +6 -4
- package/package.json +1 -1
- package/skills/foxclaw/SKILL.md +7 -4
- package/skills/npm-publish/SKILL.md +2 -2
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.
|
|
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/
|
|
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`
|
|
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
|
|
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
|
|
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`
|
|
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`
|
|
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,
|
|
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
|
|
package/dist/auth/mirror.d.ts
CHANGED
|
@@ -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>;
|
package/dist/auth/mirror.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
180
|
-
|
|
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:
|
|
190
|
-
|
|
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
|
|
193
|
-
|
|
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.
|
|
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.
|
|
418
|
-
const candidatePath = path.join(runtime.
|
|
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),
|
package/dist/store/database.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/store/database.js
CHANGED
|
@@ -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
|
@@ -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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/user-manual.md
CHANGED
|
@@ -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
|
|
package/docs/zh/foxclaw-skill.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/zh/user-manual.md
CHANGED
|
@@ -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
|
|
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/`
|
|
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
package/skills/foxclaw/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
-
|
|
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
|
|