@foxden-app/foxclaw 0.3.18 → 0.3.19

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,5 +1,8 @@
1
- # Required: one bot token per device
2
- TG_BOT_TOKEN=<telegram_bot_token>
1
+ # Required: one or more bot tokens for this host
2
+ # Comma-separated bot tokens. Each bot gets an independent Codex runtime/auth selection.
3
+ TG_BOT_TOKENS=<telegram_bot_token>
4
+ # Backward-compatible single-runtime setup:
5
+ # TG_BOT_TOKEN=<telegram_bot_token>
3
6
 
4
7
  # Required: only this Telegram user can control the bridge
5
8
  TG_ALLOWED_USER_ID=<telegram_user_id>
package/README.md CHANGED
@@ -75,7 +75,7 @@ foxclaw start
75
75
  跑 `doctor` 或 `start` 之前先把 `.env` 填好。私聊模式最小配置:
76
76
 
77
77
  ```dotenv
78
- TG_BOT_TOKEN=123456:telegram-token
78
+ TG_BOT_TOKENS=123456:telegram-token
79
79
  TG_ALLOWED_USER_ID=123456789
80
80
  DEFAULT_CWD=/absolute/path/to/workspace
81
81
  DEFAULT_APPROVAL_POLICY=on-request
@@ -84,7 +84,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
84
84
 
85
85
  配置文件默认在 `~/.foxclaw/.env`。想放别处的话设 `FOXCLAW_ENV=/path/to/.env`。
86
86
 
87
- `foxclaw start` 会自动检查环境并安装/重启后台服务。后续升级直接运行 `foxclaw update`,它会沿用当前的 npm/pnpm 全局安装方式,完成安装、自检和服务重启。
87
+ `foxclaw start` 会自动检查环境并安装/重启后台服务。后续升级直接运行 `foxclaw update`,它会沿用当前的 npm/pnpm 全局安装方式,尝试同步升级 npm/pnpm 安装的 Codex CLI,再完成安装、自检和服务重启。
88
88
 
89
89
  FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不代表群里所有人都能用。
90
90
 
@@ -111,6 +111,7 @@ 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
115
 
115
116
  </details>
116
117
 
@@ -178,7 +179,7 @@ FoxClaw 最初基于 `Gan-Xing/telegram-codex-app-bridge` fork 演进而来,
178
179
 
179
180
  ## Telegram 设置
180
181
 
181
- 1. 用 `@BotFather` 创建机器人,token 填入 `TG_BOT_TOKEN`。
182
+ 1. 用 `@BotFather` 创建一个或多个机器人,token 以逗号分隔填入 `TG_BOT_TOKENS`。
182
183
  2. 拿到你的 Telegram 数字用户 ID,填入 `TG_ALLOWED_USER_ID`。
183
184
  3. `foxclaw start` 启动。
184
185
  4. 打开和机器人的私聊,发 `/help`。
@@ -194,6 +195,15 @@ TG_ALLOWED_TOPIC_ID=42
194
195
  - 只填 `TG_ALLOWED_CHAT_ID` → 允许一个群组作为默认会话范围。
195
196
  - 两个都填 → 绑定到某个话题。
196
197
  - 配了群组后,`TG_ALLOWED_USER_ID` 的私聊依然可用。
198
+ - 配置多个 bot 时,同一个授权群内只有 `@botname` 命令、mention 或回复该 bot 的消息会被它处理,避免多个 Codex 会话同时响应。
199
+
200
+ 多 bot 并行示例:
201
+
202
+ ```dotenv
203
+ TG_BOT_TOKENS=123456:token_a,234567:token_b
204
+ ```
205
+
206
+ FoxClaw 仍然只运行一个系统服务,但会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新验证后镜像同步,各 bot 的当前选择互不影响。
197
207
 
198
208
  **怎么找群组和话题 ID:**
199
209
 
@@ -229,7 +239,7 @@ CODEX_APP_SYNC_ON_OPEN=true
229
239
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
230
240
  ```
231
241
 
