@foxden-app/foxclaw 0.3.19 → 0.4.0

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,4 +1,4 @@
1
- # Required: one or more bot tokens for this host
1
+ # Required for new installs: one or more bot tokens for this host
2
2
  # Comma-separated bot tokens. Each bot gets an independent Codex runtime/auth selection.
3
3
  TG_BOT_TOKENS=<telegram_bot_token>
4
4
  # Backward-compatible single-runtime setup:
@@ -14,7 +14,9 @@ TG_ALLOWED_CHAT_ID=
14
14
  # Leave empty to use the whole allowed group as the default scope
15
15
  TG_ALLOWED_TOPIC_ID=
16
16
 
17
- # Optional Codex Desktop bridge settings
17
+ # Optional Codex Desktop bridge settings.
18
+ # 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.
18
20
  CODEX_APP_AUTOLAUNCH=true
19
21
  CODEX_APP_LAUNCH_CMD=codex app
20
22
  # 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 分钟。
43
+ 最低要求:一个或多个 Telegram bot token、你的 Telegram 数字用户 ID、Node.js 24+、一份已登录的 `codex` CLI。首次安装大约 10–20 分钟。新安装请使用 `TG_BOT_TOKENS`;`TG_BOT_TOKEN` 只用于兼容旧的单 runtime 配置。
44
44
 
45
45
  **30 秒体验**:启动 FoxClaw 后,给你的 Telegram 机器人发一句 `List files in DEFAULT_CWD`。Codex 会在本地检查那个目录,然后把结果发回 Telegram。
46
46
 
@@ -93,7 +93,7 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
93
93
 
94
94
  **核心能力:**
95
95
  - 通过 Telegram 私聊、群组、话题控制本地 Codex
96
- - 可选微信/iLink 通道,复用同一套桥接核心
96
+ - 可选微信/iLink 通道;在多 Telegram bot 模式下仍使用原默认 Codex runtime,不与隔离 bot 会话混用
97
97
  - 手机上完整管理 Codex 线程生命周期:创建、重命名、归档、fork、回滚、compact、review、diff
98
98
  - 命令、文件变更、细粒度权限审批的内联按钮——手机上一键审批
99
99
  - MCP elicitation 卡片——工具在 turn 中提出结构化问题时展示
@@ -203,7 +203,7 @@ TG_ALLOWED_TOPIC_ID=42
203
203
  TG_BOT_TOKENS=123456:token_a,234567:token_b
204
204
  ```
205
205
 
206
- FoxClaw 仍然只运行一个系统服务,但会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新验证后镜像同步,各 bot 的当前选择互不影响。
206
+ FoxClaw 仍然只运行一个系统服务,但会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新验证后镜像同步,各 bot 的当前选择互不影响。每个 bot 首次私聊发送 `/help` 和 `/status`;`/auth` 会标明正在操作的 bot runtime,`/status` 会列出全部 bot 的连接、当前 auth 和活动 turn 摘要。
207
207
 
208
208
  **怎么找群组和话题 ID:**
209
209
 
@@ -239,7 +239,7 @@ CODEX_APP_SYNC_ON_OPEN=true
239
239
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
240
240
  ```
241
241
 
242
- FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,每个 bot 都有自己的 app-server 与 Codex home;重启时如果对应进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换只重启发起操作的 bot runtime。
242
+ FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,每个 bot 都有自己的 app-server 与 Codex home,并在该隔离 runtime 内强制使用文件凭据存储;隔离 bot 不自动拉起 Codex Desktop,避免多个新 home 同时初始化桌面状态。重启时如果对应进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换只重启发起操作的 bot runtime。
243
243
 
244
244
  一般不需要手动固定 app-server 端口。
245
245
 
@@ -284,7 +284,7 @@ WX_ALLOWED_ILINK_USER_IDS=
284
284
  foxclaw weixin-login
285
285
  ```
286
286
 
287
- 微信运行时文件在 `~/.foxclaw/weixin`。
287
+ 微信运行时文件在 `~/.foxclaw/weixin`。启用 `TG_BOT_TOKENS` 时,微信继续连接默认 Codex runtime 与原 home,不会查看或恢复隔离 Telegram bot 的线程。
288
288
 
289
289
  ## Codex Skill
290
290
 
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 only a Telegram bot token, your numeric Telegram user id, Node.js 24+, and a logged-in `codex` CLI. A first install usually takes 10–20 minutes.
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.
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
 
@@ -93,7 +93,7 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
93
93
 
94
94
  **Core capabilities:**
95
95
  - Telegram private chat, group, and topic control for your local Codex
96
- - Optional Weixin/iLink channel sharing the same bridge core
96
+ - Optional Weixin/iLink channel; in multi-Telegram mode it stays on the original default Codex runtime rather than sharing an isolated bot session
97
97
  - Full thread lifecycle management from mobile: create, rename, archive, fork, rollback, compact, review, diff
98
98
  - Inline approval buttons for commands, file changes, and granular permissions — one tap to approve
99
99
  - MCP elicitation cards for structured questions raised by tools during a turn
@@ -203,7 +203,7 @@ Parallel bot example:
203
203
  TG_BOT_TOKENS=123456:token_a,234567:token_b
204
204
  ```
205
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.
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.
207
207
 
208
208
  **How to find group and topic IDs:**
209
209
 
@@ -239,7 +239,7 @@ CODEX_APP_SYNC_ON_OPEN=true
239
239
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
240
240
  ```
241
241
 
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.
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.
243
243
 
244
244
  No static Codex app-server port is required in normal installs.
245
245
 
@@ -284,7 +284,7 @@ Run the QR login helper once after building:
284
284
  foxclaw weixin-login
285
285
  ```
286
286
 
287
- Weixin runtime files default to `~/.foxclaw/weixin`.
287
+ Weixin runtime files default to `~/.foxclaw/weixin`. When `TG_BOT_TOKENS` is enabled, Weixin continues to use the default Codex runtime and original home; it does not inspect or resume isolated Telegram bot threads.
288
288
 
289
289
  ## Codex Skill
290
290
 
@@ -1,22 +1,35 @@
1
1
  import type { Logger } from '../logger.js';
2
2
  export interface AuthMirrorRuntime {
3
3
  id: string;
4
+ label?: string;
4
5
  authDir: string;
5
6
  notify?: (message: string) => Promise<void>;
6
7
  }
8
+ export interface AuthMirrorStatus {
9
+ candidateName: string;
10
+ sourceRuntimeId: string;
11
+ sourceLabel: string;
12
+ syncedAt: string;
13
+ }
7
14
  export declare class AuthCandidateMirror {
8
15
  private readonly canonicalDir;
9
16
  private readonly runtimes;
10
17
  private readonly logger;
18
+ private readonly statusPath;
11
19
  private timer;
12
20
  private readonly lastSyncedRefresh;
13
- constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger);
21
+ private activeOperations;
22
+ private lastStatus;
23
+ constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger, statusPath?: string | null);
14
24
  initialize(): Promise<void>;
15
25
  start(): void;
16
26
  stop(): void;
27
+ isIdle(): boolean;
28
+ getStatus(): AuthMirrorStatus | null;
17
29
  syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
18
30
  private scan;
19
31
  private propagateValidatedCandidate;
32
+ private withActivity;
20
33
  private ensureCanonicalDefaultCandidate;
21
34
  private collectCandidateNames;
22
35
  private reconcileCandidateAtStartup;
@@ -5,14 +5,19 @@ export class AuthCandidateMirror {
5
5
  canonicalDir;
6
6
  runtimes;
7
7
  logger;
8
+ statusPath;
8
9
  timer = null;
9
10
  lastSyncedRefresh = new Map();
10
- constructor(canonicalDir, runtimes, logger) {
11
+ activeOperations = 0;
12
+ lastStatus = null;
13
+ constructor(canonicalDir, runtimes, logger, statusPath = null) {
11
14
  this.canonicalDir = canonicalDir;
12
15
  this.runtimes = runtimes;
13
16
  this.logger = logger;
17
+ this.statusPath = statusPath;
14
18
  }
15
19
  async initialize() {
20
+ this.lastStatus = await readMirrorStatus(this.statusPath);
16
21
  await fs.mkdir(this.canonicalDir, { recursive: true, mode: 0o700 });
17
22
  await this.ensureCanonicalDefaultCandidate();
18
23
  const candidateNames = await this.collectCandidateNames();
@@ -52,21 +57,29 @@ export class AuthCandidateMirror {
52
57
  clearInterval(this.timer);
53
58
  this.timer = null;
54
59
  }
60
+ isIdle() {
61
+ return this.activeOperations === 0;
62
+ }
63
+ getStatus() {
64
+ return this.lastStatus;
65
+ }
55
66
  async syncRuntimeCandidate(runtimeId, candidateName) {
56
67
  if (!isAuthCandidateName(candidateName))
57
68
  return false;
58
69
  const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
59
70
  if (!runtime)
60
71
  return false;
61
- return this.propagateValidatedCandidate(runtime, candidateName);
72
+ return this.withActivity(() => this.propagateValidatedCandidate(runtime, candidateName));
62
73
  }
63
74
  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);
75
+ await this.withActivity(async () => {
76
+ for (const runtime of this.runtimes) {
77
+ const names = await listAuthCandidateNames(runtime.authDir);
78
+ for (const name of names) {
79
+ await this.propagateValidatedCandidate(runtime, name);
80
+ }
68
81
  }
69
- }
82
+ });
70
83
  }
