@foxden-app/foxclaw 0.3.18 → 0.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -93,6 +93,7 @@ export class BridgeSessionCore {
93
93
  bot;
94
94
  app;
95
95
  selfUpdater;
96
+ coordinator;
96
97
  activeTurns = new Map();
97
98
  activeTurnsByTurnId = new Map();
98
99
  observedThreadWatchers = new Map();
@@ -131,13 +132,14 @@ export class BridgeSessionCore {
131
132
  /** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
132
133
  threadListPresentationState = new Map();
133
134
  messaging;
134
- constructor(config, store, logger, bot, app, outbound, selfUpdater = null) {
135
+ constructor(config, store, logger, bot, app, outbound, selfUpdater = null, coordinator = null) {
135
136
  this.config = config;
136
137
  this.store = store;
137
138
  this.logger = logger;
138
139
  this.bot = bot;
139
140
  this.app = app;
140
141
  this.selfUpdater = selfUpdater;
142
+ this.coordinator = coordinator;
141
143
  this.messaging = outbound;
142
144
  }
143
145
  /** Wire Telegram inbound events. Call before {@link startCodexApp}. */
@@ -323,6 +325,7 @@ export class BridgeSessionCore {
323
325
  allowedChatId: this.config.tgAllowedChatId,
324
326
  allowedTopicId: this.config.tgAllowedTopicId,
325
327
  topicId: event.topicId,
328
+ requireExplicitGroupAddressing: this.config.tgRequireExplicitGroupAddressing,
326
329
  }),
327
330
  replyToBot: event.replyToBot,
328
331
  });
@@ -1563,6 +1566,16 @@ export class BridgeSessionCore {
1563
1566
  return;
1564
1567
  }
1565
1568
  const lines = [t(locale, 'auth_add_done', { value: pendingAuthAdd.name })];
1569
+ try {
1570
+ await this.coordinator?.authCandidateUpdated?.(this.authRuntimeId(), pendingAuthAdd.name);
1571
+ }
1572
+ catch (error) {
1573
+ this.logger.warn('codex.auth_candidate_sync_failed', {
1574
+ candidate: pendingAuthAdd.name,
1575
+ runtimeId: this.authRuntimeId(),
1576
+ error: toErrorMeta(error),
1577
+ });
1578
+ }
1566
1579
  lines.push(...await this.buildCodexUsageStatusLines(locale));
1567
1580
  await this.sendMessage(scopeId, lines.join('\n'));
1568
1581
  return;
@@ -1572,7 +1585,7 @@ export class BridgeSessionCore {
1572
1585
  : t(locale, 'login_failed', { error: params?.error ?? t(locale, 'unknown') }));
1573
1586
  }
1574
1587
  async restorePendingAuthAdd(record) {
1575
- const state = await listCodexAuthState();
1588
+ const state = await this.listCodexAuthState();
1576
1589
  await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, record.previousTargetPath);
1577
1590
  }