232
- FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。重启时如果进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换会重启 app-server 以重新加载 `auth.json`。
242
+ FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,每个 bot 都有自己的 app-server Codex home;重启时如果对应进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换只重启发起操作的 bot runtime。
233
243
 
234
244
  一般不需要手动固定 app-server 端口。
235
245
 
package/README_EN.md CHANGED
@@ -75,7 +75,7 @@ foxclaw start
75
75
  Fill `.env` before running `doctor` or `start`. Minimum private-chat config:
76
76
 
77
77
  ```dotenv
78
- TG_BOT_TOKEN=123456:telegram-token
78
+ TG_BOT_TOKENS=123456:telegram-token
79
79
  TG_ALLOWED_USER_ID=123456789
80
80
  DEFAULT_CWD=/absolute/path/to/workspace
81
81
  DEFAULT_APPROVAL_POLICY=on-request
@@ -84,7 +84,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
84
84
 
85
85
  The default config file is `~/.foxclaw/.env`. Set `FOXCLAW_ENV=/path/to/.env` if you want to keep it somewhere else.
86
86
 
87
- `foxclaw start` runs checks and installs or restarts the background service. For later upgrades, run `foxclaw update`; it preserves the current npm/pnpm global-install method, runs checks, and restarts the service.
87
+ `foxclaw start` runs checks and installs or restarts the background service. For later upgrades, run `foxclaw update`; it preserves the current npm/pnpm global-install method, attempts to update an npm/pnpm-managed Codex CLI, runs checks, and restarts the service.
88
88
 
89
89
  FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a group does not make it available to every group member.
90
90
 
@@ -111,6 +111,7 @@ 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
115
 
115
116
  </details>
116
117
 
@@ -178,7 +179,7 @@ FoxClaw originally evolved from a fork of `Gan-Xing/telegram-codex-app-bridge` a
178
179
 
179
180
  ## Telegram Setup
180
181
 
181
- 1. Create a bot with `@BotFather` and copy the token into `TG_BOT_TOKEN`.
182
+ 1. Create one or more bots with `@BotFather` and put their comma-separated tokens into `TG_BOT_TOKENS`.
182
183
  2. Get your Telegram numeric user id and place it into `TG_ALLOWED_USER_ID`.
183
184
  3. Start FoxClaw with `foxclaw start`.
184
185
  4. Open a private chat with the bot and send `/help`.
@@ -194,6 +195,15 @@ TG_ALLOWED_TOPIC_ID=42
194
195
  - Set `TG_ALLOWED_CHAT_ID` only to allow one group as the default conversation scope.
195
196
  - Set both `TG_ALLOWED_CHAT_ID` and `TG_ALLOWED_TOPIC_ID` to bind one topic as the default scope.
196
197
  - Private chat remains available for `TG_ALLOWED_USER_ID` even when a group is configured.
198
+ - With multiple bots, a configured group handles only `@botname` commands, mentions, or replies addressed to each bot, preventing duplicate Codex responses.
199
+
200
+ Parallel bot example:
201
+
202
+ ```dotenv
203
+ TG_BOT_TOKENS=123456:token_a,234567:token_b
204
+ ```
205
+
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.
197
207
 
198
208
  **How to find group and topic IDs:**
199
209
 
@@ -229,7 +239,7 @@ CODEX_APP_SYNC_ON_OPEN=true
229
239
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
230
240
  ```
231
241
 
232
- FoxClaw starts `codex app-server` as a detached, bridge-managed process and records its pid and port. On restart, it reconnects to the recorded app-server if that process is still alive; otherwise it starts a new one. `/auth_reload` and auth switching restart the managed app-server so the current `auth.json` is reloaded.
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. 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.
233
243
 
234
244
  No static Codex app-server port is required in normal installs.
235
245
 
