@foxden-app/foxclaw 0.3.19 → 0.4.1

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,46 @@
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>;
7
+ validate?: (context: AuthMirrorValidationContext) => Promise<AuthMirrorValidationResult | boolean>;
8
+ }
9
+ export interface AuthMirrorStatus {
10
+ candidateName: string;
11
+ sourceRuntimeId: string;
12
+ sourceLabel: string;
13
+ syncedAt: string;
14
+ }
15
+ export interface AuthMirrorValidationContext {
16
+ candidateName: string;
17
+ accountId: string;
18
+ lastRefreshMs: number;
19
+ }
20
+ export interface AuthMirrorValidationResult {
21
+ ok: boolean;
22
+ reason?: string | null;
6
23
  }
7
24
  export declare class AuthCandidateMirror {
8
25
  private readonly canonicalDir;
9
26
  private readonly runtimes;
10
27
  private readonly logger;
28
+ private readonly statusPath;
11
29
  private timer;
12
30
  private readonly lastSyncedRefresh;
13
- constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger);
31
+ private activeOperations;
32
+ private lastStatus;
33
+ constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger, statusPath?: string | null);
14
34
  initialize(): Promise<void>;
15
35
  start(): void;
16
36
  stop(): void;
37
+ isIdle(): boolean;
38
+ getStatus(): AuthMirrorStatus | null;
17
39
  syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
18
40
  private scan;
19
41
  private propagateValidatedCandidate;
42
+ private validateRuntimeCandidate;
43
+ private withActivity;
20
44
  private ensureCanonicalDefaultCandidate;
21
45
  private collectCandidateNames;
22
46
  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);
@@ -83,6 +96,15 @@ export class AuthCandidateMirror {
83
96
  if (record.lastRefreshMs <= previousRefresh) {
84
97
  return false;
85
98
  }
99
+ const validation = await this.validateRuntimeCandidate(runtime, name, record);
100
+ if (!validation.ok) {
101
+ this.logger.warn('auth.mirror.validation_failed', {
102
+ runtimeId: runtime.id,
103
+ name,
104
+ reason: validation.reason ?? 'unknown',
105
+ });
106
+ return false;
107
+ }
86
108
  await atomicWrite(canonicalPath, record.raw);
87
109
  for (const target of this.runtimes) {
88
110
  if (target.id !== runtime.id) {
@@ -90,11 +112,47 @@ export class AuthCandidateMirror {
90
112
  }
91
113
  }
92
114
  this.lastSyncedRefresh.set(name, record.lastRefreshMs);
115
+ const sourceLabel = runtime.label ?? runtime.id;
116
+ this.lastStatus = {
117
+ candidateName: name,
118
+ sourceRuntimeId: runtime.id,
119
+ sourceLabel,
120
+ syncedAt: new Date().toISOString(),
121
+ };
122
+ await writeMirrorStatus(this.statusPath, this.lastStatus);
93
123
  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.`;
124
+ const message = `${name} has been refreshed by ${sourceLabel} and synchronized to the other Codex homes.`;
95
125
  await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
96
126
  return true;
97
127
  }
128
+ async validateRuntimeCandidate(runtime, name, record) {
129
+ if (!runtime.validate) {
130
+ return { ok: true };
131
+ }
132
+ try {
133
+ const result = await runtime.validate({
134
+ candidateName: name,
135
+ accountId: record.accountId,
136
+ lastRefreshMs: record.lastRefreshMs,
137
+ });
138
+ if (typeof result === 'boolean') {
139
+ return { ok: result };
140
+ }
141
+ return { ok: Boolean(result.ok), reason: result.reason ?? null };
142
+ }
143
+ catch (error) {
144
+ return { ok: false, reason: formatError(error) };
145
+ }
146
+ }
147
+ async withActivity(operation) {
148
+ this.activeOperations += 1;
149
+ try {
150
+ return await operation();
151
+ }
152
+ finally {
153
+ this.activeOperations -= 1;
154
+ }
155
+ }
98
156
  async ensureCanonicalDefaultCandidate() {
99
157
  const names = await listAuthCandidateNames(this.canonicalDir);
100
158
  if (names.length > 0)
@@ -197,3 +255,33 @@ async function exists(filePath) {
197
255
  function formatError(error) {
198
256
  return error instanceof Error ? error.message : String(error);
199
257
  }
258
+ async function readMirrorStatus(statusPath) {
259
+ if (!statusPath)
260
+ return null;
261
+ try {
262
+ const parsed = JSON.parse(await fs.readFile(statusPath, 'utf8'));
263
+ if (typeof parsed.candidateName !== 'string'
264
+ || typeof parsed.sourceRuntimeId !== 'string'
265
+ || typeof parsed.sourceLabel !== 'string'
266
+ || typeof parsed.syncedAt !== 'string') {
267
+ return null;
268
+ }
269
+ return {
270
+ candidateName: parsed.candidateName,
271
+ sourceRuntimeId: parsed.sourceRuntimeId,
272
+ sourceLabel: parsed.sourceLabel,
273
+ syncedAt: parsed.syncedAt,
274
+ };
275
+ }
276
+ catch {
277
+ return null;
278
+ }
279
+ }
280
+ async function writeMirrorStatus(statusPath, status) {
281
+ if (!statusPath)
282
+ return;
283
+ await fs.mkdir(path.dirname(statusPath), { recursive: true, mode: 0o700 });
284
+ const temporary = `${statusPath}.${process.pid}.${Date.now()}.tmp`;
285
+ await fs.writeFile(temporary, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
286
+ await fs.rename(temporary, statusPath);
287
+ }
@@ -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) {