1578
1591
  async restoreAuthAfterAddFailure(authDir, authPath, previousTargetPath) {
@@ -1849,6 +1862,9 @@ export class BridgeSessionCore {
1849
1862
  }
1850
1863
  async restorePendingUserInputs() {
1851
1864
  for (const stored of this.store.listPendingUserInputs()) {
1865
+ if (!this.ownsScope(stored.chatId)) {
1866
+ continue;
1867
+ }
1852
1868
  const record = parseStoredPendingUserInput(stored);
1853
1869
  if (!record) {
1854
1870
  this.store.markPendingUserInputResolved(stored.localId);
@@ -2535,6 +2551,34 @@ export class BridgeSessionCore {
2535
2551
  resolver,
2536
2552
  };
2537
2553
  }
2554
+ isIdleForServiceUpdate() {
2555
+ return this.activeTurns.size === 0
2556
+ && this.pendingApprovalMessages.size === 0
2557
+ && this.pendingUserInputs.size === 0
2558
+ && this.pendingMcpElicitations.size === 0
2559
+ && this.pendingLoginsByScope.size === 0
2560
+ && !this.authRotationInProgress;
2561
+ }
2562
+ hasLocalBlockingActivity() {
2563
+ return !this.isIdleForServiceUpdate();
2564
+ }
2565
+ authRuntimeId() {
2566
+ return this.config.tgScopeBotId ?? 'default';
2567
+ }
2568
+ ownsScope(scopeId) {
2569
+ if (scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)) {
2570
+ return this.messaging.hasWeixinTransport;
2571
+ }
2572
+ if (!this.config.tgScopeBotId) {
2573
+ return parseTelegramTargetFromBridgeScope(scopeId).botId === null;
2574
+ }
2575
+ try {
2576
+ return parseTelegramTargetFromBridgeScope(scopeId).botId === this.config.tgScopeBotId;
2577
+ }
2578
+ catch {
2579
+ return false;
2580
+ }
2581
+ }
2538
2582
  setActiveTurn(scopeId, turnId, active) {
2539
2583
  const key = activeTurnKey(scopeId, turnId);
2540
2584
  this.activeTurns.set(key, active);
@@ -2785,7 +2829,7 @@ export class BridgeSessionCore {
2785
2829
  }
2786
2830
  }
2787
2831
  for (const scopeId of this.store.findAllChatIdsByThreadId(threadId)) {
2788
- if (!this.messaging.canSendToScope(scopeId)) {
2832
+ if (!this.ownsScope(scopeId) || !this.messaging.canSendToScope(scopeId)) {
2789
2833
  continue;
2790
2834
  }
2791
2835
  scopes.add(scopeId);
@@ -2818,7 +2862,12 @@ export class BridgeSessionCore {
2818
2862
  return next;
2819
2863
  }
2820
2864
  updateStatus() {
2821
- writeRuntimeStatus(this.config.statusPath, this.getRuntimeStatus());
2865
+ const status = this.getRuntimeStatus();
2866
+ if (this.coordinator?.statusUpdated) {
2867
+ this.coordinator.statusUpdated(status);
2868
+ return;
2869
+ }
2870
+ writeRuntimeStatus(this.config.statusPath, status);
2822
2871
  }
2823
2872
  async sendMessage(scopeId, text, inlineKeyboard) {
2824
2873
  return this.messaging.sendPlain(scopeId, text, inlineKeyboard);
@@ -3653,7 +3702,7 @@ export class BridgeSessionCore {
3653
3702
  ].join('\n'));
3654
3703
  }
3655
3704
  async handleAuthReloadCommand(scopeId, locale) {
3656
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
3705
+ if (this.hasLocalBlockingActivity()) {
3657
3706
  await this.sendMessage(scopeId, t(locale, 'auth_reload_blocked_active'));
3658
3707
  return;
3659
3708
  }
@@ -3670,7 +3719,7 @@ export class BridgeSessionCore {
3670
3719
  await this.sendMessage(scopeId, t(locale, 'update_unavailable'));
3671
3720
  return;
3672
3721
  }
3673
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
3722
+ if (!this.isIdleForServiceUpdate() || this.store.countPendingApprovals() > 0 || this.store.countPendingUserInputs() > 0 || (this.coordinator?.canSelfUpdate && !this.coordinator.canSelfUpdate())) {
3674
3723
  await this.sendMessage(scopeId, t(locale, 'update_blocked_active'));
3675
3724
  return;
3676
3725
  }
@@ -3721,17 +3770,23 @@ export class BridgeSessionCore {
3721
3770
  this.scheduleSelfUpdateStatusPoll();
3722
3771
  return;
3723
3772
  }
3773
+ if (!this.ownsScope(status.scopeId)) {
3774
+ this.scheduleSelfUpdateStatusPoll();
3775
+ return;
3776
+ }
3724
3777
  await this.sendMessage(status.scopeId, this.formatSelfUpdateResult(status));
3725
3778
  await this.selfUpdater?.clearStatus();
3726
3779
  }
3727
3780
  formatSelfUpdateResult(status) {
3728
3781
  if (status.state === 'succeeded') {
3729
- return t(status.locale, 'update_succeeded', {
3782
+ const result = t(status.locale, 'update_succeeded', {
3730
3783
  from: status.fromVersion,
3731
3784
  to: status.toVersion ?? t(status.locale, 'unknown'),
3732
3785
  });
3786
+ return status.codexUpdate ? `${result}\n${status.codexUpdate}` : result;
3733
3787
  }
3734
- return t(status.locale, 'update_failed', { error: status.error ?? t(status.locale, 'unknown') });
3788
+ const result = t(status.locale, 'update_failed', { error: status.error ?? t(status.locale, 'unknown') });
3789
+ return status.codexUpdate ? `${result}\n${status.codexUpdate}` : result;
3735
3790
  }
3736
3791
  async handleAuthCommand(scopeId, locale, args) {
3737
3792
  const action = args[0]?.toLowerCase() ?? 'list';
@@ -3769,7 +3824,7 @@ export class BridgeSessionCore {
3769
3824
  record.messageId = messageId;
3770
3825
  }
3771
3826
  async handleAuthUseCommand(scopeId, locale, args) {
3772
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
3827
+ if (this.hasLocalBlockingActivity()) {
3773
3828
  await this.sendMessage(scopeId, t(locale, 'auth_reload_blocked_active'));
3774
3829
  return;
3775
3830
  }
@@ -3807,7 +3862,7 @@ export class BridgeSessionCore {
3807
3862
  await this.sendMessage(scopeId, renderAuthListMessage(locale, state, parseWeixinBridgeScope(scopeId) !== null));
3808
3863
  return;
3809
3864
  }
3810
- this.store.setCodexAuthCandidateDisabled(candidate.name, disabled);
3865
+ this.store.setCodexAuthCandidateDisabled(candidate.name, disabled, this.authRuntimeId());
3811
3866
  await this.sendMessage(scopeId, t(locale, disabled ? 'auth_candidate_disabled' : 'auth_candidate_enabled', {
3812
3867
  value: candidate.name,
3813
3868
  }));
@@ -3825,7 +3880,7 @@ export class BridgeSessionCore {
3825
3880
  return latest;
3826
3881
  }
3827
3882
  async handleAuthAddCommand(scopeId, locale, args) {
3828
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
3883
+ if (this.hasLocalBlockingActivity()) {
3829
3884
  await this.sendMessage(scopeId, t(locale, 'auth_reload_blocked_active'));
3830
3885
  return;
3831
3886
  }
@@ -4259,7 +4314,7 @@ export class BridgeSessionCore {
4259
4314
  return;
4260
4315
  }
4261
4316
  const disabled = !candidate.disabled;
4262
- this.store.setCodexAuthCandidateDisabled(candidate.name, disabled);
4317
+ this.store.setCodexAuthCandidateDisabled(candidate.name, disabled, this.authRuntimeId());
4263
4318
  const state = await this.listCodexAuthState();
4264
4319
  record.candidates = state.candidates;
4265
4320
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, disabled ? 'auth_candidate_disabled_short' : 'auth_candidate_enabled_short'));
@@ -4277,7 +4332,7 @@ export class BridgeSessionCore {
4277
4332
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_mismatch'));
4278
4333
  return;
4279
4334
  }
4280
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
4335
+ if (this.hasLocalBlockingActivity()) {
4281
4336
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_reload_blocked_active'));
4282
4337
  return;
4283
4338
  }
@@ -4299,7 +4354,7 @@ export class BridgeSessionCore {
4299
4354
  if (!this.pendingAuthRotation || this.authRotationInProgress) {
4300
4355
  return false;
4301
4356
  }
4302
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
4357
+ if (this.hasLocalBlockingActivity()) {
4303
4358
  return false;
4304
4359
  }
4305
4360
  const rotation = this.pendingAuthRotation;
@@ -4398,13 +4453,19 @@ export class BridgeSessionCore {
4398
4453
  return candidate ? { candidate, fromLabel: state.currentLabel, toLabel: await authPathDisplayLabel(candidate.path) } : null;
4399
4454
  }
4400
4455
  async listCodexAuthState() {
4401
- const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames());
4456
+ const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.resolveAuthDir());
4402
4457
  const snapshots = await this.readCodexAuthQuotaSnapshots();
4403
4458
  state.candidates.forEach((candidate) => {
4404
4459
  candidate.quota = snapshots[candidate.name] ?? null;
4405
4460
  });
4406
4461
  return state;
4407
4462
  }
4463
+ resolveAuthDir() {
4464
+ return this.config.codexAuthDir
4465
+ ?? (this.config.tgMultiBotMode ? this.config.codexHome : null)
4466
+ ?? process.env.CODEX_AUTH_DIR
4467
+ ?? path.join(os.homedir(), '.codex');
4468
+ }
4408
4469
  async readCodexAuthSwitchLabels(candidate) {
4409
4470
  const state = await this.listCodexAuthState();
4410
4471
  return {
@@ -4419,7 +4480,7 @@ export class BridgeSessionCore {
4419
4480
  };
4420
4481
  }
4421
4482
  async switchCodexAuthAndRestart(scopeId, locale, candidate, automatic) {
4422
- const result = await switchCodexAuth(candidate.path);
4483
+ const result = await switchCodexAuth(candidate.path, this.resolveAuthDir());
4423
4484
  this.authRotationFailedTargets.delete(candidate.path);
4424
4485
  this.pendingTurnErrors.clear();
4425
4486
  this.attachedThreads.clear();
@@ -4610,14 +4671,14 @@ export class BridgeSessionCore {
4610
4671
  await this.localUsageRefresh;
4611
4672
  }
4612
4673
  async refreshCodexLocalUsageStats() {
4613
- const stats = await readCodexLocalUsageStats();
4674
+ const stats = await readCodexLocalUsageStats(this.config.codexHome ?? undefined);
4614
4675
  const snapshot = { computedAtMs: Date.now(), stats };
4615
4676
  this.localUsageCache = snapshot;
4616
4677
  this.localUsageCacheLoaded = true;
4617
4678
  await writeCodexLocalUsageSnapshot(this.codexLocalUsageSnapshotPath(), snapshot);
4618
4679
  }
4619
4680
  codexLocalUsageSnapshotPath() {
4620
- return path.join(path.dirname(this.config.statusPath), CODEX_LOCAL_USAGE_SNAPSHOT_FILENAME);
4681
+ return path.join(path.dirname(this.config.statusPath), this.runtimeSnapshotFilename(CODEX_LOCAL_USAGE_SNAPSHOT_FILENAME));
4621
4682
  }
4622
4683
  async refreshCurrentCodexAuthQuota(state) {
4623
4684
  const candidate = state.candidates.find(entry => entry.isCurrent);
@@ -4669,7 +4730,13 @@ export class BridgeSessionCore {
4669
4730
  await fs.rename(temporaryPath, snapshotPath);
4670
4731
  }
4671
4732
  codexAuthQuotaSnapshotPath() {
4672
- return path.join(path.dirname(this.config.statusPath), CODEX_AUTH_QUOTA_SNAPSHOT_FILENAME);
4733
+ return path.join(path.dirname(this.config.statusPath), this.runtimeSnapshotFilename(CODEX_AUTH_QUOTA_SNAPSHOT_FILENAME));
4734
+ }
4735
+ runtimeSnapshotFilename(filename) {
4736
+ if (!this.config.tgScopeBotId) {
4737
+ return filename;
4738
+ }
4739
+ return filename.replace(/\.json$/, `-${this.config.tgScopeBotId}.json`);
4673
4740
  }
4674
4741
  async sendThreadContextSummary(scopeId, locale, threadId) {
4675
4742
  try {
@@ -5588,6 +5655,9 @@ export class BridgeSessionCore {
5588
5655
  }
5589
5656
  async cleanupStaleTurnPreviews() {
5590
5657
  for (const preview of this.store.listActiveTurnPreviews()) {
5658
+ if (!this.ownsScope(preview.scopeId)) {
5659
+ continue;
5660
+ }
5591
5661
  if (!this.messaging.canSendToScope(preview.scopeId)) {
5592
5662
  this.store.removeActiveTurnPreview(preview.turnId);
5593
5663
  this.logger.info('telegram.preview_dropped_disabled_channel', {
@@ -7119,11 +7189,11 @@ function parseActiveTurnKey(key) {
7119
7189
  turnId: decodeURIComponent(key.slice(split + 1)),
7120
7190
  };
7121
7191
  }
7122
- function codexAuthDir() {
7123
- return process.env.CODEX_AUTH_DIR || path.join(os.homedir(), '.codex');
7192
+ function codexAuthDir(explicitAuthDir = null) {
7193
+ return explicitAuthDir || process.env.CODEX_AUTH_DIR || path.join(os.homedir(), '.codex');
7124
7194
  }
7125
- async function listCodexAuthState(disabledNames = new Set()) {
7126
- const authDir = codexAuthDir();
7195
+ async function listCodexAuthState(disabledNames = new Set(), explicitAuthDir = null) {
7196
+ const authDir = codexAuthDir(explicitAuthDir);
7127
7197
  const authPath = path.join(authDir, 'auth.json');
7128
7198
  const currentTargetPath = await resolveCurrentAuthTarget(authDir, authPath);
7129
7199
  const candidates = [];
@@ -7222,8 +7292,8 @@ async function pointCodexAuthAtTarget(authDir, authPath, targetPath) {
7222
7292
  throw error;
7223
7293
  }
7224
7294
  }
7225
- async function switchCodexAuth(targetPath) {
7226
- const state = await listCodexAuthState();
7295
+ async function switchCodexAuth(targetPath, explicitAuthDir = null) {
7296
+ const state = await listCodexAuthState(new Set(), explicitAuthDir);
7227
7297
  const candidate = state.candidates.find(entry => entry.path === targetPath);
7228
7298
  if (!candidate) {
7229
7299
  throw new Error(`Auth candidate is no longer available: ${path.basename(targetPath)}`);
@@ -7,12 +7,15 @@ export interface WeixinBridgeScope {
7
7
  accountId: string;
8
8
  fromUserId: string;
9
9
  }
10
+ export interface TelegramBridgeTarget extends TelegramScope {
11
+ botId: string | null;
12
+ }
10
13
  export declare function isBridgeScopedKey(key: string): boolean;
11
14
  /** Wrap legacy Telegram inner scope (`chat::topic`) for storage and routing. */
12
- export declare function toTelegramBridgeScopeId(telegramInnerScopeId: string): string;
15
+ export declare function toTelegramBridgeScopeId(telegramInnerScopeId: string, botId?: string | null): string;
13
16
  /** Strip `telegram:` prefix; returns `null` if not a Telegram bridge scope. */
14
17
  export declare function telegramInnerScopeFromBridge(bridgeScopeId: string): string | null;
15
- export declare function parseTelegramTargetFromBridgeScope(bridgeScopeId: string): TelegramScope;
18
+ export declare function parseTelegramTargetFromBridgeScope(bridgeScopeId: string): TelegramBridgeTarget;
16
19
  /** Parse `weixin:<accountId>:<from_user_id>`; returns `null` if not a Weixin scope. */
17
20
  export declare function parseWeixinBridgeScope(bridgeScopeId: string): WeixinBridgeScope | null;
18
21
  export declare function toWeixinBridgeScopeId(accountId: string, fromUserId: string): string;
@@ -7,8 +7,8 @@ export function isBridgeScopedKey(key) {
7
7
  return key.startsWith(BRIDGE_SCOPE_TELEGRAM_PREFIX) || key.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX);
8
8
  }
9
9
  /** Wrap legacy Telegram inner scope (`chat::topic`) for storage and routing. */
10
- export function toTelegramBridgeScopeId(telegramInnerScopeId) {
11
- return `${BRIDGE_SCOPE_TELEGRAM_PREFIX}${telegramInnerScopeId}`;
10
+ export function toTelegramBridgeScopeId(telegramInnerScopeId, botId = null) {
11
+ return `${BRIDGE_SCOPE_TELEGRAM_PREFIX}${botId ? `${botId}:` : ''}${telegramInnerScopeId}`;
12
12
  }
13
13
  /** Strip `telegram:` prefix; returns `null` if not a Telegram bridge scope. */
14
14
  export function telegramInnerScopeFromBridge(bridgeScopeId) {
@@ -22,7 +22,11 @@ export function parseTelegramTargetFromBridgeScope(bridgeScopeId) {
22
22
  if (inner === null) {
23
23
  throw new Error(`Expected ${BRIDGE_SCOPE_TELEGRAM_PREFIX} scope, got: ${bridgeScopeId}`);
24
24
  }
25
- return parseTelegramScopeId(inner);
25
+ const namespaced = /^(bot\d+):(.*)$/.exec(inner);
26
+ if (!namespaced) {
27
+ return { ...parseTelegramScopeId(inner), botId: null };
28
+ }
29
+ return { ...parseTelegramScopeId(namespaced[2]), botId: namespaced[1] };
26
30
  }
27
31
  /** Parse `weixin:<accountId>:<from_user_id>`; returns `null` if not a Weixin scope. */
28
32
  export function parseWeixinBridgeScope(bridgeScopeId) {
package/dist/main.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import process from 'node:process';
5
6
  import { createInterface } from 'node:readline/promises';
6
7
  import { spawnSync } from 'node:child_process';
7
8
  import { fileURLToPath } from 'node:url';
8
- import { APP_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getLoadedEnvPath, loadConfig, loadEnv, } from './config.js';
9
+ import { APP_HOME, DEFAULT_CODEX_TELEGRAM_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getLoadedEnvPath, loadConfig, loadEnv, } from './config.js';
9
10
  import { acquireProcessLock, LockHeldError } from './lock.js';
10
11
  import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
11
12
  import { refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns } from './systemd.js';
@@ -61,6 +62,9 @@ async function main() {
61
62
  entryPoint,
62
63
  nodePath: process.execPath,
63
64
  version: readPackageVersion(),
65
+ ...(process.env.CODEX_CLI_BIN || resolveCommand('codex')
66
+ ? { codexCliBin: process.env.CODEX_CLI_BIN || resolveCommand('codex') }
67
+ : {}),
64
68
  ...(notificationFile ? { notificationFile } : {}),
65
69
  };
66
70
  const outcome = performSelfUpdate(options);
@@ -137,7 +141,7 @@ Usage:
137
141
  foxclaw --help`);
138
142
  }
139
143
  async function runServeCli() {
140
- const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter },] = await Promise.all([
144
+ const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter }, { AuthCandidateMirror },] = await Promise.all([
141
145
  import('./channels/bridge_messaging_router.js'),
142
146
  import('./channels/telegram/telegram_messaging_port.js'),
143
147
  import('./channels/weixin/weixin_channel_adapter.js'),
@@ -150,6 +154,7 @@ async function runServeCli() {
150
154
  import('./codex_app/client.js'),
151
155
  import('./controller/controller.js'),
152
156
  import('./channels/telegram/telegram_channel_adapter.js'),
157
+ import('./auth/mirror.js'),
153
158
  ]);
154
159
  const config = loadConfig();
155
160
  const logger = new Logger(config.logLevel, config.logPath);
@@ -157,8 +162,132 @@ async function runServeCli() {
157
162
  const processLock = acquireProcessLock(config.lockPath);
158
163
  let store = null;
159
164
  let weixinAdapter = null;
165
+ let activeTelegramAdapters = [];
166
+ let managedApps = [];
167
+ let activeAuthMirror = null;
160
168
  try {
161
169
  store = new BridgeStore(config.storePath);
170
+ if (config.tgMultiBotMode) {
171
+ const seeds = [];
172
+ for (const token of config.tgBotTokens) {
173
+ const bot = new TelegramGateway(token, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger, true);
174
+ const id = await bot.initializeIdentity();
175
+ if (seeds.some((runtime) => runtime.id === id)) {
176
+ throw new Error(`TG_BOT_TOKENS contains duplicate Telegram bot identity: ${id}`);
177
+ }
178
+ const home = path.join(DEFAULT_CODEX_TELEGRAM_HOME, id, 'home');
179
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
180
+ const runtimeConfig = {
181
+ ...config,
182
+ tgBotToken: token,
183
+ tgBotTokens: [token],
184
+ tgScopeBotId: id,
185
+ codexAuthDir: home,
186
+ codexHome: home,
187
+ codexAppServerStatePath: path.join(APP_HOME, 'runtime', `codex-app-server-${id}.json`),
188
+ codexAppServerLogPath: path.join(APP_HOME, 'logs', `codex-app-server-${id}.log`),
189
+ };
190
+ const app = new CodexAppClient(runtimeConfig.codexCliBin, runtimeConfig.codexAppLaunchCmd, runtimeConfig.codexAppAutolaunch, runtimeConfig.codexAppServerStatePath, runtimeConfig.codexAppServerLogPath, logger, { CODEX_HOME: home });
191
+ seeds.push({ id, home, config: runtimeConfig, bot, app });
192
+ }
193
+ const canonicalAuthDir = config.codexAuthDir ?? config.codexHome ?? path.join(os.homedir(), '.codex');
194
+ const mirror = new AuthCandidateMirror(canonicalAuthDir, seeds.map((runtime) => ({
195
+ id: runtime.id,
196
+ authDir: runtime.home,
197
+ notify: async (message) => {
198
+ const chatId = store.getTelegramPrivateChatId(runtime.id);
199
+ if (chatId) {
200
+ await runtime.bot.sendMessage(chatId, message);
201
+ }
202
+ },
203
+ })), logger);
204
+ await mirror.initialize();
205
+ mirror.start();
206
+ activeAuthMirror = mirror;
207
+ managedApps = seeds.map((runtime) => runtime.app);
208
+ const selfUpdater = createSelfUpdateRuntime({
209
+ entryPoint,
210
+ nodePath: process.execPath,
211
+ version: readPackageVersion(),
212
+ statusPath: config.statusPath,
213
+ logPath: path.join(APP_HOME, 'logs', 'update.log'),
214
+ codexCliBin: config.codexCliBin,
215
+ });
216
+ const runtimes = [];
217
+ const writeAggregateStatus = (running = true) => {
218
+ const statuses = runtimes.map((runtime) => runtime.core.getRuntimeStatus());
219
+ const first = statuses[0] ?? null;
220
+ writeRuntimeStatus(config.statusPath, {
221
+ running,
222
+ connected: running && statuses.every((status) => status.connected),
223
+ userAgent: first?.userAgent ?? null,
224
+ ...(first?.codexAppServer ? { codexAppServer: first.codexAppServer } : {}),
225
+ botUsername: first?.botUsername ?? null,
226
+ currentBindings: store.countBindings(),
227
+ pendingApprovals: store.countPendingApprovals(),
228
+ pendingUserInputs: store.countPendingUserInputs(),
229
+ activeTurns: statuses.reduce((sum, status) => sum + status.activeTurns, 0),
230
+ lastError: statuses.find((status) => status.lastError)?.lastError ?? null,
231
+ updatedAt: new Date().toISOString(),
232
+ channels: { telegram: running, weixin: running && config.wxEnabled },
233
+ bots: runtimes.map((runtime, index) => ({
234
+ id: runtime.id,
235
+ username: statuses[index]?.botUsername ?? runtime.bot.username,
236
+ connected: running && Boolean(statuses[index]?.connected),
237
+ activeTurns: running ? (statuses[index]?.activeTurns ?? 0) : 0,
238
+ ...(statuses[index]?.codexAppServer ? { codexAppServer: statuses[index].codexAppServer } : {}),
239
+ })),
240
+ });
241
+ };
242
+ const coordinator = {
243
+ canSelfUpdate: () => runtimes.every((runtime) => runtime.core.isIdleForServiceUpdate()),
244
+ authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
245
+ statusUpdated: () => writeAggregateStatus(),
246
+ };
247
+ for (const [index, seed] of seeds.entries()) {
248
+ 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);
253
+ const core = new BridgeSessionCore(seed.config, store, logger, seed.bot, seed.app, outbound, selfUpdater, coordinator);
254
+ runtimes.push({ ...seed, core, telegram: new TelegramChannelAdapter(core) });
255
+ }
256
+ if (config.wxEnabled) {
257
+ weixinAdapter = new WeixinChannelAdapter(runtimes[0].core, store, runtimes[0].config, logger);
258
+ }
259
+ activeTelegramAdapters = runtimes.map((runtime) => runtime.telegram);
260
+ process.on('unhandledRejection', (error) => {
261
+ logger.error('process.unhandled_rejection', { error: serializeError(error) });
262
+ });
263
+ process.on('uncaughtException', (error) => {
264
+ logger.error('process.uncaught_exception', { error: serializeError(error) });
265
+ });
266
+ for (const runtime of runtimes) {
267
+ await runtime.telegram.start();
268
+ }
269
+ if (weixinAdapter) {
270
+ await weixinAdapter.start();
271
+ }
272
+ writeAggregateStatus();
273
+ logger.info('bridge.started', { bots: runtimes.map((runtime) => runtime.id) });
274
+ const shutdown = async (signal) => {
275
+ logger.info('bridge.shutting_down', { signal });
276
+ mirror.stop();
277
+ await weixinAdapter?.stop();
278
+ await Promise.all(runtimes.map((runtime) => runtime.telegram.stop()));
279
+ 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) });
282
+ })));
283
+ store?.close();
284
+ processLock.release();
285
+ process.exit(0);
286
+ };
287
+ process.on('SIGINT', () => void shutdown('SIGINT'));
288
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
289
+ return;
290
+ }
162
291
  const bot = new TelegramGateway(config.tgBotToken, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger);
163
292
  const app = new CodexAppClient(config.codexCliBin, config.codexAppLaunchCmd, config.codexAppAutolaunch, config.codexAppServerStatePath, config.codexAppServerLogPath, logger);
164
293
  const telegramMessaging = new TelegramMessagingPort(bot);
@@ -172,9 +301,12 @@ async function runServeCli() {
172
301
  version: readPackageVersion(),
173
302
  statusPath: config.statusPath,
174
303
  logPath: path.join(APP_HOME, 'logs', 'update.log'),
304
+ codexCliBin: config.codexCliBin,
175
305
  });
176
306
  const core = new BridgeSessionCore(config, store, logger, bot, app, outbound, selfUpdater);
177
307
  const telegram = new TelegramChannelAdapter(core);
308
+ managedApps = [app];
309
+ activeTelegramAdapters = [telegram];
178
310
  if (config.wxEnabled) {
179
311
  weixinAdapter = new WeixinChannelAdapter(core, store, config, logger);
180
312
  }
@@ -218,7 +350,10 @@ async function runServeCli() {
218
350
  process.on('SIGTERM', () => void shutdown('SIGTERM'));
219
351
  }
220
352
  catch (error) {
353
+ activeAuthMirror?.stop();
221
354
  await weixinAdapter?.stop().catch(() => { });
355
+ await Promise.allSettled(activeTelegramAdapters.map((adapter) => adapter.stop()));
356
+ await Promise.allSettled(managedApps.map((app) => app.stop({ terminateServer: true })));
222
357
  store?.close();
223
358
  processLock.release();
224
359
  throw error;
@@ -264,15 +399,15 @@ async function configureEnvInteractively(envPath, existed) {
264
399
  const skipped = [];
265
400
  const warnings = [];
266
401
  Object.assign(updates, await maybeSaveProxyEnvFromShell(rl, envPath));
267
- const token = sanitizeEnvInput(await rl.question('Telegram bot token (TG_BOT_TOKEN): '));
268
- if (token) {
269
- updates.TG_BOT_TOKEN = token;
270
- if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
271
- warnings.push('TG_BOT_TOKEN does not look like a standard Telegram bot token.');
402
+ const tokens = sanitizeEnvInput(await rl.question('Telegram bot token(s), comma-separated (TG_BOT_TOKENS): '));
403
+ if (tokens) {
404
+ updates.TG_BOT_TOKENS = tokens;
405
+ if (tokens.split(',').map((token) => token.trim()).some((token) => !/^\d+:[A-Za-z0-9_-]+$/.test(token))) {
406
+ warnings.push('One or more TG_BOT_TOKENS values do not look like standard Telegram bot tokens.');
272
407
  }
273
408
  }
274
409
  else {
275
- skipped.push('TG_BOT_TOKEN');
410
+ skipped.push('TG_BOT_TOKENS');
276
411
  }
277
412
  const userId = sanitizeEnvInput(await rl.question('Telegram numeric user ID (TG_ALLOWED_USER_ID): '));
278
413
  if (userId) {
@@ -486,7 +621,7 @@ function runDoctorChecks() {
486
621
  const checks = [
487
622
  ['node >= 24', Number(process.versions.node.split('.')[0]) >= 24],
488
623
  ['codex cli available', hasConfiguredCodexBin(configuredCodexBin) || hasCommand('codex')],
489
- ['telegram bot token configured', Boolean(process.env.TG_BOT_TOKEN)],
624
+ ['telegram bot token(s) configured', Boolean(process.env.TG_BOT_TOKENS?.trim() || process.env.TG_BOT_TOKEN?.trim())],
490
625
  ['telegram allowed user configured', Boolean(process.env.TG_ALLOWED_USER_ID)],
491
626
  ];
492
627
  if (process.env.WX_ENABLED === 'true' || process.env.WX_ENABLED === '1') {
@@ -29,6 +29,8 @@ export declare class BridgeStore {
29
29
  constructor(dbPath: string);
30
30
  getTelegramOffset(botKey: string): number;
31
31
  setTelegramOffset(botKey: string, updateId: number): void;
32
+ rememberTelegramPrivateScope(botId: string, scopeId: string, chatId: string): void;
33
+ getTelegramPrivateChatId(botId: string): string | null;
32
34
  getBinding(chatId: string): ThreadBinding | null;
33
35
  setBinding(chatId: string, threadId: string, cwd: string | null): void;
34
36
  clearBinding(chatId: string): void;
@@ -73,7 +75,7 @@ export declare class BridgeStore {
73
75
  private writeChatSettings;
74
76
  getWeixinContextToken(scopeId: string): string | null;
75
77
  setWeixinContextToken(scopeId: string, contextToken: string): void;
76
- listDisabledCodexAuthCandidateNames(): Set<string>;
77
- setCodexAuthCandidateDisabled(name: string, disabled: boolean): void;
78
+ listDisabledCodexAuthCandidateNames(runtimeId?: string): Set<string>;
79
+ setCodexAuthCandidateDisabled(name: string, disabled: boolean, runtimeId?: string): void;
78
80
  private ensureColumn;
79
81
  }