71
84
  async propagateValidatedCandidate(runtime, name) {
72
85
  const sourcePath = path.join(runtime.authDir, name);
@@ -90,11 +103,28 @@ export class AuthCandidateMirror {
90
103
  }
91
104
  }
92
105
  this.lastSyncedRefresh.set(name, record.lastRefreshMs);
106
+ const sourceLabel = runtime.label ?? runtime.id;
107
+ this.lastStatus = {
108
+ candidateName: name,
109
+ sourceRuntimeId: runtime.id,
110
+ sourceLabel,
111
+ syncedAt: new Date().toISOString(),
112
+ };
113
+ await writeMirrorStatus(this.statusPath, this.lastStatus);
93
114
  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.`;
115
+ const message = `${name} has been refreshed by ${sourceLabel} and synchronized to the other Codex homes.`;
95
116
  await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
96
117
  return true;
97
118
  }
119
+ async withActivity(operation) {
120
+ this.activeOperations += 1;
121
+ try {
122
+ return await operation();
123
+ }
124
+ finally {
125
+ this.activeOperations -= 1;
126
+ }
127
+ }
98
128
  async ensureCanonicalDefaultCandidate() {
99
129
  const names = await listAuthCandidateNames(this.canonicalDir);
100
130
  if (names.length > 0)
@@ -197,3 +227,33 @@ async function exists(filePath) {
197
227
  function formatError(error) {
198
228
  return error instanceof Error ? error.message : String(error);
199
229
  }
230
+ async function readMirrorStatus(statusPath) {
231
+ if (!statusPath)
232
+ return null;
233
+ try {
234
+ const parsed = JSON.parse(await fs.readFile(statusPath, 'utf8'));
235
+ if (typeof parsed.candidateName !== 'string'
236
+ || typeof parsed.sourceRuntimeId !== 'string'
237
+ || typeof parsed.sourceLabel !== 'string'
238
+ || typeof parsed.syncedAt !== 'string') {
239
+ return null;
240
+ }
241
+ return {
242
+ candidateName: parsed.candidateName,
243
+ sourceRuntimeId: parsed.sourceRuntimeId,
244
+ sourceLabel: parsed.sourceLabel,
245
+ syncedAt: parsed.syncedAt,
246
+ };
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ }
252
+ async function writeMirrorStatus(statusPath, status) {
253
+ if (!statusPath)
254
+ return;
255
+ await fs.mkdir(path.dirname(statusPath), { recursive: true, mode: 0o700 });
256
+ const temporary = `${statusPath}.${process.pid}.${Date.now()}.tmp`;
257
+ await fs.writeFile(temporary, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
258
+ await fs.rename(temporary, statusPath);
259
+ }
@@ -58,6 +58,7 @@ interface CodexAppServerRuntimeStatus {
58
58
  interface StopOptions {
59
59
  terminateServer?: boolean;
60
60
  }
61
+ export declare function buildCodexAppServerArgs(port: number, configOverrides?: readonly string[]): string[];
61
62
  export declare class CodexAppClient extends EventEmitter {
62
63
  private readonly codexCliBin;
63
64
  private readonly launchCommand;
@@ -66,6 +67,7 @@ export declare class CodexAppClient extends EventEmitter {
66
67
  private readonly serverLogPath;
67
68
  private readonly logger;
68
69
  private readonly childEnv;
70
+ private readonly appServerConfigOverrides;
69
71
  private child;
70
72
  private socket;
71
73
  private requestId;
@@ -76,7 +78,7 @@ export declare class CodexAppClient extends EventEmitter {
76
78
  private port;
77
79
  private connected;
78
80
  private userAgent;
79
- constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null);
81
+ constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null, appServerConfigOverrides?: readonly string[]);
80
82
  isConnected(): boolean;
81
83
  getUserAgent(): string | null;
82
84
  getServerStatus(): CodexAppServerRuntimeStatus;
@@ -6,6 +6,14 @@ import { spawn } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { buildThreadDeepLink, openUrl } from './deeplink.js';
8
8
  const CLIENT_VERSION = readPackageVersion();
9
+ export function buildCodexAppServerArgs(port, configOverrides = []) {
10
+ const args = ['app-server'];
11
+ for (const override of configOverrides) {
12
+ args.push('-c', override);
13
+ }
14
+ args.push('--listen', `ws://127.0.0.1:${port}`);
15
+ return args;
16
+ }
9
17
  export class CodexAppClient extends EventEmitter {
10
18
  codexCliBin;
11
19
  launchCommand;
@@ -14,6 +22,7 @@ export class CodexAppClient extends EventEmitter {
14
22
  serverLogPath;
15
23
  logger;
16
24
  childEnv;
25
+ appServerConfigOverrides;
17
26
  child = null;
18
27
  socket = null;
19
28
  requestId = 0;
@@ -24,7 +33,7 @@ export class CodexAppClient extends EventEmitter {
24
33
  port = null;
25
34
  connected = false;
26
35
  userAgent = null;
27
- constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger, childEnv = null) {
36
+ constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger, childEnv = null, appServerConfigOverrides = []) {
28
37
  super();
29
38
  this.codexCliBin = codexCliBin;
30
39
  this.launchCommand = launchCommand;
@@ -33,6 +42,7 @@ export class CodexAppClient extends EventEmitter {
33
42
  this.serverLogPath = serverLogPath;
34
43
  this.logger = logger;
35
44
  this.childEnv = childEnv;
45
+ this.appServerConfigOverrides = appServerConfigOverrides;
36
46
  }
37
47
  isConnected() {
38
48
  return this.connected;
@@ -474,10 +484,11 @@ export class CodexAppClient extends EventEmitter {
474
484
  launcher.unref();
475
485
  }
476
486
  this.port = await reservePort();
487
+ const serverArgs = buildCodexAppServerArgs(this.port, this.appServerConfigOverrides);
477
488
  const [stdoutFd, stderrFd] = this.openServerLogFiles();
478
489
  let child;
479
490
  try {
480
- child = spawn(this.codexCliBin, ['app-server', '--listen', `ws://127.0.0.1:${this.port}`], {
491
+ child = spawn(this.codexCliBin, serverArgs, {
481
492
  detached: true,
482
493
  stdio: ['ignore', stdoutFd, stderrFd],
483
494
  env: this.childEnv ? { ...process.env, ...this.childEnv } : process.env,
@@ -496,7 +507,7 @@ export class CodexAppClient extends EventEmitter {
496
507
  this.writeServerState({
497
508
  pid: child.pid,
498
509
  port: this.port,
499
- command: `${this.codexCliBin} app-server --listen ws://127.0.0.1:${this.port}`,
510
+ command: [this.codexCliBin, ...serverArgs].join(' '),
500
511
  logPath: this.serverLogPath,
501
512
  bridgePid: process.pid,
502
513
  startedAt: new Date().toISOString(),
@@ -5,11 +5,18 @@ import type { RuntimeStatus } from '../types.js';
5
5
  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
- import type { SelfUpdateRuntime } from '../update.js';
8
+ import type { SelfUpdateRuntime, SelfUpdateStatus } from '../update.js';
9
9
  export interface CoreCoordinator {
10
10
  canSelfUpdate?: () => boolean;
11
11
  authCandidateUpdated?: (runtimeId: string, candidateName: string) => Promise<void>;
12
12
  statusUpdated?: (status: RuntimeStatus) => void;
13
+ getServiceStatus?: () => Promise<{
14
+ bots: NonNullable<RuntimeStatus['bots']>;
15
+ weixinRuntime?: RuntimeStatus['weixinRuntime'];
16
+ authMirror?: RuntimeStatus['authMirror'];
17
+ lastUpdate?: SelfUpdateStatus | null;
18
+ }>;
19
+ selfUpdateCompleted?: (status: SelfUpdateStatus) => void;
13
20
  }
14
21
  export declare class BridgeSessionCore {
15
22
  private readonly config;
@@ -19,6 +26,7 @@ export declare class BridgeSessionCore {
19
26
  private readonly app;
20
27
  private readonly selfUpdater;
21
28
  private readonly coordinator;
29
+ private readonly ownsTelegramRuntime;
22
30
  private activeTurns;
23
31
  private activeTurnsByTurnId;
24
32
  private observedThreadWatchers;
@@ -57,7 +65,7 @@ export declare class BridgeSessionCore {
57
65
  /** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
58
66
  private threadListPresentationState;
59
67
  private readonly messaging;
60
- constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter, selfUpdater?: SelfUpdateRuntime | null, coordinator?: CoreCoordinator | null);
68
+ constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter, selfUpdater?: SelfUpdateRuntime | null, coordinator?: CoreCoordinator | null, ownsTelegramRuntime?: boolean);
61
69
  /** Wire Telegram inbound events. Call before {@link startCodexApp}. */
62
70
  registerTelegramInboundHandlers(): void;
63
71
  /**
@@ -146,9 +154,11 @@ export declare class BridgeSessionCore {
146
154
  private stageAttachments;
147
155
  private registerActiveTurn;
148
156
  private createActiveTurnState;
157
+ getCurrentAuthLabel(): Promise<string | null>;
149
158
  isIdleForServiceUpdate(): boolean;
150
159
  private hasLocalBlockingActivity;
151
160
  private authRuntimeId;
161
+ private authDisplayBotLabel;
152
162
  private ownsScope;
153
163
  private setActiveTurn;
154
164
  private getActiveTurn;
@@ -94,6 +94,7 @@ export class BridgeSessionCore {
94
94
  app;
95
95
  selfUpdater;
96
96
  coordinator;
97
+ ownsTelegramRuntime;
97
98
  activeTurns = new Map();
98
99
  activeTurnsByTurnId = new Map();
99
100
  observedThreadWatchers = new Map();
@@ -132,7 +133,7 @@ export class BridgeSessionCore {
132
133
  /** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
133
134
  threadListPresentationState = new Map();
134
135
  messaging;
135
- constructor(config, store, logger, bot, app, outbound, selfUpdater = null, coordinator = null) {
136
+ constructor(config, store, logger, bot, app, outbound, selfUpdater = null, coordinator = null, ownsTelegramRuntime = true) {
136
137
  this.config = config;
137
138
  this.store = store;
138
139
  this.logger = logger;
@@ -140,6 +141,7 @@ export class BridgeSessionCore {
140
141
  this.app = app;
141
142
  this.selfUpdater = selfUpdater;
142
143
  this.coordinator = coordinator;
144
+ this.ownsTelegramRuntime = ownsTelegramRuntime;
143
145
  this.messaging = outbound;
144
146
  }
145
147
  /** Wire Telegram inbound events. Call before {@link startCodexApp}. */
@@ -198,13 +200,13 @@ export class BridgeSessionCore {
198
200
  this.logger.warn('codex.local_usage_background_refresh_failed', { error: formatUserError(error) });
199
201
  });
200
202
  this.updateStatus();
203
+ this.scheduleSelfUpdateStatusPoll(0);
201
204
  }
202
205
  /** Begin Telegram Bot API long-polling after handlers and Codex are ready. */
203
206
  async startTelegramPolling() {
204
207
  await this.bot.start();
205
208
  this.botUsername = this.bot.username;
206
209
  this.updateStatus();
207
- this.scheduleSelfUpdateStatusPoll(0);
208
210
  }
209
211
  /** Telegram-only default startup (single channel). */
210
212
  async start() {
@@ -258,7 +260,7 @@ export class BridgeSessionCore {
258
260
  lastError: this.lastError,
259
261
  updatedAt: new Date().toISOString(),
260
262
  channels: {
261
- telegram: true,
263
+ telegram: this.ownsTelegramRuntime,
262
264
  weixin: Boolean(this.config.wxEnabled && this.messaging.hasWeixinTransport),
263
265
  },
264
266
  };
@@ -360,10 +362,13 @@ export class BridgeSessionCore {
360
362
  const binding = this.store.getBinding(scopeId);
361
363
  const settings = this.store.getChatSettings(scopeId);
362
364
  const access = this.resolveEffectiveAccess(scopeId, settings);
363
- const [fastStatus, codexUsageLines, codexLocalUsageLines] = await Promise.all([
365
+ const [fastStatus, codexUsageLines, codexLocalUsageLines, serviceStatus] = await Promise.all([
364
366
  this.resolveFastStatusLabel(locale, settings),
365
367
  this.buildCodexUsageStatusLines(locale),
366
368
  this.buildCodexLocalUsageStatusLines(locale),
369
+ this.config.tgMultiBotMode
370
+ ? (this.coordinator?.getServiceStatus?.() ?? Promise.resolve(null))
371
+ : Promise.resolve(null),
367
372
  ]);
368
373
  const appServer = this.app.getServerStatus();
369
374
  const appServerLabel = appServer.pid && appServer.port
@@ -392,6 +397,43 @@ export class BridgeSessionCore {
392
397
  t(locale, 'status_pending_user_inputs', { value: this.store.countPendingUserInputs() }),
393
398
  t(locale, 'status_active_turns', { value: this.activeTurns.size }),
394
399
  ];
400
+ if (serviceStatus) {
401
+ lines.push('', t(locale, 'status_runtime_overview'));
402
+ for (const runtime of serviceStatus.bots) {
403
+ lines.push(t(locale, 'status_runtime_bot', {
404
+ bot: runtime.username ? `@${runtime.username}` : runtime.id,
405
+ connected: t(locale, runtime.connected ? 'yes' : 'no'),
406
+ auth: runtime.currentAuth ?? t(locale, 'none'),
407
+ turns: runtime.activeTurns,
408
+ }));
409
+ }
410
+ if (serviceStatus.weixinRuntime) {
411
+ lines.push(t(locale, 'status_runtime_weixin', {
412
+ connected: t(locale, serviceStatus.weixinRuntime.connected ? 'yes' : 'no'),
413
+ turns: serviceStatus.weixinRuntime.activeTurns,
414
+ }));
415
+ }
416
+ lines.push(serviceStatus.authMirror
417
+ ? t(locale, 'status_auth_mirror_synced', {
418
+ candidate: serviceStatus.authMirror.candidateName,
419
+ source: serviceStatus.authMirror.sourceLabel,
420
+ time: serviceStatus.authMirror.syncedAt,
421
+ })
422
+ : t(locale, 'status_auth_mirror_none'));
423
+ if (serviceStatus.lastUpdate) {
424
+ lines.push(t(locale, 'status_last_update', {
425
+ from: serviceStatus.lastUpdate.fromVersion,
426
+ to: serviceStatus.lastUpdate.toVersion ?? t(locale, 'unknown'),
427
+ time: serviceStatus.lastUpdate.updatedAt,
428
+ }));
429
+ if (serviceStatus.lastUpdate.codexUpdate) {
430
+ lines.push(t(locale, 'status_last_codex_update', { value: serviceStatus.lastUpdate.codexUpdate }));
431
+ }
432
+ }
433
+ else {
434
+ lines.push(t(locale, 'status_last_update_none'));
435
+ }
436
+ }
395
437
  lines.push(...codexUsageLines);
396
438
  lines.push(...codexLocalUsageLines);
397
439
  await this.sendMessage(scopeId, lines.join('\n'));
@@ -2551,6 +2593,9 @@ export class BridgeSessionCore {
2551
2593
  resolver,
2552
2594
  };
2553
2595
  }
2596
+ async getCurrentAuthLabel() {
2597
+ return (await this.listCodexAuthState()).currentLabel;
2598
+ }
2554
2599
  isIdleForServiceUpdate() {
2555
2600
  return this.activeTurns.size === 0
2556
2601
  && this.pendingApprovalMessages.size === 0
@@ -2565,10 +2610,18 @@ export class BridgeSessionCore {
2565
2610
  authRuntimeId() {
2566
2611
  return this.config.tgScopeBotId ?? 'default';
2567
2612
  }
2613
+ authDisplayBotLabel() {
2614
+ if (!this.config.tgScopeBotId)
2615
+ return null;
2616
+ return this.botUsername ? `@${this.botUsername}` : this.config.tgScopeBotId;
2617
+ }
2568
2618
  ownsScope(scopeId) {
2569
2619
  if (scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)) {
2570
2620
  return this.messaging.hasWeixinTransport;
2571
2621
  }
2622
+ if (!this.ownsTelegramRuntime) {
2623
+ return false;
2624
+ }
2572
2625
  if (!this.config.tgScopeBotId) {
2573
2626
  return parseTelegramTargetFromBridgeScope(scopeId).botId === null;
2574
2627
  }
@@ -3774,6 +3827,7 @@ export class BridgeSessionCore {
3774
3827
  this.scheduleSelfUpdateStatusPoll();
3775
3828
  return;
3776
3829
  }
3830
+ this.coordinator?.selfUpdateCompleted?.(status);
3777
3831
  await this.sendMessage(status.scopeId, this.formatSelfUpdateResult(status));
3778
3832
  await this.selfUpdater?.clearStatus();
3779
3833
  }
@@ -3820,7 +3874,7 @@ export class BridgeSessionCore {
3820
3874
  createdAt: Date.now(),
3821
3875
  };
3822
3876
  this.pendingAuthChoiceLists.set(record.localId, record);
3823
- const messageId = await this.sendMessage(scopeId, renderAuthListMessage(locale, state, parseWeixinBridgeScope(scopeId) !== null), authChoiceKeyboard(locale, record));
3877
+ const messageId = await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null), authChoiceKeyboard(locale, record));
3824
3878
  record.messageId = messageId;
3825
3879
  }
3826
3880
  async handleAuthUseCommand(scopeId, locale, args) {
@@ -3842,7 +3896,7 @@ export class BridgeSessionCore {
3842
3896
  if (!candidate) {
3843
3897
  await this.sendMessage(scopeId, t(locale, 'auth_choice_expired'));
3844
3898
  const state = await this.listCodexAuthState();
3845
- await this.sendMessage(scopeId, renderAuthListMessage(locale, state, parseWeixinBridgeScope(scopeId) !== null));
3899
+ await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null));
3846
3900
  return;
3847
3901
  }
3848
3902
  const switchLabels = await this.readCodexAuthSwitchLabels(candidate);
@@ -3859,7 +3913,7 @@ export class BridgeSessionCore {
3859
3913
  const candidate = state.candidates[index - 1];
3860
3914
  if (!candidate) {
3861
3915
  await this.sendMessage(scopeId, t(locale, 'auth_choice_expired'));
3862
- await this.sendMessage(scopeId, renderAuthListMessage(locale, state, parseWeixinBridgeScope(scopeId) !== null));
3916
+ await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null));
3863
3917
  return;
3864
3918
  }
3865
3919
  this.store.setCodexAuthCandidateDisabled(candidate.name, disabled, this.authRuntimeId());
@@ -4319,7 +4373,7 @@ export class BridgeSessionCore {
4319
4373
  record.candidates = state.candidates;
4320
4374
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, disabled ? 'auth_candidate_disabled_short' : 'auth_candidate_enabled_short'));
4321
4375
  if (record.messageId !== null) {
4322
- await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, parseWeixinBridgeScope(event.scopeId) !== null), authChoiceKeyboard(locale, record));
4376
+ await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null), authChoiceKeyboard(locale, record));
4323
4377
  }
4324
4378
  }
4325
4379
  async handleAuthSwitchCallback(event, localId, index, locale) {
@@ -7304,12 +7358,14 @@ async function switchCodexAuth(targetPath, explicitAuthDir = null) {
7304
7358
  toLabel: await authPathDisplayLabel(candidate.path),
7305
7359
  };
7306
7360
  }
7307
- function renderAuthListMessage(locale, state, includeWeixinCopyPaste = false) {
7361
+ function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopyPaste = false) {
7308
7362
  const lines = [
7309
7363
  t(locale, 'auth_list_title'),
7310
- t(locale, 'auth_current', { value: state.currentLabel ?? t(locale, 'none') }),
7311
- t(locale, 'auth_dir', { value: state.authDir }),
7312
7364
  ];
7365
+ if (botLabel) {
7366
+ lines.push(t(locale, 'auth_bot', { value: botLabel }));
7367
+ }
7368
+ lines.push(t(locale, 'auth_current', { value: state.currentLabel ?? t(locale, 'none') }), t(locale, 'auth_dir', { value: state.authDir }));
7313
7369
  if (state.candidates.length === 0) {
7314
7370
  lines.push(t(locale, 'auth_no_candidates'));
7315
7371
  if (includeWeixinCopyPaste) {
package/dist/i18n.d.ts CHANGED
@@ -118,18 +118,27 @@ declare const MESSAGES: {
118
118
  readonly status_codex_local_usage_unavailable: "Codex local history: unavailable ({error})";
119
119
  readonly status_codex_credits: "Codex credits: {value}";
120
120
  readonly status_codex_limit_reached: "Codex limit: {value}";
121
+ readonly status_runtime_overview: "Telegram bot runtimes:";
122
+ readonly status_runtime_bot: "- {bot}: connected {connected}, auth {auth}, active turns {turns}";
123
+ readonly status_runtime_weixin: "- Weixin default runtime: connected {connected}, active turns {turns}";
124
+ readonly status_auth_mirror_none: "Last auth mirror: none recorded";
125
+ readonly status_auth_mirror_synced: "Last auth mirror: {candidate} from {source} at {time}";
126
+ readonly status_last_update_none: "Last service update: none recorded";
127
+ readonly status_last_update: "Last service update: {from} -> {to} at {time}";
128
+ readonly status_last_codex_update: "Last Codex update: {value}";
121
129
  readonly update_started: "FoxClaw update started. I will report here after installation, checks, and service restart complete.";
122
130
  readonly update_succeeded: "FoxClaw updated and restarted: {from} -> {to}.";
123
131
  readonly update_failed: "FoxClaw update failed: {error}\nRun foxclaw update in a terminal for details.";
124
132
  readonly update_unavailable: "Self-update is unavailable in this runtime. Run foxclaw update in a terminal.";
125
133
  readonly update_already_running: "A FoxClaw update is already running. I will report here when it finishes.";
126
- readonly update_blocked_active: "Cannot update FoxClaw while a turn, approval, or question is active. Wait or use /interrupt first.";
134
+ readonly update_blocked_active: "Cannot update FoxClaw while a runtime activity or auth mirror write is active. Wait or use /interrupt first.";
127
135
  readonly auth_reload_restarting: "Restarting Codex app-server to reload auth...";
128
136
  readonly auth_reload_done: "Codex app-server restarted. Current auth has been reloaded.";
129
137
  readonly auth_reload_blocked_active: "Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.";
130
138
  readonly usage_auth: "Usage: /auth [list|use <n>|enable <n>|disable <n>|reload|add <name>]";
131
139
  readonly usage_auth_add: "Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.";
132
140
  readonly auth_list_title: "Codex auth files:";
141
+ readonly auth_bot: "Bot runtime: {value}";
133
142
  readonly auth_current: "Current auth: {value}";
134
143
  readonly auth_dir: "Auth dir: {value}";
135
144
  readonly auth_candidate_count: "Candidates: {value}";
@@ -682,18 +691,27 @@ declare const MESSAGES: {
682
691
  readonly status_codex_local_usage_unavailable: "Codex 本地历史:无法获取({error})";
683
692
  readonly status_codex_credits: "Codex 额度:{value}";
684
693
  readonly status_codex_limit_reached: "Codex 限制:{value}";
694
+ readonly status_runtime_overview: "Telegram bot 运行时:";
695
+ readonly status_runtime_bot: "- {bot}:连接 {connected},auth {auth},进行中回复 {turns}";
696
+ readonly status_runtime_weixin: "- 微信默认运行时:连接 {connected},进行中回复 {turns}";
697
+ readonly status_auth_mirror_none: "最近 auth 镜像:暂无记录";
698
+ readonly status_auth_mirror_synced: "最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步";
699
+ readonly status_last_update_none: "最近服务升级:暂无记录";
700
+ readonly status_last_update: "最近服务升级:{from} -> {to}({time})";
701
+ readonly status_last_codex_update: "最近 Codex 升级:{value}";
685
702
  readonly update_started: "已开始升级 FoxClaw。安装、自检和服务重启完成后,我会在这里回报结果。";
686
703
  readonly update_succeeded: "FoxClaw 已升级并重启:{from} -> {to}。";
687
704
  readonly update_failed: "FoxClaw 升级失败:{error}\n请在终端运行 foxclaw update 查看详情。";
688
705
  readonly update_unavailable: "当前运行方式不支持自升级,请在终端运行 foxclaw update。";
689
706
  readonly update_already_running: "FoxClaw 升级已经在进行中,结束后我会在这里回报结果。";
690
- readonly update_blocked_active: "当前有回复、审批或问题在进行中,不能升级 FoxClaw。请先等待,或使用 /interrupt。";
707
+ readonly update_blocked_active: "当前有 runtime 操作或 auth 镜像写入在进行中,不能升级 FoxClaw。请先等待,或使用 /interrupt。";
691
708
  readonly auth_reload_restarting: "正在重启 Codex app-server 以重新读取 auth...";
692
709
  readonly auth_reload_done: "Codex app-server 已重启,当前 auth 已重新读取。";
693
710
  readonly auth_reload_blocked_active: "当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。";
694
711
  readonly usage_auth: "用法:/auth [list|use <编号>|enable <编号>|disable <编号>|reload|add <名称>]";
695
712
  readonly usage_auth_add: "用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。";
696
713
  readonly auth_list_title: "Codex auth 文件:";
714
+ readonly auth_bot: "Bot runtime:{value}";
697
715
  readonly auth_current: "当前 auth:{value}";
698
716
  readonly auth_dir: "Auth 目录:{value}";
699
717
  readonly auth_candidate_count: "候选数量:{value}";
package/dist/i18n.js CHANGED
@@ -116,18 +116,27 @@ const MESSAGES = {
116
116
  status_codex_local_usage_unavailable: 'Codex local history: unavailable ({error})',
117
117
  status_codex_credits: 'Codex credits: {value}',
118
118
  status_codex_limit_reached: 'Codex limit: {value}',
119
+ status_runtime_overview: 'Telegram bot runtimes:',
120
+ status_runtime_bot: '- {bot}: connected {connected}, auth {auth}, active turns {turns}',
121
+ status_runtime_weixin: '- Weixin default runtime: connected {connected}, active turns {turns}',
122
+ status_auth_mirror_none: 'Last auth mirror: none recorded',
123
+ status_auth_mirror_synced: 'Last auth mirror: {candidate} from {source} at {time}',
124
+ status_last_update_none: 'Last service update: none recorded',
125
+ status_last_update: 'Last service update: {from} -> {to} at {time}',
126
+ status_last_codex_update: 'Last Codex update: {value}',
119
127
  update_started: 'FoxClaw update started. I will report here after installation, checks, and service restart complete.',
120
128
  update_succeeded: 'FoxClaw updated and restarted: {from} -> {to}.',
121
129
  update_failed: 'FoxClaw update failed: {error}\nRun foxclaw update in a terminal for details.',
122
130
  update_unavailable: 'Self-update is unavailable in this runtime. Run foxclaw update in a terminal.',
123
131
  update_already_running: 'A FoxClaw update is already running. I will report here when it finishes.',
124
- update_blocked_active: 'Cannot update FoxClaw while a turn, approval, or question is active. Wait or use /interrupt first.',
132
+ update_blocked_active: 'Cannot update FoxClaw while a runtime activity or auth mirror write is active. Wait or use /interrupt first.',
125
133
  auth_reload_restarting: 'Restarting Codex app-server to reload auth...',
126
134
  auth_reload_done: 'Codex app-server restarted. Current auth has been reloaded.',
127
135
  auth_reload_blocked_active: 'Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.',
128
136
  usage_auth: 'Usage: /auth [list|use <n>|enable <n>|disable <n>|reload|add <name>]',
129
137
  usage_auth_add: 'Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.',
130
138
  auth_list_title: 'Codex auth files:',
139
+ auth_bot: 'Bot runtime: {value}',
131
140
  auth_current: 'Current auth: {value}',
132
141
  auth_dir: 'Auth dir: {value}',
133
142
  auth_candidate_count: 'Candidates: {value}',
@@ -680,18 +689,27 @@ const MESSAGES = {
680
689
  status_codex_local_usage_unavailable: 'Codex 本地历史:无法获取({error})',
681
690
  status_codex_credits: 'Codex 额度:{value}',
682
691
  status_codex_limit_reached: 'Codex 限制:{value}',
692
+ status_runtime_overview: 'Telegram bot 运行时:',
693
+ status_runtime_bot: '- {bot}:连接 {connected},auth {auth},进行中回复 {turns}',
694
+ status_runtime_weixin: '- 微信默认运行时:连接 {connected},进行中回复 {turns}',
695
+ status_auth_mirror_none: '最近 auth 镜像:暂无记录',
696
+ status_auth_mirror_synced: '最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步',
697
+ status_last_update_none: '最近服务升级:暂无记录',
698
+ status_last_update: '最近服务升级:{from} -> {to}({time})',
699
+ status_last_codex_update: '最近 Codex 升级:{value}',
683
700
  update_started: '已开始升级 FoxClaw。安装、自检和服务重启完成后,我会在这里回报结果。',
684
701
  update_succeeded: 'FoxClaw 已升级并重启:{from} -> {to}。',
685
702
  update_failed: 'FoxClaw 升级失败:{error}\n请在终端运行 foxclaw update 查看详情。',
686
703
  update_unavailable: '当前运行方式不支持自升级,请在终端运行 foxclaw update。',
687
704
  update_already_running: 'FoxClaw 升级已经在进行中,结束后我会在这里回报结果。',
688
- update_blocked_active: '当前有回复、审批或问题在进行中,不能升级 FoxClaw。请先等待,或使用 /interrupt。',
705
+ update_blocked_active: '当前有 runtime 操作或 auth 镜像写入在进行中,不能升级 FoxClaw。请先等待,或使用 /interrupt。',
689
706
  auth_reload_restarting: '正在重启 Codex app-server 以重新读取 auth...',
690
707
  auth_reload_done: 'Codex app-server 已重启,当前 auth 已重新读取。',
691
708
  auth_reload_blocked_active: '当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。',
692
709
  usage_auth: '用法:/auth [list|use <编号>|enable <编号>|disable <编号>|reload|add <名称>]',
693
710
  usage_auth_add: '用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。',
694
711
  auth_list_title: 'Codex auth 文件:',
712
+ auth_bot: 'Bot runtime:{value}',
695
713
  auth_current: '当前 auth:{value}',
696
714
  auth_dir: 'Auth 目录:{value}',
697
715
  auth_candidate_count: '候选数量:{value}',
package/dist/main.js CHANGED
@@ -10,7 +10,7 @@ import { APP_HOME, DEFAULT_CODEX_TELEGRAM_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PA
10
10
  import { acquireProcessLock, LockHeldError } from './lock.js';
11
11
  import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
12
12
  import { refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns } from './systemd.js';
13
- import { createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate } from './update.js';
13
+ import { createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate, readSelfUpdateStatus, writeSelfUpdateStatus, } from './update.js';
14
14
  const rawCommand = process.argv[2];
15
15
  const command = rawCommand || 'serve';
16
16
  loadEnv();
@@ -162,6 +162,7 @@ async function runServeCli() {
162
162
  const processLock = acquireProcessLock(config.lockPath);
163
163
  let store = null;
164
164
  let weixinAdapter = null;
165
+ let activeWeixinCore = null;
165
166
  let activeTelegramAdapters = [];
166
167
  let managedApps = [];
167
168
  let activeAuthMirror = null;
@@ -184,15 +185,17 @@ async function runServeCli() {
184
185
  tgScopeBotId: id,
185
186
  codexAuthDir: home,
186
187
  codexHome: home,
188
+ codexAppAutolaunch: false,
187
189
  codexAppServerStatePath: path.join(APP_HOME, 'runtime', `codex-app-server-${id}.json`),
188
190
  codexAppServerLogPath: path.join(APP_HOME, 'logs', `codex-app-server-${id}.log`),
189
191
  };
190
- const app = new CodexAppClient(runtimeConfig.codexCliBin, runtimeConfig.codexAppLaunchCmd, runtimeConfig.codexAppAutolaunch, runtimeConfig.codexAppServerStatePath, runtimeConfig.codexAppServerLogPath, logger, { CODEX_HOME: home });
192
+ const app = new CodexAppClient(runtimeConfig.codexCliBin, runtimeConfig.codexAppLaunchCmd, runtimeConfig.codexAppAutolaunch, runtimeConfig.codexAppServerStatePath, runtimeConfig.codexAppServerLogPath, logger, { CODEX_HOME: home }, ['cli_auth_credentials_store="file"']);
191
193
  seeds.push({ id, home, config: runtimeConfig, bot, app });
192
194
  }
193
195
  const canonicalAuthDir = config.codexAuthDir ?? config.codexHome ?? path.join(os.homedir(), '.codex');
194
196
  const mirror = new AuthCandidateMirror(canonicalAuthDir, seeds.map((runtime) => ({
195
197
  id: runtime.id,
198
+ label: runtime.bot.username ? `@${runtime.bot.username}` : runtime.id,
196
199
  authDir: runtime.home,
197
200
  notify: async (message) => {
198
201
  const chatId = store.getTelegramPrivateChatId(runtime.id);
@@ -200,7 +203,7 @@ async function runServeCli() {
200
203
  await runtime.bot.sendMessage(chatId, message);
201
204
  }
202
205
  },
203
- })), logger);
206
+ })), logger, path.join(APP_HOME, 'runtime', 'auth-mirror.json'));
204
207
  await mirror.initialize();
205
208
  mirror.start();
206
209
  activeAuthMirror = mirror;
@@ -213,21 +216,29 @@ async function runServeCli() {
213
216
  logPath: path.join(APP_HOME, 'logs', 'update.log'),
214
217
  codexCliBin: config.codexCliBin,
215
218
  });
219
+ const lastSelfUpdatePath = path.join(APP_HOME, 'runtime', 'last-self-update.json');
220
+ let lastSelfUpdate = readSelfUpdateStatus(lastSelfUpdatePath);
216
221
  const runtimes = [];
217
222
  const writeAggregateStatus = (running = true) => {
218
223
  const statuses = runtimes.map((runtime) => runtime.core.getRuntimeStatus());
224
+ const weixinStatus = activeWeixinCore?.getRuntimeStatus() ?? null;
219
225
  const first = statuses[0] ?? null;
220
226
  writeRuntimeStatus(config.statusPath, {
221
227
  running,
222
- connected: running && statuses.every((status) => status.connected),
228
+ connected: running
229
+ && statuses.every((status) => status.connected)
230
+ && (!weixinStatus || weixinStatus.connected),
223
231
  userAgent: first?.userAgent ?? null,
224
232
  ...(first?.codexAppServer ? { codexAppServer: first.codexAppServer } : {}),
225
233
  botUsername: first?.botUsername ?? null,
226
234
  currentBindings: store.countBindings(),
227
235
  pendingApprovals: store.countPendingApprovals(),
228
236
  pendingUserInputs: store.countPendingUserInputs(),
229
- activeTurns: statuses.reduce((sum, status) => sum + status.activeTurns, 0),
230
- lastError: statuses.find((status) => status.lastError)?.lastError ?? null,
237
+ activeTurns: statuses.reduce((sum, status) => sum + status.activeTurns, 0)
238
+ + (weixinStatus?.activeTurns ?? 0),
239
+ lastError: statuses.find((status) => status.lastError)?.lastError
240
+ ?? weixinStatus?.lastError
241
+ ?? null,
231
242
  updatedAt: new Date().toISOString(),
232
243
  channels: { telegram: running, weixin: running && config.wxEnabled },
233
244
  bots: runtimes.map((runtime, index) => ({
@@ -237,24 +248,63 @@ async function runServeCli() {
237
248
  activeTurns: running ? (statuses[index]?.activeTurns ?? 0) : 0,
238
249
  ...(statuses[index]?.codexAppServer ? { codexAppServer: statuses[index].codexAppServer } : {}),
239
250
  })),
251
+ ...(weixinStatus ? {
252
+ weixinRuntime: {
253
+ connected: running && weixinStatus.connected,
254
+ activeTurns: running ? weixinStatus.activeTurns : 0,
255
+ ...(weixinStatus.codexAppServer ? { codexAppServer: weixinStatus.codexAppServer } : {}),
256
+ },
257
+ } : {}),
258
+ authMirror: mirror.getStatus(),
259
+ lastUpdate: lastSelfUpdate,
240
260
  });
241
261
  };
242
262
  const coordinator = {
243
- canSelfUpdate: () => runtimes.every((runtime) => runtime.core.isIdleForServiceUpdate()),
263
+ canSelfUpdate: () => runtimes.every((runtime) => runtime.core.isIdleForServiceUpdate())
264
+ && (!activeWeixinCore || activeWeixinCore.isIdleForServiceUpdate())
265
+ && mirror.isIdle(),
244
266
  authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
245
267
  statusUpdated: () => writeAggregateStatus(),
268
+ getServiceStatus: async () => ({
269
+ bots: await Promise.all(runtimes.map(async (runtime) => {
270
+ const status = runtime.core.getRuntimeStatus();
271
+ return {
272
+ id: runtime.id,
273
+ username: status.botUsername ?? runtime.bot.username,
274
+ connected: status.connected,
275
+ activeTurns: status.activeTurns,
276
+ currentAuth: await runtime.core.getCurrentAuthLabel().catch(() => null),
277
+ ...(status.codexAppServer ? { codexAppServer: status.codexAppServer } : {}),
278
+ };
279
+ })),
280
+ ...(activeWeixinCore ? {
281
+ weixinRuntime: {
282
+ connected: activeWeixinCore.getRuntimeStatus().connected,
283
+ activeTurns: activeWeixinCore.getRuntimeStatus().activeTurns,
284
+ codexAppServer: activeWeixinCore.getRuntimeStatus().codexAppServer,
285
+ },
286
+ } : {}),
287
+ authMirror: mirror.getStatus(),
288
+ lastUpdate: lastSelfUpdate,
289
+ }),
290
+ selfUpdateCompleted: (status) => {
291
+ lastSelfUpdate = status;
292
+ writeSelfUpdateStatus(lastSelfUpdatePath, status);
293
+ writeAggregateStatus();
294
+ },
246
295
  };
247
- for (const [index, seed] of seeds.entries()) {
296
+ for (const seed of seeds) {
248
297
  const telegramMessaging = new TelegramMessagingPort(seed.bot);
249
- const weixinMessaging = index === 0 && config.wxEnabled
250
- ? new WeixinMessagingPort(store, (id) => loadWeixinAccount(config.weixinAccountsDir, id))
251
- : null;
252
- const outbound = new BridgeMessagingRouter(telegramMessaging, weixinMessaging);
298
+ const outbound = new BridgeMessagingRouter(telegramMessaging, null);
253
299
  const core = new BridgeSessionCore(seed.config, store, logger, seed.bot, seed.app, outbound, selfUpdater, coordinator);
254
300
  runtimes.push({ ...seed, core, telegram: new TelegramChannelAdapter(core) });
255
301
  }
256
302
  if (config.wxEnabled) {
257
- weixinAdapter = new WeixinChannelAdapter(runtimes[0].core, store, runtimes[0].config, logger);
303
+ const weixinApp = new CodexAppClient(config.codexCliBin, config.codexAppLaunchCmd, config.codexAppAutolaunch, config.codexAppServerStatePath, config.codexAppServerLogPath, logger);
304
+ const outbound = new BridgeMessagingRouter(new TelegramMessagingPort(seeds[0].bot), new WeixinMessagingPort(store, (id) => loadWeixinAccount(config.weixinAccountsDir, id)));
305
+ activeWeixinCore = new BridgeSessionCore(config, store, logger, seeds[0].bot, weixinApp, outbound, selfUpdater, coordinator, false);
306
+ managedApps.push(weixinApp);
307
+ weixinAdapter = new WeixinChannelAdapter(activeWeixinCore, store, config, logger);
258
308
  }
259
309
  activeTelegramAdapters = runtimes.map((runtime) => runtime.telegram);
260
310
  process.on('unhandledRejection', (error) => {
@@ -267,6 +317,7 @@ async function runServeCli() {
267
317
  await runtime.telegram.start();
268
318
  }
269
319
  if (weixinAdapter) {
320
+ await activeWeixinCore.startCodexApp();
270
321
  await weixinAdapter.start();
271
322
  }
272
323
  writeAggregateStatus();
@@ -275,10 +326,11 @@ async function runServeCli() {
275
326
  logger.info('bridge.shutting_down', { signal });
276
327
  mirror.stop();
277
328
  await weixinAdapter?.stop();
329
+ await activeWeixinCore?.stop();
278
330
  await Promise.all(runtimes.map((runtime) => runtime.telegram.stop()));
279
331
  writeAggregateStatus(false);
280
- await Promise.all(runtimes.map((runtime) => runtime.app.stop({ terminateServer: true }).catch((error) => {
281
- logger.warn('codex.app-server.stop_failed', { runtimeId: runtime.id, error: serializeError(error) });
332
+ await Promise.all(managedApps.map((app) => app.stop({ terminateServer: true }).catch((error) => {
333
+ logger.warn('codex.app-server.stop_failed', { error: serializeError(error) });
282
334
  })));
283
335
  store?.close();
284
336
  processLock.release();
@@ -352,6 +404,7 @@ async function runServeCli() {
352
404
  catch (error) {
353
405
  activeAuthMirror?.stop();
354
406
  await weixinAdapter?.stop().catch(() => { });
407
+ await activeWeixinCore?.stop().catch(() => { });
355
408
  await Promise.allSettled(activeTelegramAdapters.map((adapter) => adapter.stop()));
356
409
  await Promise.allSettled(managedApps.map((app) => app.stop({ terminateServer: true })));
357
410
  store?.close();
package/dist/types.d.ts CHANGED
@@ -345,6 +345,25 @@ export interface RuntimeStatus {
345
345
  username: string | null;
346
346
  connected: boolean;
347
347
  activeTurns: number;
348
+ currentAuth?: string | null;
348
349
  codexAppServer?: RuntimeStatus['codexAppServer'];
349
350
  }>;
351
+ weixinRuntime?: {
352
+ connected: boolean;
353
+ activeTurns: number;
354
+ codexAppServer?: RuntimeStatus['codexAppServer'];
355
+ };
356
+ authMirror?: {
357
+ candidateName: string;
358
+ sourceRuntimeId: string;
359
+ sourceLabel: string;
360
+ syncedAt: string;
361
+ } | null;
362
+ lastUpdate?: {
363
+ state: string;
364
+ fromVersion: string;
365
+ toVersion: string | null;
366
+ codexUpdate?: string | null;
367
+ updatedAt: string;
368
+ } | null;
350
369
  }
@@ -17,7 +17,7 @@ Optional later:
17
17
  - `TG_ALLOWED_CHAT_ID`
18
18
  - `TG_ALLOWED_TOPIC_ID`
19
19
 
20
- Use private Telegram chat first. Configure groups/topics only after private chat works.
20
+ Use private Telegram chat first. Configure groups/topics only after private chat works. Multiple tokens mean independent Codex runtimes inside one service; validate each bot privately.
21
21
 
22
22
  ## Copy-Paste Prompt
23
23
 
@@ -45,11 +45,12 @@ Tasks:
45
45
  5. Run foxclaw init, then write ~/.foxclaw/.env. Never print or commit the bot token.
46
46
  6. Run foxclaw doctor.
47
47
  7. Start FoxClaw with foxclaw start.
48
- 8. Ask me to send /help and /status to the Telegram bot.
48
+ 8. Ask me to send /help, /status, and /auth privately to each configured Telegram bot; confirm /auth names that bot runtime.
49
49
  9. Verify the final state:
50
50
  - foxclaw.service is active/enabled on Linux
51
51
  - foxclaw status works
52
52
  10. Report the commands used, the final status, and the log command I should use if something stops working. Redact TG_BOT_TOKENS and never print the full token or full .env content.
53
+ 11. If multiple bots are enabled, confirm foxclaw status lists independent app-servers; group-chat tests must mention or reply to the intended bot.
53
54
  ```
54
55
 
55
56
  ## Safety Notes
@@ -123,7 +123,7 @@ npm install -g @foxden-app/foxclaw
123
123
  foxclaw init
124
124
  ```
125
125
 
126
- This creates the config file at `~/.foxclaw/.env` and prompts for the Telegram bot token, your numeric Telegram user id, and the default workspace.
126
+ This creates the config file at `~/.foxclaw/.env` and prompts for one or more comma-separated Telegram bot tokens, your numeric Telegram user id, and the default workspace. New installs use `TG_BOT_TOKENS`; legacy `TG_BOT_TOKEN` is only for existing single-bot configurations.
127
127
 
128
128
  If you prefer pnpm:
129
129
 
@@ -154,6 +154,14 @@ DEFAULT_SANDBOX_MODE=workspace-write
154
154
 
155
155
  Keep `TG_ALLOWED_CHAT_ID=` and `TG_ALLOWED_TOPIC_ID=` empty for the first install. Do not delete those lines; leaving them empty means private-chat mode.
156
156
 
157
+ Start with one bot. To add three independent Codex lanes later, put the tokens on the same line:
158
+
159
+ ```dotenv
160
+ TG_BOT_TOKENS=123456789:token_a,234567890:token_b,345678901:token_c
161
+ ```
162
+
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
+
157
165
  `DEFAULT_CWD` must be a real folder. Examples:
158
166
 
159
167
  ```dotenv
@@ -280,7 +288,7 @@ Update FoxClaw later:
280
288
  foxclaw update
281
289
  ```
282
290
 
283
- You can also send `/update` in an authorized Telegram chat. When every bot is idle with no approval or question active, it attempts to update an npm/pnpm-managed Codex CLI, upgrades FoxClaw, checks, restarts the service, and reports the result after restart.
291
+ You can also send `/update` in an authorized Telegram chat. When every Telegram bot runtime, an enabled Weixin default runtime, and auth mirror writes are idle, it attempts to update an npm/pnpm-managed Codex CLI, upgrades FoxClaw, checks, restarts the service, and reports the result through the bot that started it.
284
292
 
285
293
  ## Next Step
286
294
 
@@ -182,6 +182,18 @@ Bridge logs are stored here:
182
182
  tail -f ~/.foxclaw/logs/service.log
183
183
  ```
184
184
 
185
+ ## Checking Multi-Bot Mode
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.
188
+
189
+ Each isolated app-server log is stored at:
190
+
191
+ ```bash
192
+ tail -f ~/.foxclaw/logs/codex-app-server-bot<id>.log
193
+ ```
194
+
195
+ When multiple bots share a group, unaddressed messages intentionally do not trigger them; mention `@botname`, reply to the intended bot, or send `/status@botname`. When enabled, Weixin stays on the default Codex runtime and does not appear inside an isolated Telegram bot's thread list.
196
+
185
197
  ## ChatGPT Backend 403 Or Unable To Load Site
186
198
 
187
199
  If Telegram shows `ChatGPT backend 403 Forbidden`, or the app-server log contains `Unable to load site`, `cf-ray`, or `chatgpt.com/backend-api`, the auth file is not necessarily broken. The service process is usually reaching ChatGPT with the wrong network/proxy/IP.
@@ -230,10 +230,10 @@ Later commands are sorted by recent usage. Plain text, photos, and files continu
230
230
 
231
231
  ### 3.2 `/status`, `/account`, `/quota`, `/update`
232
232
 
233
- - `/status`: FoxClaw, app-server, current thread binding, model, access, and Codex usage summary. Local session, token, and visible-reply-throughput metrics use a background-generated historical snapshot instead of scanning large logs during the request; throughput is computed end-to-end for completed turns, excluding reasoning tokens while including waiting and tool execution time.
233
+ - `/status`: FoxClaw, app-server, current thread binding, model, access, and Codex usage summary. In multi-bot mode it also lists every bot's connection, current auth, active turns, and the most recent auth mirror and service/Codex update outcomes. Local session, token, and visible-reply-throughput metrics use a background-generated historical snapshot instead of scanning large logs during the request; throughput is computed end-to-end for completed turns, excluding reasoning tokens while including waiting and tool execution time.
234
234
  - `/account`: current Codex account.
235
235
  - `/quota`: Codex usage and quota window.
236
- - `/update`: upgrade FoxClaw, attempt to update an npm/pnpm-managed Codex CLI, run checks, and restart the service; it refuses while any bot runtime has a turn, approval, or question active, then reports the result after restart.
236
+ - `/update`: upgrade FoxClaw, attempt to update an npm/pnpm-managed Codex CLI, run checks, and restart the service; it refuses while any Telegram bot runtime, an enabled Weixin default runtime, or an auth mirror write is busy, then reports the result through the initiating bot after restart.
237
237
 
238
238
  ### 3.3 `/config`, `/requirements`, `/provider`
239
239
 
@@ -335,7 +335,7 @@ 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; validated login/refresh credentials are safely mirrored between bot homes.
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.
339
339
 
340
340
  ### 6.1 File Format
341
341
 
@@ -387,7 +387,7 @@ If the login is cancelled or fails, FoxClaw tries to restore the previous auth t
387
387
 
388
388
  ### 6.3 The `/auth` Panel
389
389
 
390
- `/auth` lists candidate accounts, the current account, and the auth directory. It also provides buttons for switching, disabling, login, and reload. 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.
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.
391
391
 
392
392
  Approximation:
393
393
 
@@ -17,7 +17,7 @@
17
17
  - `TG_ALLOWED_CHAT_ID`
18
18
  - `TG_ALLOWED_TOPIC_ID`
19
19
 
20
- 第一次请先用 Telegram 私聊跑通。群组和话题模式等私聊稳定后再开。
20
+ 第一次请先用 Telegram 私聊跑通。群组和话题模式等私聊稳定后再开。多个 token 表示同一服务内多个独立 Codex runtime;每个 bot 都必须单独完成私聊验收。
21
21
 
22
22
  ## 复制给 agent 的安装提示词
23
23
 
@@ -44,11 +44,12 @@ DEFAULT_CWD=<把绝对工作目录粘贴在这里>
44
44
  5. 运行 foxclaw init,然后写入 ~/.foxclaw/.env。不要打印或提交 bot token。
45
45
  6. 运行 foxclaw doctor。
46
46
  7. 用 foxclaw start 启动 FoxClaw。
47
- 8. 让我在 Telegram bot 里发送 /help 和 /status
47
+ 8. 让我在每个已配置的 Telegram bot 私聊里发送 /help、/status 和 /auth;确认 /auth 显示对应 bot runtime
48
48
  9. 验证最终状态:
49
49
  - Linux 上 foxclaw.service 处于 active/enabled
50
50
  - foxclaw status 可以正常输出
51
51
  10. 汇报执行过的命令、最终状态和后续看日志的命令。请隐藏 TG_BOT_TOKENS,不要打印完整 token 或完整 .env。
52
+ 11. 如果启用了多个 bot,确认 foxclaw status 列出独立 app-server;群聊测试必须点名或回复目标 bot。
52
53
  ```
53
54
 
54
55
  ## 安全注意事项
@@ -14,11 +14,13 @@
14
14
  2. 让 Codex 使用 `skills/foxclaw`。
15
15
  3. 如果是远程机器,提供 SSH 目标。
16
16
  4. 让 Codex 执行安装、写配置、跑 `foxclaw doctor`。
17
- 5. 启动服务后,在 Telegram bot 里发送 `/help` 和 `/status` 验证。
17
+ 5. 启动服务后,在每个配置的 Telegram bot 私聊里发送 `/help`、`/status` 和 `/auth` 验证。
18
18
 
19
19
  ## 注意事项
20
20
 
21
21
  - 不要让 agent 把完整 bot token 打印到日志或提交到仓库。
22
22
  - 第一次请先用私聊模式跑通。
23
+ - 多个 token 会在同一服务中建立多个独立 Codex home、session 与 auth 选择;群组中必须点名或回复目标 bot。
24
+ - 同时启用微信时,微信仍使用默认 Codex runtime,不共享隔离 Telegram bot 的线程。
23
25
  - 不要把整个 home 目录或根目录作为首次 `DEFAULT_CWD`。
24
26
  - 只有在 `doctor` 通过、服务已启动、Telegram 首条消息验证通过后,才算安装完成。
@@ -121,7 +121,7 @@ npm install -g @foxden-app/foxclaw
121
121
  foxclaw init
122
122
  ```
123
123
 
124
- 这会创建默认配置文件 `~/.foxclaw/.env`,并提示你填写 Telegram bot tokenTelegram 数字用户 ID 和默认工作目录。
124
+ 这会创建默认配置文件 `~/.foxclaw/.env`,并提示你填写一个或多个 Telegram bot token(多个用英文逗号分隔)、Telegram 数字用户 ID 和默认工作目录。新安装使用 `TG_BOT_TOKENS`;旧的 `TG_BOT_TOKEN` 仅用于兼容已有单 bot 配置。
125
125
 
126
126
  如果你用 pnpm:
127
127
 
@@ -152,6 +152,14 @@ DEFAULT_SANDBOX_MODE=workspace-write
152
152
 
153
153
  `TG_ALLOWED_CHAT_ID=` 和 `TG_ALLOWED_TOPIC_ID=` 第一次保持为空,不要删掉这两行;留空表示私聊模式。
154
154
 
155
+ 只有一个 bot 时先按上面的配置跑通即可。需要三条互不打断的 Codex 会话时,把多个 token 写在同一行:
156
+
157
+ ```dotenv
158
+ TG_BOT_TOKENS=123456789:token_a,234567890:token_b,345678901:token_c
159
+ ```
160
+
161
+ FoxClaw 仍只安装一个服务,但每个 bot 会有独立 app-server、会话目录和当前 auth。服务启动后请分别私聊每个 bot 发送 `/help` 与 `/status`。
162
+
155
163
  `DEFAULT_CWD` 必须是真实存在的目录,例如:
156
164
 
157
165
  ```dotenv
@@ -278,7 +286,7 @@ foxclaw uninstall-systemd
278
286
  foxclaw update
279
287
  ```
280
288
 
281
- 也可以在已授权的 Telegram 私聊里发送 `/update`。它会在所有 bot 都没有运行中回复、审批或待确认问题时,尝试升级 npm/pnpm 安装的 Codex CLI,完成 FoxClaw 升级、自检和服务重启,并在重启后回报结果。
289
+ 也可以在已授权的 Telegram 私聊里发送 `/update`。它会在所有 Telegram bot、已启用的微信默认 runtime 和 auth 镜像写入都空闲时,尝试升级 npm/pnpm 安装的 Codex CLI,完成 FoxClaw 升级、自检和服务重启,并在发起命令的 bot 中回报结果。
282
290
 
283
291
  如果 `~/.foxclaw/.env` 已经存在,`foxclaw init` 会先询问是否更新 Telegram 和工作目录相关字段,其它配置保持不变。
284
292
 
@@ -183,6 +183,18 @@ Bridge 日志默认在:
183
183
  tail -f ~/.foxclaw/logs/service.log
184
184
  ```
185
185
 
186
+ ## 多 bot 模式核查
187
+
188
+ 配置 `TG_BOT_TOKENS` 后,`foxclaw status` 的 `bots` 列表应为每个 token 显示一个 bot id、连接状态和独立 app-server。私聊任一 bot 发送 `/status` 会显示全部 runtime 摘要;发送 `/auth` 应显示当前 `@botname` 和该 bot 的 auth 目录。
189
+
190
+ 每个隔离 app-server 的日志路径为:
191
+
192
+ ```bash
193
+ tail -f ~/.foxclaw/logs/codex-app-server-bot<id>.log
194
+ ```
195
+
196
+ 群组里配置了多个 bot 时,普通未点名消息不会触发它们;使用 `@botname`、回复目标 bot,或 `/status@botname`。微信启用后仍使用默认 Codex runtime,不会出现在某个 Telegram bot 的隔离线程中。
197
+
186
198
  ## ChatGPT 后端 403 或 Unable to load site
187
199
 
188
200
  如果 Telegram 里看到 `ChatGPT backend 403 Forbidden`,或者 app-server 日志里出现 `Unable to load site`、`cf-ray`、`chatgpt.com/backend-api`,通常不是 `auth.json` 文件坏了,而是服务进程访问 ChatGPT 后端时没有走正确网络。
@@ -230,10 +230,10 @@ TG_ALLOWED_TOPIC_ID=42
230
230
 
231
231
  ### 3.2 `/status`、`/account`、`/quota`、`/update`
232
232
 
233
- - `/status`:查看 FoxClaw、app-server、当前绑定线程、模型、权限和 Codex 用量摘要。本地 session/Token/可见答复吞吐使用后台生成的历史快照,避免状态查询现场扫描大量日志;答复吞吐按完成轮次端到端耗时计算,排除推理 token,但包含等待与工具执行时间。
233
+ - `/status`:查看 FoxClaw、app-server、当前绑定线程、模型、权限和 Codex 用量摘要。多 bot 模式还会列出所有 bot 的连接、当前 auth、活动 turn,以及最近一次 auth 镜像和服务/Codex 升级结果。本地 session/Token/可见答复吞吐使用后台生成的历史快照,避免状态查询现场扫描大量日志;答复吞吐按完成轮次端到端耗时计算,排除推理 token,但包含等待与工具执行时间。
234
234
  - `/account`:查看当前 Codex 登录账号。
235
235
  - `/quota`:查看 Codex 用量和额度窗口。
236
- - `/update`:升级 FoxClaw,并尝试升级 npm/pnpm 安装的 Codex CLI,然后自检和重启服务;任意 bot runtime 有运行中回复、审批或待确认问题时都会拒绝执行,重启后会回报结果。
236
+ - `/update`:升级 FoxClaw,并尝试升级 npm/pnpm 安装的 Codex CLI,然后自检和重启服务;任意 Telegram bot、启用的微信默认 runtime 或 auth 镜像写入不空闲时都会拒绝执行,重启后通过发起命令的 bot 回报结果。
237
237
 
238
238
  ### 3.3 `/config`、`/requirements`、`/provider`
239
239
 
@@ -335,7 +335,7 @@ 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 和独立当前候选,因此可以并行运行、单独切号;已验证的登录/刷新凭据会安全镜像到其他 bot home。
338
+ 这是 FoxClaw 的特色功能。Codex 的登录状态通常保存在 `~/.codex/auth.json`。FoxClaw 把多个账号保存成候选文件,并通过切换 `auth.json` 指向哪个候选来换号。启用 `TG_BOT_TOKENS` 多 bot 模式后,每个 bot 使用独立 Codex home、独立 app-server 和独立当前候选,因此可以并行运行、单独切号;隔离 Telegram runtime 会强制使用文件凭据存储。已验证的登录/刷新凭据会安全镜像到其他 bot home,但不会共享 session
339
339
 
340
340
  ### 6.1 文件格式
341
341
 
@@ -387,7 +387,7 @@ cp -L ~/.codex/auth.json ~/.codex/auth.json_personal
387
387
 
388
388
  ### 6.3 `/auth` 面板
389
389
 
390
- `/auth` 会列出候选账号、当前账号和 auth 目录,并提供按钮切换、禁用、登录和重载。每个候选名前的 `5h|7d` 数字表示上次记录到的两个额度窗口剩余百分比;当前 auth 会在打开面板时刷新,其他候选不会为了查询额度被自动切换。
390
+ `/auth` 会列出候选账号、当前账号和 auth 目录,并提供按钮切换、禁用、登录和重载。多 bot 模式中,面板顶部还会显示当前正在管理的 `@botname`,因为该 bot 内的私聊、群聊和话题共享同一个当前 auth。每个候选名前的 `5h|7d` 数字表示上次记录到的两个额度窗口剩余百分比;当前 auth 会在打开面板时刷新,其他候选不会为了查询额度被自动切换。
391
391
 
392
392
  示意:
393
393
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.3.19",
3
+ "version": "0.4.0",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -65,6 +65,8 @@ Telegram behavior:
65
65
  - private chat with `TG_ALLOWED_USER_ID` remains available even when `TG_ALLOWED_CHAT_ID` or `TG_ALLOWED_TOPIC_ID` is set
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
+ - isolated Telegram runtimes use independent `CODEX_HOME` directories and file-backed credentials; they do not share Codex sessions
69
+ - if Weixin is enabled alongside multiple Telegram bots, it remains on the default Codex runtime instead of borrowing a Telegram bot runtime
68
70
  - in a group with multiple configured bots, address a bot by mention, reply, or suffixed command such as `/status@botname`
69
71
 
70
72
  If `TG_ALLOWED_CHAT_ID` or `TG_ALLOWED_TOPIC_ID` is missing, read [references/telegram-setup.md](./references/telegram-setup.md) and explicitly guide the user through collecting it.
@@ -142,7 +144,8 @@ After either bootstrap path:
142
144
  - `privacy mode` is disabled
143
145
  - the bot is an admin in the group
144
146
  - the configured `TG_ALLOWED_CHAT_ID` and `TG_ALLOWED_TOPIC_ID` match the target group/topic
145
- 5. If group or topic mode is enabled, also verify that private chat still responds for the configured `TG_ALLOWED_USER_ID`.
147
+ 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.
146
149
 
147
150
  ## First Telegram Message Check
148
151
 
@@ -192,7 +195,7 @@ Use this checklist when the user asks for standard closing actions, release wrap
192
195
  - For macOS launchd, use the launchd install/start path from this skill and verify with `node dist/main.js status`.
193
196
  - Verify the running service reports the expected FoxClaw version in `status`.
194
197
  - If `doctor` fails only because `DEFAULT_CWD` is missing, report that separately; do not treat it as evidence that the service update failed.
195
- - `/update` now attempts to update globally npm/pnpm-managed Codex CLI installations and refuses to restart while any configured bot runtime is busy.
198
+ - `/update` now attempts to update globally npm/pnpm-managed Codex CLI installations and refuses to restart while any configured Telegram runtime, enabled Weixin default runtime, or auth mirror write is busy.
196
199
  7. Publish to npm when requested:
197
200
  - Prefer GitHub Actions trusted publishing via `.github/workflows/publish.yml`: bump and commit the package version, push `main`, then push a matching `v<version>` tag. The tag version must match `package.json`.
198
201
  - Treat `workflow_dispatch` only as a retry path from an existing matching release tag; do not manually run publishing from `main`.
@@ -34,6 +34,7 @@ Behavior:
34
34
  - `TG_ALLOWED_CHAT_ID` + `TG_ALLOWED_TOPIC_ID`: that topic becomes the default scope
35
35
  - Private chat with `TG_ALLOWED_USER_ID` still works in every mode above
36
36
  - Multiple tokens start independent Codex runtimes in one FoxClaw service; group messages must mention or reply to the intended bot
37
+ - Each bot keeps its own Codex home, sessions, and current auth selection; verify it privately with `/status` and `/auth`
37
38
 
38
39
  If multiple bots share one group, keep the same `TG_ALLOWED_CHAT_ID`; use explicit `@botname` mentions or replies. Separate topics are still useful for organization.
39
40
 
@@ -71,8 +72,9 @@ If the bridge is still polling, it may consume the update before you inspect it.
71
72
 
72
73
  After the bridge is started:
73
74
 
74
- 1. Private chat mode:
75
- - send `/help` to the bot in private chat
75
+ 1. Private chat mode:
76
+ - send `/help` to the bot in private chat
77
+ - for each configured bot, send `/status` and `/auth` and confirm its isolated runtime is shown
76
78
  - send one plain-language message such as `show /status`
77
79
  2. Group mode:
78
80
  - send `/help` in the configured group or default topic