@@ -0,0 +1,25 @@
1
+ import type { Logger } from '../logger.js';
2
+ export interface AuthMirrorRuntime {
3
+ id: string;
4
+ authDir: string;
5
+ notify?: (message: string) => Promise<void>;
6
+ }
7
+ export declare class AuthCandidateMirror {
8
+ private readonly canonicalDir;
9
+ private readonly runtimes;
10
+ private readonly logger;
11
+ private timer;
12
+ private readonly lastSyncedRefresh;
13
+ constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger);
14
+ initialize(): Promise<void>;
15
+ start(): void;
16
+ stop(): void;
17
+ syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
18
+ private scan;
19
+ private propagateValidatedCandidate;
20
+ private ensureCanonicalDefaultCandidate;
21
+ private collectCandidateNames;
22
+ private reconcileCandidateAtStartup;
23
+ private resolveCanonicalCurrentCandidate;
24
+ }
25
+ export declare function isAuthCandidateName(name: string): boolean;
@@ -0,0 +1,199 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const AUTH_SCAN_INTERVAL_MS = 5_000;
4
+ export class AuthCandidateMirror {
5
+ canonicalDir;
6
+ runtimes;
7
+ logger;
8
+ timer = null;
9
+ lastSyncedRefresh = new Map();
10
+ constructor(canonicalDir, runtimes, logger) {
11
+ this.canonicalDir = canonicalDir;
12
+ this.runtimes = runtimes;
13
+ this.logger = logger;
14
+ }
15
+ async initialize() {
16
+ await fs.mkdir(this.canonicalDir, { recursive: true, mode: 0o700 });
17
+ await this.ensureCanonicalDefaultCandidate();
18
+ const candidateNames = await this.collectCandidateNames();
19
+ for (const name of candidateNames) {
20
+ await this.reconcileCandidateAtStartup(name);
21
+ }
22
+ const canonicalCandidateNames = await listAuthCandidateNames(this.canonicalDir);
23
+ const defaultCandidate = await this.resolveCanonicalCurrentCandidate()
24
+ ?? canonicalCandidateNames.sort()[0]
25
+ ?? null;
26
+ for (const runtime of this.runtimes) {
27
+ await fs.mkdir(runtime.authDir, { recursive: true, mode: 0o700 });
28
+ for (const name of canonicalCandidateNames) {
29
+ const destination = path.join(runtime.authDir, name);
30
+ if (!(await exists(destination))) {
31
+ await atomicCopy(path.join(this.canonicalDir, name), destination);
32
+ }
33
+ }
34
+ if (defaultCandidate && !(await exists(path.join(runtime.authDir, 'auth.json')))) {
35
+ await pointAuthSymlink(runtime.authDir, defaultCandidate);
36
+ }
37
+ }
38
+ }
39
+ start() {
40
+ if (this.timer)
41
+ return;
42
+ this.timer = setInterval(() => {
43
+ void this.scan().catch((error) => {
44
+ this.logger.warn('auth.mirror.scan_failed', { error: formatError(error) });
45
+ });
46
+ }, AUTH_SCAN_INTERVAL_MS);
47
+ this.timer.unref();
48
+ }
49
+ stop() {
50
+ if (!this.timer)
51
+ return;
52
+ clearInterval(this.timer);
53
+ this.timer = null;
54
+ }
55
+ async syncRuntimeCandidate(runtimeId, candidateName) {
56
+ if (!isAuthCandidateName(candidateName))
57
+ return false;
58
+ const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
59
+ if (!runtime)
60
+ return false;
61
+ return this.propagateValidatedCandidate(runtime, candidateName);
62
+ }
63
+ async scan() {
64
+ for (const runtime of this.runtimes) {
65
+ const names = await listAuthCandidateNames(runtime.authDir);
66
+ for (const name of names) {
67
+ await this.propagateValidatedCandidate(runtime, name);
68
+ }
69
+ }
70
+ }
71
+ async propagateValidatedCandidate(runtime, name) {
72
+ const sourcePath = path.join(runtime.authDir, name);
73
+ const record = await readChatGptAuthRecord(sourcePath);
74
+ if (!record)
75
+ return false;
76
+ const canonicalPath = path.join(this.canonicalDir, name);
77
+ const canonical = await readChatGptAuthRecord(canonicalPath);
78
+ if (canonical && canonical.accountId !== record.accountId) {
79
+ this.logger.warn('auth.mirror.account_conflict', { runtimeId: runtime.id, name });
80
+ return false;
81
+ }
82
+ const previousRefresh = Math.max(canonical?.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(name) ?? 0);
83
+ if (record.lastRefreshMs <= previousRefresh) {
84
+ return false;
85
+ }
86
+ await atomicWrite(canonicalPath, record.raw);
87
+ for (const target of this.runtimes) {
88
+ if (target.id !== runtime.id) {
89
+ await atomicWrite(path.join(target.authDir, name), record.raw);
90
+ }
91
+ }
92
+ this.lastSyncedRefresh.set(name, record.lastRefreshMs);
93
+ this.logger.info('auth.mirror.synced', { sourceRuntimeId: runtime.id, name });
94
+ const message = `${name} has been refreshed by ${runtime.id} and synchronized to the other Codex homes.`;
95
+ await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
96
+ return true;
97
+ }
98
+ async ensureCanonicalDefaultCandidate() {
99
+ const names = await listAuthCandidateNames(this.canonicalDir);
100
+ if (names.length > 0)
101
+ return;
102
+ const authPath = path.join(this.canonicalDir, 'auth.json');
103
+ if (!(await exists(authPath)))
104
+ return;
105
+ const finalPath = await resolveFinalPath(authPath);
106
+ if (isAuthCandidateName(path.basename(finalPath)))
107
+ return;
108
+ await atomicCopy(authPath, path.join(this.canonicalDir, 'auth.json_default'));
109
+ }
110
+ async collectCandidateNames() {
111
+ const all = new Set(await listAuthCandidateNames(this.canonicalDir));
112
+ for (const runtime of this.runtimes) {
113
+ for (const name of await listAuthCandidateNames(runtime.authDir)) {
114
+ all.add(name);
115
+ }
116
+ }
117
+ return [...all];
118
+ }
119
+ async reconcileCandidateAtStartup(name) {
120
+ const paths = [
121
+ path.join(this.canonicalDir, name),
122
+ ...this.runtimes.map((runtime) => path.join(runtime.authDir, name)),
123
+ ];
124
+ const records = (await Promise.all(paths.map(async (sourcePath) => ({
125
+ sourcePath,
126
+ record: await readChatGptAuthRecord(sourcePath),
127
+ })))).filter((entry) => entry.record !== null);
128
+ if (records.length === 0)
129
+ return;
130
+ const accountIds = new Set(records.map((entry) => entry.record.accountId));
131
+ if (accountIds.size !== 1) {
132
+ this.logger.warn('auth.mirror.startup_conflict', { name });
133
+ return;
134
+ }
135
+ const newest = records.reduce((current, entry) => (entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current));
136
+ this.lastSyncedRefresh.set(name, newest.record.lastRefreshMs);
137
+ for (const destination of paths) {
138
+ await atomicWrite(destination, newest.record.raw);
139
+ }
140
+ }
141
+ async resolveCanonicalCurrentCandidate() {
142
+ const finalPath = await resolveFinalPath(path.join(this.canonicalDir, 'auth.json'));
143
+ const name = path.basename(finalPath);
144
+ return isAuthCandidateName(name) ? name : null;
145
+ }
146
+ }
147
+ export function isAuthCandidateName(name) {
148
+ return name !== 'auth.json'
149
+ && !name.startsWith('.auth.json.')
150
+ && (name.startsWith('auth.json_') || name.startsWith('auth.json.') || name.startsWith('auth.json-'));
151
+ }
152
+ async function listAuthCandidateNames(dir) {
153
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
154
+ return entries
155
+ .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && isAuthCandidateName(entry.name))
156
+ .map((entry) => entry.name);
157
+ }
158
+ async function readChatGptAuthRecord(filePath) {
159
+ try {
160
+ const raw = await fs.readFile(filePath, 'utf8');
161
+ const parsed = JSON.parse(raw);
162
+ const accountId = typeof parsed.tokens?.account_id === 'string' ? parsed.tokens.account_id : '';
163
+ const lastRefreshMs = typeof parsed.last_refresh === 'string' ? Date.parse(parsed.last_refresh) : NaN;
164
+ if (!accountId || !Number.isFinite(lastRefreshMs))
165
+ return null;
166
+ return { raw, accountId, lastRefreshMs };
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ async function atomicCopy(source, destination) {
173
+ await atomicWrite(destination, await fs.readFile(source, 'utf8'));
174
+ }
175
+ async function atomicWrite(destination, contents) {
176
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
177
+ const temporary = path.join(path.dirname(destination), `.auth.json.${process.pid}.${Date.now()}.tmp`);
178
+ await fs.writeFile(temporary, contents, { encoding: 'utf8', mode: 0o600 });
179
+ await fs.rename(temporary, destination);
180
+ }
181
+ async function pointAuthSymlink(dir, candidateName) {
182
+ const temporary = path.join(dir, `.auth.json.${process.pid}.${Date.now()}.link`);
183
+ await fs.symlink(path.join(dir, candidateName), temporary);
184
+ await fs.rename(temporary, path.join(dir, 'auth.json'));
185
+ }
186
+ async function resolveFinalPath(sourcePath) {
187
+ try {
188
+ return await fs.realpath(sourcePath);
189
+ }
190
+ catch {
191
+ return sourcePath;
192
+ }
193
+ }
194
+ async function exists(filePath) {
195
+ return fs.lstat(filePath).then(() => true).catch(() => false);
196
+ }
197
+ function formatError(error) {
198
+ return error instanceof Error ? error.message : String(error);
199
+ }
@@ -65,6 +65,7 @@ export declare class CodexAppClient extends EventEmitter {
65
65
  private readonly serverStatePath;
66
66
  private readonly serverLogPath;
67
67
  private readonly logger;
68
+ private readonly childEnv;
68
69
  private child;
69
70
  private socket;
70
71
  private requestId;
@@ -75,7 +76,7 @@ export declare class CodexAppClient extends EventEmitter {
75
76
  private port;
76
77
  private connected;
77
78
  private userAgent;
78
- constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger);
79
+ constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null);
79
80
  isConnected(): boolean;
80
81
  getUserAgent(): string | null;
81
82
  getServerStatus(): CodexAppServerRuntimeStatus;
@@ -13,6 +13,7 @@ export class CodexAppClient extends EventEmitter {
13
13
  serverStatePath;
14
14
  serverLogPath;
15
15
  logger;
16
+ childEnv;
16
17
  child = null;
17
18
  socket = null;
18
19
  requestId = 0;
@@ -23,7 +24,7 @@ export class CodexAppClient extends EventEmitter {
23
24
  port = null;
24
25
  connected = false;
25
26
  userAgent = null;
26
- constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger) {
27
+ constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger, childEnv = null) {
27
28
  super();
28
29
  this.codexCliBin = codexCliBin;
29
30
  this.launchCommand = launchCommand;
@@ -31,6 +32,7 @@ export class CodexAppClient extends EventEmitter {
31
32
  this.serverStatePath = serverStatePath;
32
33
  this.serverLogPath = serverLogPath;
33
34
  this.logger = logger;
35
+ this.childEnv = childEnv;
34
36
  }
35
37
  isConnected() {
36
38
  return this.connected;
@@ -463,7 +465,12 @@ export class CodexAppClient extends EventEmitter {
463
465
  return;
464
466
  }
465
467
  if (this.autolaunch) {
466
- const launcher = spawn(this.launchCommand, { shell: true, detached: true, stdio: 'ignore' });
468
+ const launcher = spawn(this.launchCommand, {
469
+ shell: true,
470
+ detached: true,
471
+ stdio: 'ignore',
472
+ env: this.childEnv ? { ...process.env, ...this.childEnv } : process.env,
473
+ });
467
474
  launcher.unref();
468
475
  }
469
476
  this.port = await reservePort();
@@ -473,6 +480,7 @@ export class CodexAppClient extends EventEmitter {
473
480
  child = spawn(this.codexCliBin, ['app-server', '--listen', `ws://127.0.0.1:${this.port}`], {
474
481
  detached: true,
475
482
  stdio: ['ignore', stdoutFd, stderrFd],
483
+ env: this.childEnv ? { ...process.env, ...this.childEnv } : process.env,
476
484
  });
477
485
  }
478
486
  finally {
package/dist/config.d.ts CHANGED
@@ -7,12 +7,17 @@ export declare const DEFAULT_LOG_PATH: string;
7
7
  export declare const DEFAULT_LOCK_PATH: string;
8
8
  export declare const DEFAULT_CODEX_APP_SERVER_STATE_PATH: string;
9
9
  export declare const DEFAULT_CODEX_APP_SERVER_LOG_PATH: string;
10
+ export declare const DEFAULT_CODEX_TELEGRAM_HOME: string;
10
11
  export declare const DEFAULT_ENV_PATH: string;
11
12
  export declare function resolveEnvPath(): string;
12
13
  export declare function getLoadedEnvPath(): string | null;
13
14
  export declare function loadEnv(): void;
14
15
  export interface AppConfig {
15
16
  tgBotToken: string;
17
+ tgBotTokens: string[];
18
+ tgMultiBotMode: boolean;
19
+ tgScopeBotId: string | null;
20
+ tgRequireExplicitGroupAddressing: boolean;
16
21
  tgAllowedUserId: string;
17
22
  tgAllowedChatId: string | null;
18
23
  tgAllowedTopicId: number | null;
@@ -21,6 +26,8 @@ export interface AppConfig {
21
26
  codexAppLaunchCmd: string;
22
27
  codexAppServerStatePath: string;
23
28
  codexAppServerLogPath: string;
29
+ codexAuthDir: string | null;
30
+ codexHome: string | null;
24
31
  codexAppSyncOnOpen: boolean;
25
32
  codexAppSyncOnTurnComplete: boolean;
26
33
  storePath: string;
package/dist/config.js CHANGED
@@ -10,6 +10,7 @@ export const DEFAULT_LOG_PATH = path.join(APP_HOME, 'logs', 'service.log');
10
10
  export const DEFAULT_LOCK_PATH = path.join(APP_HOME, 'runtime', 'bridge.lock');
11
11
  export const DEFAULT_CODEX_APP_SERVER_STATE_PATH = path.join(APP_HOME, 'runtime', 'codex-app-server.json');
12
12
  export const DEFAULT_CODEX_APP_SERVER_LOG_PATH = path.join(APP_HOME, 'logs', 'codex-app-server.log');
13
+ export const DEFAULT_CODEX_TELEGRAM_HOME = path.join(APP_HOME, 'codex', 'telegram');
13
14
  export const DEFAULT_ENV_PATH = path.join(APP_HOME, '.env');
14
15
  let envLoaded = false;
15
16
  let loadedEnvPath = null;
@@ -37,8 +38,22 @@ export function loadEnv() {
37
38
  }
38
39
  export function loadConfig() {
39
40
  loadEnv();
41
+ const configuredTokens = parseCommaSeparatedIds(process.env.TG_BOT_TOKENS);
42
+ const legacyToken = optional('TG_BOT_TOKEN');
43
+ const tgBotTokens = configuredTokens.length > 0
44
+ ? configuredTokens
45
+ : legacyToken
46
+ ? [legacyToken]
47
+ : [];
48
+ if (tgBotTokens.length === 0) {
49
+ throw new Error('TG_BOT_TOKENS or TG_BOT_TOKEN is required');
50
+ }
40
51
  const config = {
41
- tgBotToken: required('TG_BOT_TOKEN'),
52
+ tgBotToken: tgBotTokens[0],
53
+ tgBotTokens,
54
+ tgMultiBotMode: configuredTokens.length > 0,
55
+ tgScopeBotId: null,
56
+ tgRequireExplicitGroupAddressing: configuredTokens.length > 1,
42
57
  tgAllowedUserId: required('TG_ALLOWED_USER_ID'),
43
58
  tgAllowedChatId: optional('TG_ALLOWED_CHAT_ID'),
44
59
  tgAllowedTopicId: nullableIntEnv('TG_ALLOWED_TOPIC_ID'),
@@ -47,6 +62,8 @@ export function loadConfig() {
47
62
  codexAppLaunchCmd: process.env.CODEX_APP_LAUNCH_CMD || 'codex app',
48
63
  codexAppServerStatePath: process.env.CODEX_APP_SERVER_STATE_PATH || DEFAULT_CODEX_APP_SERVER_STATE_PATH,
49
64
  codexAppServerLogPath: process.env.CODEX_APP_SERVER_LOG_PATH || DEFAULT_CODEX_APP_SERVER_LOG_PATH,
65
+ codexAuthDir: process.env.CODEX_AUTH_DIR?.trim() || null,
66
+ codexHome: process.env.CODEX_HOME?.trim() || null,
50
67
  codexAppSyncOnOpen: boolEnv('CODEX_APP_SYNC_ON_OPEN', true),
51
68
  codexAppSyncOnTurnComplete: boolEnv('CODEX_APP_SYNC_ON_TURN_COMPLETE', false),
52
69
  storePath: process.env.STORE_PATH || DEFAULT_STORE_PATH,
@@ -6,6 +6,11 @@ import type { TelegramGateway, TelegramTextEvent } from '../telegram/gateway.js'
6
6
  import { BridgeMessagingRouter } from '../channels/bridge_messaging_router.js';
7
7
  import type { CodexAppClient } from '../codex_app/client.js';
8
8
  import type { SelfUpdateRuntime } from '../update.js';
9
+ export interface CoreCoordinator {
10
+ canSelfUpdate?: () => boolean;
11
+ authCandidateUpdated?: (runtimeId: string, candidateName: string) => Promise<void>;
12
+ statusUpdated?: (status: RuntimeStatus) => void;
13
+ }
9
14
  export declare class BridgeSessionCore {
10
15
  private readonly config;
11
16
  private readonly store;
@@ -13,6 +18,7 @@ export declare class BridgeSessionCore {
13
18
  private readonly bot;
14
19
  private readonly app;
15
20
  private readonly selfUpdater;
21
+ private readonly coordinator;
16
22
  private activeTurns;
17
23
  private activeTurnsByTurnId;
18
24
  private observedThreadWatchers;
@@ -51,7 +57,7 @@ export declare class BridgeSessionCore {
51
57
  /** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
52
58
  private threadListPresentationState;
53
59
  private readonly messaging;
54
- constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter, selfUpdater?: SelfUpdateRuntime | null);
60
+ constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter, selfUpdater?: SelfUpdateRuntime | null, coordinator?: CoreCoordinator | null);
55
61
  /** Wire Telegram inbound events. Call before {@link startCodexApp}. */
56
62
  registerTelegramInboundHandlers(): void;
57
63
  /**
@@ -140,6 +146,10 @@ export declare class BridgeSessionCore {
140
146
  private stageAttachments;
141
147
  private registerActiveTurn;
142
148
  private createActiveTurnState;
149
+ isIdleForServiceUpdate(): boolean;
150
+ private hasLocalBlockingActivity;
151
+ private authRuntimeId;
152
+ private ownsScope;
143
153
  private setActiveTurn;
144
154
  private getActiveTurn;
145
155
  private getActiveTurnsForTurn;
@@ -261,6 +271,7 @@ export declare class BridgeSessionCore {
261
271
  private retryTurnAfterAuthRotation;
262
272
  private selectNextCodexAuthCandidate;
263
273
  private listCodexAuthState;
274
+ private resolveAuthDir;
264
275
  private readCodexAuthSwitchLabels;
265
276
  private codexAuthSwitchParams;
266
277
  private switchCodexAuthAndRestart;
@@ -277,6 +288,7 @@ export declare class BridgeSessionCore {
277
288
  private readCodexAuthQuotaSnapshots;
278
289
  private writeCodexAuthQuotaSnapshots;
279
290
  private codexAuthQuotaSnapshotPath;
291
+ private runtimeSnapshotFilename;
280
292
  private sendThreadContextSummary;
281
293
  private handleModelCommand;
282
294
  private handleEffortCommand;