@foxden-app/foxclaw 0.10.0 → 0.11.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/dist/main.js CHANGED
@@ -9,7 +9,7 @@ import { spawnSync } from 'node:child_process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { APP_HOME, buildCodexApiProviderOverrides, DEFAULT_CODEX_TELEGRAM_HOME, DEFAULT_ANTIGRAVITY_TELEGRAM_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getLoadedEnvPath, loadConfig, loadEnv, } from './config.js';
11
11
  import { createAuthRefreshNotificationAggregator, } from './auth/notifications.js';
12
- import { getAntigravityTelegramCommands } from './i18n.js';
12
+ import { getAntigravityTelegramCommands, getTelegramCommands } from './i18n.js';
13
13
  import { installBundledCodexSkills } from './codex_skills.js';
14
14
  import { acquireProcessLock, LockHeldError } from './lock.js';
15
15
  import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
@@ -191,6 +191,9 @@ async function main() {
191
191
  ...(process.env.CODEX_CLI_BIN || resolveCommand('codex')
192
192
  ? { codexCliBin: process.env.CODEX_CLI_BIN || resolveCommand('codex') }
193
193
  : {}),
194
+ ...(process.env.AGY_CLI_BIN || resolveCommand('agy')
195
+ ? { agyCliBin: process.env.AGY_CLI_BIN || resolveCommand('agy') }
196
+ : {}),
194
197
  ...(notificationFile ? { notificationFile } : {}),
195
198
  };
196
199
  const outcome = performSelfUpdate(options);
@@ -346,7 +349,8 @@ async function runSendVoiceCli() {
346
349
  throw new Error('Telegram voice files must be 50MB or smaller.');
347
350
  }
348
351
  const inferredBotId = parsed.botId ?? inferTelegramBotId(process.env.CODEX_HOME) ?? inferTelegramBotId(config.codexHome);
349
- const { botId, botToken } = resolveTelegramVoiceTarget(config.tgBotTokens, inferredBotId);
352
+ const allTokens = [...config.codexBotTokens, ...config.antigravityBotTokens];
353
+ const { botId, botToken } = resolveTelegramVoiceTarget(allTokens, inferredBotId);
350
354
  const { BridgeStore } = await import('./store/database.js');
351
355
  const store = new BridgeStore(config.storePath);
352
356
  let chatId = parsed.chatId;
@@ -391,7 +395,8 @@ async function runSendMediaCli() {
391
395
  }
392
396
  const plan = planTelegramOutboundMedia(filePath);
393
397
  const inferredBotId = parsed.botId ?? inferTelegramBotId(process.env.CODEX_HOME) ?? inferTelegramBotId(config.codexHome);
394
- const { botId, botToken } = resolveTelegramVoiceTarget(config.tgBotTokens, inferredBotId);
398
+ const allTokens = [...config.codexBotTokens, ...config.antigravityBotTokens];
399
+ const { botId, botToken } = resolveTelegramVoiceTarget(allTokens, inferredBotId);
395
400
  const { BridgeStore } = await import('./store/database.js');
396
401
  const store = new BridgeStore(config.storePath);
397
402
  let chatId = parsed.chatId;
@@ -523,6 +528,17 @@ function formatRuntimeStatusSummary(status) {
523
528
  else if (status.codexHome) {
524
529
  lines.push(`Codex home: ${status.codexHome}`);
525
530
  }
531
+ const agyHomes = (status.bots ?? [])
532
+ .flatMap(bot => bot.antigravityHome ? [{ label: bot.username ? `@${bot.username}` : bot.id, home: bot.antigravityHome }] : []);
533
+ if (agyHomes.length > 1) {
534
+ lines.push('Antigravity homes:');
535
+ for (const { label, home } of agyHomes) {
536
+ lines.push(` ${label}: ${home}`);
537
+ }
538
+ }
539
+ else if (agyHomes.length === 1) {
540
+ lines.push(`Antigravity home: ${agyHomes[0].home}`);
541
+ }
526
542
  if (status.codexAppServer) {
527
543
  lines.push(`App server: ${status.codexAppServer.running ? 'running' : 'stopped'}${status.codexAppServer.pid ? ` pid=${status.codexAppServer.pid}` : ''}${status.codexAppServer.port ? ` port=${status.codexAppServer.port}` : ''}`);
528
544
  }
@@ -589,7 +605,7 @@ function truncateStatusLine(value, maxLength) {
589
605
  return normalized.length <= maxLength ? normalized : `${normalized.slice(0, Math.max(0, maxLength - 1))}…`;
590
606
  }
591
607
  async function runServeCli() {
592
- const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter }, { AuthCandidateMirror }, { CrossNodeAuthSync }, { OpencodeTelegramRuntime }, { AntigravityTelegramRuntime }, { UnifiedBridgeCore }, { AntigravityAppClient }, { AntigravityAuthManager },] = await Promise.all([
608
+ const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter }, { AuthCandidateMirror }, { CrossNodeAuthSync }, { OpencodeTelegramRuntime }, { UnifiedBridgeCore }, { AntigravityAppClient }, { AntigravityAuthManager, linkAntigravityAuthTokens },] = await Promise.all([
593
609
  import('./channels/bridge_messaging_router.js'),
594
610
  import('./channels/telegram/telegram_messaging_port.js'),
595
611
  import('./channels/weixin/weixin_channel_adapter.js'),
@@ -605,7 +621,6 @@ async function runServeCli() {
605
621
  import('./auth/mirror.js'),
606
622
  import('./auth/cross_node_sync.js'),
607
623
  import('./opencode/runtime.js'),
608
- import('./antigravity/runtime.js'),
609
624
  import('./antigravity/controller.js'),
610
625
  import('./antigravity/client.js'),
611
626
  import('./antigravity/auth.js'),
@@ -624,12 +639,20 @@ async function runServeCli() {
624
639
  let activeAuthMirror = null;
625
640
  let activeAuthSync = null;
626
641
  let activeOpencodeRuntime = null;
627
- const activeAntigravityRuntimes = [];
628
642
  let sharedCodexApp = null;
629
643
  let sharedAntigravityApp = null;
630
644
  let sharedAntigravityAuth = null;
631
645
  try {
632
646
  store = new BridgeStore(config.storePath);
647
+ const selfUpdater = createSelfUpdateRuntime({
648
+ entryPoint,
649
+ nodePath: process.execPath,
650
+ version: readPackageVersion(),
651
+ statusPath: config.statusPath,
652
+ logPath: path.join(APP_HOME, 'logs', 'update.log'),
653
+ codexCliBin: config.codexCliBin,
654
+ agyCliBin: config.antigravityCliBin,
655
+ });
633
656
  sharedAntigravityAuth = new AntigravityAuthManager(config.antigravityAuthDir, logger);
634
657
  sharedAntigravityApp = new AntigravityAppClient(config.antigravityCliBin, logger);
635
658
  if (config.codexCliBin) {
@@ -640,474 +663,226 @@ async function runServeCli() {
640
663
  await activeOpencodeRuntime.start();
641
664
  logger.info('opencode.bridge.started', activeOpencodeRuntime.getRuntimeStatus());
642
665
  }
643
- if (config.antigravityBotTokens.length > 0) {
644
- for (const token of config.antigravityBotTokens) {
645
- const bot = new TelegramGateway(token, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger, true, getAntigravityTelegramCommands);
646
- const id = await bot.initializeIdentity();
647
- const username = await bot.resolveUsername();
648
- const sharedDefaultRuntime = config.antigravityDefaultRuntimeBotToken
666
+ const totalBots = config.codexBotTokens.length + config.antigravityBotTokens.length;
667
+ const namespacedScopes = totalBots > 1;
668
+ const seeds = [];
669
+ const canonicalAuthDir = config.codexAuthDir ?? config.codexHome ?? path.join(os.homedir(), '.codex');
670
+ const specs = [
671
+ ...config.codexBotTokens.map((token, i) => ({
672
+ token,
673
+ backend: 'codex',
674
+ isDefaultRuntimeForBackend: config.tgDefaultRuntimeBotToken
675
+ ? config.tgDefaultRuntimeBotToken === token
676
+ : i === 0,
677
+ })),
678
+ ...config.antigravityBotTokens.map((token, i) => ({
679
+ token,
680
+ backend: 'antigravity',
681
+ isDefaultRuntimeForBackend: config.antigravityDefaultRuntimeBotToken
649
682
  ? config.antigravityDefaultRuntimeBotToken === token
650
- : (token === config.antigravityBotTokens[0]);
651
- let home;
652
- let authDir;
653
- let app;
654
- let auth;
655
- if (sharedDefaultRuntime) {
656
- home = os.homedir();
657
- authDir = config.antigravityAuthDir;
658
- app = sharedAntigravityApp;
659
- auth = sharedAntigravityAuth;
660
- }
661
- else {
662
- home = prepareTelegramBotHome(DEFAULT_ANTIGRAVITY_TELEGRAM_HOME, id, username, null);
663
- authDir = path.join(home, '.gemini', 'antigravity-cli');
664
- fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
665
- app = new AntigravityAppClient(config.antigravityCliBin, logger, { HOME: home });
666
- auth = new AntigravityAuthManager(authDir, logger);
667
- }
668
- const runtimeConfig = {
669
- ...config,
670
- antigravityBotToken: token,
671
- antigravityBotTokens: [token],
672
- tgScopeBotId: id,
673
- antigravityAuthDir: authDir,
674
- };
675
- const runtime = new AntigravityTelegramRuntime(runtimeConfig, store, logger, {
676
- botToken: token,
677
- botId: id,
678
- botUsername: username ?? undefined,
679
- sharedDefaultRuntime,
680
- home,
681
- authDir,
682
- codexApp: sharedCodexApp ?? undefined,
683
- app,
684
- auth,
685
- });
686
- await runtime.start();
687
- activeAntigravityRuntimes.push(runtime);
688
- logger.info('antigravity.bridge.started', runtime.getRuntimeStatus());
683
+ : i === 0,
684
+ })),
685
+ ];
686
+ const identities = await Promise.all(specs.map(async (spec) => {
687
+ const bot = new TelegramGateway(spec.token, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger, namespacedScopes, spec.backend === 'antigravity' ? getAntigravityTelegramCommands : getTelegramCommands);
688
+ const id = await bot.initializeIdentity();
689
+ const username = await bot.resolveUsername();
690
+ return { ...spec, bot, id, username };
691
+ }));
692
+ for (const { token, backend, isDefaultRuntimeForBackend, bot, id, username } of identities) {
693
+ if (seeds.some((runtime) => runtime.id === id)) {
694
+ throw new Error(`Duplicate Telegram bot identity detected: ${id}`);
689
695
  }
690
- }
691
- if (config.tgMultiBotMode) {
692
- const seeds = [];
693
- const canonicalAuthDir = config.codexAuthDir ?? config.codexHome ?? path.join(os.homedir(), '.codex');
694
- const identities = await Promise.all(config.tgBotTokens.map(async (token) => {
695
- const bot = new TelegramGateway(token, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger, true);
696
- const id = await bot.initializeIdentity();
697
- const username = await bot.resolveUsername();
698
- return { token, bot, id, username };
699
- }));
700
- for (const { token, bot, id, username } of identities) {
701
- if (seeds.some((runtime) => runtime.id === id)) {
702
- throw new Error(`TG_BOT_TOKENS contains duplicate Telegram bot identity: ${id}`);
703
- }
704
- const sharedDefaultRuntime = config.tgDefaultRuntimeBotToken === token;
705
- const home = prepareTelegramBotHome(DEFAULT_CODEX_TELEGRAM_HOME, id, username, sharedDefaultRuntime ? (config.codexHome ?? path.join(os.homedir(), '.codex')) : null);
706
- const authDir = sharedDefaultRuntime ? canonicalAuthDir : home;
707
- if (!sharedDefaultRuntime) {
708
- fs.mkdirSync(home, { recursive: true, mode: 0o700 });
709
- }
710
- installBundledCodexSkills(packageRoot, home);
711
- const runtimeConfig = {
712
- ...config,
713
- tgBotToken: token,
714
- tgBotTokens: [token],
715
- tgScopeBotId: id,
716
- codexAuthDir: sharedDefaultRuntime ? config.codexAuthDir : home,
717
- codexHome: home,
718
- codexAppAutolaunch: sharedDefaultRuntime ? config.codexAppAutolaunch : false,
719
- codexAppServerStatePath: sharedDefaultRuntime
720
- ? config.codexAppServerStatePath
721
- : path.join(APP_HOME, 'runtime', `codex-app-server-${id}.json`),
722
- codexAppServerLogPath: sharedDefaultRuntime
723
- ? config.codexAppServerLogPath
724
- : path.join(APP_HOME, 'logs', `codex-app-server-${id}.log`),
725
- };
726
- const childEnv = { CODEX_HOME: home };
727
- const app = new CodexAppClient(runtimeConfig.codexCliBin, runtimeConfig.codexAppLaunchCmd, runtimeConfig.codexAppAutolaunch, runtimeConfig.codexAppServerStatePath, runtimeConfig.codexAppServerLogPath, logger, childEnv, [
696
+ // Codex environment
697
+ const isCodexDefault = backend === 'codex' && isDefaultRuntimeForBackend;
698
+ const codexHome = isCodexDefault
699
+ ? (config.codexHome ?? path.join(os.homedir(), '.codex'))
700
+ : prepareTelegramBotHome(DEFAULT_CODEX_TELEGRAM_HOME, id, username, null);
701
+ const codexAuthDir = isCodexDefault ? (config.codexAuthDir ?? codexHome) : codexHome;
702
+ if (!isCodexDefault) {
703
+ fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
704
+ }
705
+ installBundledCodexSkills(packageRoot, codexHome);
706
+ const codexAppAutolaunch = isCodexDefault ? config.codexAppAutolaunch : false;
707
+ const codexAppServerStatePath = isCodexDefault
708
+ ? config.codexAppServerStatePath
709
+ : path.join(APP_HOME, 'runtime', `codex-app-server-${id}.json`);
710
+ const codexAppServerLogPath = isCodexDefault
711
+ ? config.codexAppServerLogPath
712
+ : path.join(APP_HOME, 'logs', `codex-app-server-${id}.log`);
713
+ const codexApp = (isCodexDefault && sharedCodexApp)
714
+ ? sharedCodexApp
715
+ : new CodexAppClient(config.codexCliBin, config.codexAppLaunchCmd, codexAppAutolaunch, codexAppServerStatePath, codexAppServerLogPath, logger, { CODEX_HOME: codexHome }, [
728
716
  ...codexApiProviderOverrides,
729
- ...(sharedDefaultRuntime ? [] : ['cli_auth_credentials_store="file"']),
717
+ ...(isCodexDefault ? [] : ['cli_auth_credentials_store="file"']),
730
718
  ]);
731
- seeds.push({ id, home, authDir, sharedDefaultRuntime, config: runtimeConfig, bot, app });
732
- }
733
- let authSync = null;
734
- const authRuntimeIds = seeds.map((runtime) => runtime.id);
735
- const mirror = new AuthCandidateMirror(canonicalAuthDir, seeds.map((runtime) => ({
736
- id: runtime.id,
737
- label: runtime.bot.username ? `@${runtime.bot.username}` : runtime.id,
738
- authDir: runtime.authDir,
739
- validate: async (context) => validateRefreshedAuthCandidate(runtime, context.candidateName),
740
- notify: createAuthMirrorNotifier(store, runtime.id, runtime.bot, authNotificationAggregator, {
741
- quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
742
- suppressBackgroundNotifications: () => true,
743
- }),
744
- })), logger, path.join(APP_HOME, 'runtime', 'auth-mirror.json'), {
745
- onSynced: async (event) => {
746
- await authSync?.publishCandidate(event.record.candidateName);
747
- },
748
- onRemoteImported: (event) => {
749
- restoreImportedAuthCandidateState(store, event.record.candidateName, authRuntimeIds);
750
- },
751
- });
752
- await mirror.initialize();
753
- activeAuthMirror = mirror;
754
- managedApps = seeds.map((runtime) => runtime.app);
755
- const selfUpdater = createSelfUpdateRuntime({
756
- entryPoint,
757
- nodePath: process.execPath,
758
- version: readPackageVersion(),
759
- statusPath: config.statusPath,
760
- logPath: path.join(APP_HOME, 'logs', 'update.log'),
761
- codexCliBin: config.codexCliBin,
762
- agyCliBin: config.antigravityCliBin,
763
- });
764
- const lastSelfUpdatePath = path.join(APP_HOME, 'runtime', 'last-self-update.json');
765
- let lastSelfUpdate = readSelfUpdateStatus(lastSelfUpdatePath);
766
- const runtimes = [];
767
- const localAuthRefreshLease = createLocalAuthRefreshLease();
768
- const writeAggregateStatus = (running = true) => {
769
- const statuses = runtimes.map((runtime) => runtime.core.getRuntimeStatus());
770
- const weixinStatus = activeWeixinCore?.getRuntimeStatus() ?? null;
771
- const authProactiveRefresh = newestAuthProactiveRefreshStatus([...statuses, ...(weixinStatus ? [weixinStatus] : [])]);
772
- const first = statuses[0] ?? null;
773
- writeRuntimeStatus(config.statusPath, {
774
- running,
775
- connected: running
776
- && statuses.every((status) => status.connected)
777
- && (!weixinStatus || weixinStatus.connected),
778
- userAgent: first?.userAgent ?? null,
779
- codexHome: first?.codexHome ?? null,
780
- ...(first?.codexAppServer ? { codexAppServer: first.codexAppServer } : {}),
781
- botUsername: first?.botUsername ?? null,
782
- currentBindings: store.countBindings(),
783
- pendingApprovals: store.countPendingApprovals(),
784
- pendingUserInputs: store.countPendingUserInputs(),
785
- queuedTurns: store.countQueuedTurnInputs(),
786
- activeTurns: statuses.reduce((sum, status) => sum + status.activeTurns, 0)
787
- + (weixinStatus?.activeTurns ?? 0),
788
- lastError: statuses.find((status) => status.lastError)?.lastError
789
- ?? weixinStatus?.lastError
790
- ?? null,
791
- updatedAt: new Date().toISOString(),
792
- channels: { telegram: running, weixin: running && config.wxEnabled },
793
- bots: runtimes.map((runtime, index) => ({
794
- id: runtime.id,
795
- username: statuses[index]?.botUsername ?? runtime.bot.username,
796
- connected: running && Boolean(statuses[index]?.connected),
797
- activeTurns: running ? (statuses[index]?.activeTurns ?? 0) : 0,
798
- runtimeKind: runtime.sharedDefaultRuntime ? 'default' : 'isolated',
799
- codexHome: statuses[index]?.codexHome ?? runtime.home,
800
- ...(statuses[index]?.codexAppServer ? { codexAppServer: statuses[index].codexAppServer } : {}),
801
- })),
802
- ...(weixinStatus ? {
803
- weixinRuntime: {
804
- connected: running && weixinStatus.connected,
805
- activeTurns: running ? weixinStatus.activeTurns : 0,
806
- ...(weixinStatus.codexAppServer ? { codexAppServer: weixinStatus.codexAppServer } : {}),
807
- },
808
- } : {}),
809
- ...(activeAntigravityRuntimes.length > 0 ? {
810
- antigravityBots: activeAntigravityRuntimes.map((runtime) => {
811
- const status = runtime.getRuntimeStatus();
812
- return {
813
- id: runtime.id,
814
- username: status.botUsername ?? runtime.botGateway.username,
815
- connected: running && Boolean(status.connected),
816
- activeTurns: running ? (status.activeTurns ?? 0) : 0,
817
- runtimeKind: runtime.isSharedDefaultRuntime ? 'default' : 'isolated',
818
- home: runtime.botHome,
819
- authDir: runtime.authDirectory,
820
- };
821
- }),
822
- } : {}),
823
- authMirror: mirror.getStatus(),
824
- authSync: authSync?.getStatus() ?? null,
825
- authProactiveRefresh,
826
- lastUpdate: lastSelfUpdate,
827
- });
828
- };
829
- const authSyncLocalIdle = () => runtimes.every((runtime) => runtime.core.isIdleForServiceUpdate())
830
- && (!activeWeixinCore || activeWeixinCore.isIdleForServiceUpdate())
831
- && mirror.isIdle();
832
- const clusterUpdateScheduler = createClusterUpdateScheduler({
833
- selfUpdater,
834
- canUpdate: () => authSyncLocalIdle()
835
- && (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
836
- currentVersion: readPackageVersion,
837
- logger,
838
- });
839
- const coordinator = {
840
- canSelfUpdate: () => authSyncLocalIdle()
841
- && (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
842
- authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
843
- authCandidateDeleted: async (_runtimeId, candidateName, reason = null) => {
844
- await mirror.deleteCandidate(candidateName);
845
- await authSync?.publishCandidateDeletion(candidateName, reason);
846
- },
847
- recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
848
- const local = await mirror.recoverRuntimeCandidate(runtimeId, candidateName);
849
- if (local)
850
- return true;
851
- if (options.crossNode === false)
852
- return false;
853
- const current = await mirror.readRuntimeCandidate(runtimeId, candidateName)
854
- ?? await mirror.readNewestCandidate(candidateName);
855
- return await authSync?.requestRecovery(candidateName, {
856
- accountId: current?.accountId ?? null,
857
- quotaIdentityId: current?.quotaIdentityId ?? null,
858
- lastRefreshMs: current?.lastRefreshMs ?? null,
859
- }) ?? false;
860
- },
861
- acquireAuthRefreshLease: (reason) => authSync?.acquireRefreshLease(reason)
862
- ?? localAuthRefreshLease.acquire(reason),
863
- releaseAuthRefreshLease: (leaseId) => authSync?.releaseRefreshLease(leaseId)
864
- ?? localAuthRefreshLease.release(leaseId),
865
- getAuthSyncStatus: () => authSync?.getStatus() ?? null,
866
- authSyncSafeAll: async () => {
867
- const local = await mirror.syncAllRuntimeCandidates();
868
- const remote = await authSync?.pushAll() ?? { sent: 0, skipped: 0 };
869
- return {
870
- localSynced: local.synced,
871
- localSkipped: local.skipped,
872
- sent: remote.sent,
873
- skipped: remote.skipped,
874
- };
875
- },
876
- authSyncPushAll: () => authSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
877
- authSyncTest: () => authSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
878
- authSyncAudit: () => authSync?.auditCluster() ?? Promise.resolve(null),
879
- statusUpdated: () => writeAggregateStatus(),
880
- getServiceStatus: async () => ({
881
- currentVersion: readPackageVersion(),
882
- bots: await Promise.all(runtimes.map(async (runtime) => {
883
- const status = runtime.core.getRuntimeStatus();
884
- return {
885
- id: runtime.id,
886
- username: status.botUsername ?? runtime.bot.username,
887
- connected: status.connected,
888
- activeTurns: status.activeTurns,
889
- runtimeKind: runtime.sharedDefaultRuntime ? 'default' : 'isolated',
890
- currentAuth: await runtime.core.getCurrentAuthLabel().catch(() => null),
891
- codexHome: status.codexHome ?? runtime.home,
892
- ...(status.codexAppServer ? { codexAppServer: status.codexAppServer } : {}),
893
- };
894
- })),
895
- ...(activeWeixinCore ? {
896
- weixinRuntime: {
897
- connected: activeWeixinCore.getRuntimeStatus().connected,
898
- activeTurns: activeWeixinCore.getRuntimeStatus().activeTurns,
899
- codexAppServer: activeWeixinCore.getRuntimeStatus().codexAppServer,
900
- },
901
- } : {}),
902
- authMirror: mirror.getStatus(),
903
- authSync: authSync?.getStatus() ?? null,
904
- authProactiveRefresh: newestAuthProactiveRefreshStatus([
905
- ...runtimes.map(runtime => runtime.core.getRuntimeStatus()),
906
- ...(activeWeixinCore ? [activeWeixinCore.getRuntimeStatus()] : []),
907
- ]),
908
- lastUpdate: lastSelfUpdate,
909
- }),
910
- selfUpdateCompleted: (status) => {
911
- lastSelfUpdate = status;
912
- writeSelfUpdateStatus(lastSelfUpdatePath, status);
913
- writeAggregateStatus();
914
- },
915
- };
916
- for (const seed of seeds) {
917
- const telegramMessaging = new TelegramMessagingPort(seed.bot);
918
- const outbound = new BridgeMessagingRouter(telegramMessaging, null);
919
- const codexCore = new BridgeSessionCore(seed.config, store, logger, seed.bot, seed.app, outbound, selfUpdater, coordinator, false);
920
- const core = new UnifiedBridgeCore(seed.config, store, logger, seed.bot, sharedAntigravityApp ?? undefined, sharedAntigravityAuth ?? undefined, telegramMessaging, {
921
- codexApp: seed.app,
922
- codexCore,
923
- antigravityApp: sharedAntigravityApp ?? undefined,
924
- antigravityAuth: sharedAntigravityAuth ?? undefined,
925
- defaultBackendId: 'codex',
926
- });
927
- runtimes.push({ ...seed, core, telegram: new TelegramChannelAdapter(core) });
719
+ // Antigravity environment
720
+ const isAntigravityDefault = backend === 'antigravity' && isDefaultRuntimeForBackend;
721
+ let antigravityHome;
722
+ let antigravityAuthDir;
723
+ let antigravityApp;
724
+ let antigravityAuth;
725
+ if (isAntigravityDefault) {
726
+ antigravityHome = os.homedir();
727
+ antigravityAuthDir = config.antigravityAuthDir;
728
+ antigravityApp = sharedAntigravityApp;
729
+ antigravityAuth = sharedAntigravityAuth;
928
730
  }
929
- if (config.wxEnabled) {
930
- const weixinApp = new CodexAppClient(config.codexCliBin, config.codexAppLaunchCmd, config.codexAppAutolaunch, config.codexAppServerStatePath, config.codexAppServerLogPath, logger, null, codexApiProviderOverrides);
931
- const outbound = new BridgeMessagingRouter(new TelegramMessagingPort(seeds[0].bot), new WeixinMessagingPort(store, (id) => loadWeixinAccount(config.weixinAccountsDir, id)));
932
- activeWeixinCore = new BridgeSessionCore(config, store, logger, seeds[0].bot, weixinApp, outbound, selfUpdater, coordinator, false);
933
- managedApps.push(weixinApp);
934
- weixinAdapter = new WeixinChannelAdapter(activeWeixinCore, store, config, logger);
731
+ else {
732
+ antigravityHome = prepareTelegramBotHome(DEFAULT_ANTIGRAVITY_TELEGRAM_HOME, id, username, null);
733
+ antigravityAuthDir = path.join(antigravityHome, '.gemini', 'antigravity-cli');
734
+ fs.mkdirSync(antigravityAuthDir, { recursive: true, mode: 0o700 });
735
+ linkAntigravityAuthTokens(config.antigravityAuthDir, antigravityAuthDir);
736
+ antigravityApp = new AntigravityAppClient(config.antigravityCliBin, logger, { HOME: antigravityHome });
737
+ antigravityAuth = new AntigravityAuthManager(antigravityAuthDir, logger);
935
738
  }
936
- activeTelegramAdapters = runtimes.map((runtime) => runtime.telegram);
937
- if (config.authSyncEnabled) {
938
- const authSyncTransportBot = seeds[0];
939
- const authSyncTransportLabel = authSyncTransportBot.bot.username
940
- ? `@${authSyncTransportBot.bot.username}`
941
- : authSyncTransportBot.id;
942
- authSync = new CrossNodeAuthSync(buildAuthSyncConfig(config, authSyncTransportLabel), logger, {
943
- send: async (peer, envelope) => {
944
- await authSyncTransportBot.bot.sendDocument(peer, `foxclaw-auth-sync-${Date.now()}.json`, Buffer.from(envelope, 'utf8'), 'FOXCLAW_AUTH_SYNC_V1');
945
- },
946
- }, {
947
- readLocalCandidate: (candidateName) => mirror.readNewestCandidate(candidateName),
948
- listLocalCandidates: () => mirror.listNewestCandidates(),
949
- listLocalCandidateCopies: () => mirror.listCandidateCopies(),
950
- validateCandidate: async (candidateName, raw, expectedAccountId) => {
951
- if (!authSyncLocalIdle()) {
952
- return { ok: false, reason: 'runtime is not idle' };
953
- }
954
- const runtime = runtimes.find((entry) => entry.core.isIdleForServiceUpdate()) ?? runtimes[0] ?? null;
955
- if (!runtime) {
956
- return { ok: false, reason: 'no validation runtime is available' };
957
- }
958
- return runtime.core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
959
- },
960
- importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
961
- markCandidateState: (candidateName, state, expected) => markAuditedAuthCandidateState(store, mirror, candidateName, state, expected, authRuntimeIds),
962
- deleteLocalCandidate: async (candidateName, source) => {
963
- if (!authSyncLocalIdle()) {
964
- return { ok: false, deleted: false, reason: 'runtime is not idle' };
965
- }
966
- await mirror.deleteCandidate(candidateName);
967
- store.deleteCodexAuthCandidate(candidateName);
968
- await Promise.all(runtimes.map(runtime => runtime.core.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null)));
969
- if (activeWeixinCore) {
970
- await activeWeixinCore.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null);
971
- }
972
- return { ok: true, deleted: true, reason: source.reason ?? null };
973
- },
974
- scheduleServiceUpdate: (source) => clusterUpdateScheduler.schedule(source),
975
- isIdle: authSyncLocalIdle,
976
- notify: createAuthSyncNotifier(store, authSyncTransportBot.bot, authNotificationAggregator, {
977
- quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
978
- suppressBackgroundNotifications: () => true,
979
- }),
980
- });
981
- await authSync.initialize();
982
- activeAuthSync = authSync;
983
- attachTelegramAuthSync(authSyncTransportBot.bot, authSync, config, logger);
984
- authSync.start();
985
- void publishPendingClusterUpdateBroadcast(authSync, logger);
739
+ const runtimeConfig = {
740
+ ...config,
741
+ tgBotToken: token,
742
+ tgBotTokens: [token],
743
+ tgScopeBotId: id,
744
+ codexAuthDir,
745
+ codexHome,
746
+ antigravityHome,
747
+ antigravityAuthDir,
748
+ codexAppAutolaunch,
749
+ codexAppServerStatePath,
750
+ codexAppServerLogPath,
751
+ };
752
+ if (namespacedScopes && (isCodexDefault || isAntigravityDefault)) {
753
+ store.migrateTelegramUnnamespacedScope(id, config.tgAllowedUserId);
986
754
  }
987
- mirror.start();
988
- process.on('unhandledRejection', (error) => {
989
- logger.error('process.unhandled_rejection', { error: serializeError(error) });
990
- });
991
- process.on('uncaughtException', (error) => {
992
- logger.error('process.uncaught_exception', { error: serializeError(error) });
755
+ seeds.push({
756
+ id,
757
+ username,
758
+ home: codexHome,
759
+ authDir: codexAuthDir,
760
+ sharedDefaultRuntime: isCodexDefault || isAntigravityDefault,
761
+ config: runtimeConfig,
762
+ bot,
763
+ app: codexApp,
764
+ antigravityApp,
765
+ antigravityAuth,
766
+ antigravityHome,
767
+ antigravityAuthDir,
768
+ defaultBackend: backend,
993
769
  });
994
- for (const runtime of runtimes) {
995
- await runtime.telegram.start();
996
- }
997
- if (weixinAdapter) {
998
- await activeWeixinCore.startCodexApp();
999
- await weixinAdapter.start();
1000
- }
1001
- writeAggregateStatus();
1002
- logger.info('bridge.started', { bots: runtimes.map((runtime) => runtime.id) });
1003
- const shutdown = async (signal) => {
1004
- logger.info('bridge.shutting_down', { signal });
1005
- await authNotificationAggregator.flushAll();
1006
- authSync?.stop();
1007
- mirror.stop();
1008
- await weixinAdapter?.stop();
1009
- await activeWeixinCore?.stop();
1010
- await Promise.all(runtimes.map((runtime) => runtime.telegram.stop()));
1011
- await activeOpencodeRuntime?.stop();
1012
- await Promise.all(activeAntigravityRuntimes.map((runtime) => runtime.stop().catch(() => { })));
1013
- writeAggregateStatus(false);
1014
- const allManaged = [...managedApps, ...(sharedCodexApp ? [sharedCodexApp] : [])];
1015
- await Promise.all(allManaged.map((app) => app.stop({ terminateServer: true }).catch((error) => {
1016
- logger.warn('codex.app-server.stop_failed', { error: serializeError(error) });
1017
- })));
1018
- store?.close();
1019
- processLock.release();
1020
- process.exit(0);
1021
- };
1022
- process.on('SIGINT', () => void shutdown('SIGINT'));
1023
- process.on('SIGTERM', () => void shutdown('SIGTERM'));
1024
- return;
1025
770
  }
1026
- const singleCodexHome = config.codexHome ?? path.join(os.homedir(), '.codex');
1027
- installBundledCodexSkills(packageRoot, singleCodexHome);
1028
- const bot = new TelegramGateway(config.tgBotToken, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger);
1029
- const app = sharedCodexApp ?? new CodexAppClient(config.codexCliBin, config.codexAppLaunchCmd, config.codexAppAutolaunch, config.codexAppServerStatePath, config.codexAppServerLogPath, logger, config.codexHome ? { CODEX_HOME: config.codexHome } : null, codexApiProviderOverrides);
1030
- const telegramMessaging = new TelegramMessagingPort(bot);
1031
- const weixinMessaging = config.wxEnabled
1032
- ? new WeixinMessagingPort(store, (id) => loadWeixinAccount(config.weixinAccountsDir, id))
1033
- : null;
1034
- const outbound = new BridgeMessagingRouter(telegramMessaging, weixinMessaging);
1035
- void outbound;
1036
- const selfUpdater = createSelfUpdateRuntime({
1037
- entryPoint,
1038
- nodePath: process.execPath,
1039
- version: readPackageVersion(),
1040
- statusPath: config.statusPath,
1041
- logPath: path.join(APP_HOME, 'logs', 'update.log'),
1042
- codexCliBin: config.codexCliBin,
1043
- agyCliBin: config.antigravityCliBin,
771
+ let authSync = null;
772
+ const authRuntimeIds = seeds.map((runtime) => runtime.id);
773
+ const mirror = new AuthCandidateMirror(canonicalAuthDir, seeds.map((runtime) => ({
774
+ id: runtime.id,
775
+ label: runtime.bot.username ? `@${runtime.bot.username}` : runtime.id,
776
+ authDir: runtime.authDir,
777
+ validate: async (context) => validateRefreshedAuthCandidate(runtime, context.candidateName),
778
+ notify: createAuthMirrorNotifier(store, runtime.id, runtime.bot, authNotificationAggregator, {
779
+ quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
780
+ suppressBackgroundNotifications: () => true,
781
+ }),
782
+ })), logger, path.join(APP_HOME, 'runtime', 'auth-mirror.json'), {
783
+ onSynced: async (event) => {
784
+ await authSync?.publishCandidate(event.record.candidateName);
785
+ },
786
+ onRemoteImported: (event) => {
787
+ restoreImportedAuthCandidateState(store, event.record.candidateName, authRuntimeIds);
788
+ },
1044
789
  });
1045
- let singleAuthSync = null;
1046
- let singleMirror = null;
1047
- let core = null;
1048
- const writeSingleStatus = (running = true) => {
1049
- const coreStatus = core?.getRuntimeStatus();
790
+ await mirror.initialize();
791
+ activeAuthMirror = mirror;
792
+ managedApps = seeds.map((runtime) => runtime.app);
793
+ const lastSelfUpdatePath = path.join(APP_HOME, 'runtime', 'last-self-update.json');
794
+ let lastSelfUpdate = readSelfUpdateStatus(lastSelfUpdatePath);
795
+ const runtimes = [];
796
+ const localAuthRefreshLease = createLocalAuthRefreshLease();
797
+ const writeAggregateStatus = (running = true) => {
798
+ const statuses = runtimes.map((runtime) => runtime.core.getRuntimeStatus());
799
+ const weixinStatus = activeWeixinCore?.getRuntimeStatus() ?? null;
800
+ const authProactiveRefresh = newestAuthProactiveRefreshStatus([...statuses, ...(weixinStatus ? [weixinStatus] : [])]);
801
+ const first = statuses[0] ?? null;
1050
802
  writeRuntimeStatus(config.statusPath, {
1051
803
  running,
1052
- connected: running && Boolean(coreStatus?.connected),
1053
- userAgent: coreStatus?.userAgent ?? app.getUserAgent(),
1054
- codexHome: coreStatus?.codexHome ?? singleCodexHome,
1055
- ...(coreStatus?.codexAppServer ? { codexAppServer: coreStatus.codexAppServer } : app.getServerStatus() ? { codexAppServer: app.getServerStatus() } : {}),
1056
- botUsername: coreStatus?.botUsername ?? bot.username,
1057
- currentBindings: store?.countBindings() ?? 0,
1058
- pendingApprovals: store?.countPendingApprovals() ?? 0,
1059
- pendingUserInputs: store?.countPendingUserInputs() ?? 0,
1060
- queuedTurns: store?.countQueuedTurnInputs() ?? 0,
1061
- activeTurns: coreStatus?.activeTurns ?? 0,
1062
- lastError: null,
804
+ connected: running
805
+ && statuses.every((status) => status.connected)
806
+ && (!weixinStatus || weixinStatus.connected),
807
+ userAgent: first?.userAgent ?? null,
808
+ codexHome: first?.codexHome ?? null,
809
+ ...(first?.codexAppServer ? { codexAppServer: first.codexAppServer } : {}),
810
+ botUsername: first?.botUsername ?? null,
811
+ currentBindings: store.countBindings(),
812
+ pendingApprovals: store.countPendingApprovals(),
813
+ pendingUserInputs: store.countPendingUserInputs(),
814
+ queuedTurns: store.countQueuedTurnInputs(),
815
+ activeTurns: statuses.reduce((sum, status) => sum + status.activeTurns, 0)
816
+ + (weixinStatus?.activeTurns ?? 0),
817
+ lastError: statuses.find((status) => status.lastError)?.lastError
818
+ ?? weixinStatus?.lastError
819
+ ?? null,
1063
820
  updatedAt: new Date().toISOString(),
1064
821
  channels: { telegram: running, weixin: running && config.wxEnabled },
1065
- authMirror: singleMirror?.getStatus() ?? null,
1066
- authSync: singleAuthSync?.getStatus() ?? null,
822
+ bots: runtimes.map((runtime, index) => ({
823
+ id: runtime.id,
824
+ username: statuses[index]?.botUsername ?? runtime.bot.username,
825
+ connected: running && Boolean(statuses[index]?.connected),
826
+ activeTurns: running ? (statuses[index]?.activeTurns ?? 0) : 0,
827
+ runtimeKind: runtime.sharedDefaultRuntime ? 'default' : 'isolated',
828
+ defaultBackend: runtime.defaultBackend,
829
+ codexHome: statuses[index]?.codexHome ?? runtime.home,
830
+ antigravityHome: runtime.antigravityHome,
831
+ ...(statuses[index]?.codexAppServer ? { codexAppServer: statuses[index].codexAppServer } : {}),
832
+ })),
833
+ ...(weixinStatus ? {
834
+ weixinRuntime: {
835
+ connected: running && weixinStatus.connected,
836
+ activeTurns: running ? weixinStatus.activeTurns : 0,
837
+ ...(weixinStatus.codexAppServer ? { codexAppServer: weixinStatus.codexAppServer } : {}),
838
+ },
839
+ } : {}),
840
+ authMirror: mirror.getStatus(),
841
+ authSync: authSync?.getStatus() ?? null,
842
+ authProactiveRefresh,
843
+ lastUpdate: lastSelfUpdate,
1067
844
  });
1068
845
  };
1069
- const singleAuthDir = config.codexAuthDir ?? config.codexHome ?? process.env.CODEX_AUTH_DIR ?? path.join(os.homedir(), '.codex');
1070
- const singleLocalAuthRefreshLease = createLocalAuthRefreshLease();
1071
- const singleAuthSyncLocalIdle = () => Boolean(core?.isIdleForServiceUpdate())
1072
- && (!singleMirror || singleMirror.isIdle());
1073
- const singleClusterUpdateScheduler = createClusterUpdateScheduler({
846
+ const authSyncLocalIdle = () => runtimes.every((runtime) => runtime.core.isIdleForServiceUpdate())
847
+ && (!activeWeixinCore || activeWeixinCore.isIdleForServiceUpdate())
848
+ && mirror.isIdle();
849
+ const clusterUpdateScheduler = createClusterUpdateScheduler({
1074
850
  selfUpdater,
1075
- canUpdate: () => singleAuthSyncLocalIdle()
1076
- && (singleAuthSync ? singleAuthSync.isIdle() : singleLocalAuthRefreshLease.isIdle()),
851
+ canUpdate: () => authSyncLocalIdle()
852
+ && (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
1077
853
  currentVersion: readPackageVersion,
1078
854
  logger,
1079
855
  });
1080
- const singleCoordinator = config.authSyncEnabled ? {
1081
- canSelfUpdate: () => singleAuthSyncLocalIdle()
1082
- && (singleAuthSync ? singleAuthSync.isIdle() : singleLocalAuthRefreshLease.isIdle()),
1083
- authCandidateUpdated: (runtimeId, candidateName) => singleMirror?.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined) ?? Promise.resolve(),
856
+ const coordinator = {
857
+ canSelfUpdate: () => authSyncLocalIdle()
858
+ && (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
859
+ authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
1084
860
  authCandidateDeleted: async (_runtimeId, candidateName, reason = null) => {
1085
- await singleMirror?.deleteCandidate(candidateName);
1086
- await singleAuthSync?.publishCandidateDeletion(candidateName, reason);
861
+ await mirror.deleteCandidate(candidateName);
862
+ await authSync?.publishCandidateDeletion(candidateName, reason);
1087
863
  },
1088
864
  recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
1089
- const local = await singleMirror?.recoverRuntimeCandidate(runtimeId, candidateName) ?? null;
865
+ const local = await mirror.recoverRuntimeCandidate(runtimeId, candidateName);
1090
866
  if (local)
1091
867
  return true;
1092
868
  if (options.crossNode === false)
1093
869
  return false;
1094
- const current = await singleMirror?.readRuntimeCandidate(runtimeId, candidateName)
1095
- ?? await singleMirror?.readNewestCandidate(candidateName)
1096
- ?? null;
1097
- return await singleAuthSync?.requestRecovery(candidateName, {
870
+ const current = await mirror.readRuntimeCandidate(runtimeId, candidateName)
871
+ ?? await mirror.readNewestCandidate(candidateName);
872
+ return await authSync?.requestRecovery(candidateName, {
1098
873
  accountId: current?.accountId ?? null,
1099
874
  quotaIdentityId: current?.quotaIdentityId ?? null,
1100
875
  lastRefreshMs: current?.lastRefreshMs ?? null,
1101
876
  }) ?? false;
1102
877
  },
1103
- acquireAuthRefreshLease: (reason) => singleAuthSync?.acquireRefreshLease(reason)
1104
- ?? singleLocalAuthRefreshLease.acquire(reason),
1105
- releaseAuthRefreshLease: (leaseId) => singleAuthSync?.releaseRefreshLease(leaseId)
1106
- ?? singleLocalAuthRefreshLease.release(leaseId),
1107
- getAuthSyncStatus: () => singleAuthSync?.getStatus() ?? null,
878
+ acquireAuthRefreshLease: (reason) => authSync?.acquireRefreshLease(reason)
879
+ ?? localAuthRefreshLease.acquire(reason),
880
+ releaseAuthRefreshLease: (leaseId) => authSync?.releaseRefreshLease(leaseId)
881
+ ?? localAuthRefreshLease.release(leaseId),
882
+ getAuthSyncStatus: () => authSync?.getStatus() ?? null,
1108
883
  authSyncSafeAll: async () => {
1109
- const local = await singleMirror?.syncAllRuntimeCandidates() ?? { synced: 0, skipped: 0 };
1110
- const remote = await singleAuthSync?.pushAll() ?? { sent: 0, skipped: 0 };
884
+ const local = await mirror.syncAllRuntimeCandidates();
885
+ const remote = await authSync?.pushAll() ?? { sent: 0, skipped: 0 };
1111
886
  return {
1112
887
  localSynced: local.synced,
1113
888
  localSkipped: local.skipped,
@@ -1115,131 +890,148 @@ async function runServeCli() {
1115
890
  skipped: remote.skipped,
1116
891
  };
1117
892
  },
1118
- authSyncPushAll: () => singleAuthSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
1119
- authSyncTest: () => singleAuthSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
1120
- authSyncAudit: () => singleAuthSync?.auditCluster() ?? Promise.resolve(null),
1121
- statusUpdated: (_status) => {
1122
- void _status;
1123
- writeSingleStatus(true);
893
+ authSyncPushAll: () => authSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
894
+ authSyncTest: () => authSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
895
+ authSyncAudit: () => authSync?.auditCluster() ?? Promise.resolve(null),
896
+ statusUpdated: () => writeAggregateStatus(),
897
+ getServiceStatus: async () => ({
898
+ currentVersion: readPackageVersion(),
899
+ bots: await Promise.all(runtimes.map(async (runtime) => {
900
+ const status = runtime.core.getRuntimeStatus();
901
+ return {
902
+ id: runtime.id,
903
+ username: status.botUsername ?? runtime.bot.username,
904
+ connected: status.connected,
905
+ activeTurns: status.activeTurns,
906
+ runtimeKind: runtime.sharedDefaultRuntime ? 'default' : 'isolated',
907
+ currentAuth: await runtime.core.getCurrentAuthLabel().catch(() => null),
908
+ codexHome: status.codexHome ?? runtime.home,
909
+ ...(status.codexAppServer ? { codexAppServer: status.codexAppServer } : {}),
910
+ };
911
+ })),
912
+ ...(activeWeixinCore ? {
913
+ weixinRuntime: {
914
+ connected: activeWeixinCore.getRuntimeStatus().connected,
915
+ activeTurns: activeWeixinCore.getRuntimeStatus().activeTurns,
916
+ codexAppServer: activeWeixinCore.getRuntimeStatus().codexAppServer,
917
+ },
918
+ } : {}),
919
+ authMirror: mirror.getStatus(),
920
+ authSync: authSync?.getStatus() ?? null,
921
+ authProactiveRefresh: newestAuthProactiveRefreshStatus([
922
+ ...runtimes.map(runtime => runtime.core.getRuntimeStatus()),
923
+ ...(activeWeixinCore ? [activeWeixinCore.getRuntimeStatus()] : []),
924
+ ]),
925
+ lastUpdate: lastSelfUpdate,
926
+ }),
927
+ selfUpdateCompleted: (status) => {
928
+ lastSelfUpdate = status;
929
+ writeSelfUpdateStatus(lastSelfUpdatePath, status);
930
+ writeAggregateStatus();
1124
931
  },
1125
- } : null;
1126
- if (config.authSyncEnabled) {
1127
- singleMirror = new AuthCandidateMirror(singleAuthDir, [{
1128
- id: 'default',
1129
- label: bot.username ? `@${bot.username}` : 'default',
1130
- authDir: singleAuthDir,
1131
- validate: async (context) => validateRefreshedAuthCandidate({
1132
- id: 'default',
1133
- authDir: singleAuthDir,
1134
- app,
1135
- }, context.candidateName),
1136
- }], logger, path.join(APP_HOME, 'runtime', 'auth-mirror.json'), {
1137
- onSynced: async (event) => {
1138
- await singleAuthSync?.publishCandidate(event.record.candidateName);
1139
- },
1140
- onRemoteImported: (event) => {
1141
- restoreImportedAuthCandidateState(store, event.record.candidateName, ['default']);
1142
- },
932
+ };
933
+ for (const seed of seeds) {
934
+ const telegramMessaging = new TelegramMessagingPort(seed.bot);
935
+ const outbound = new BridgeMessagingRouter(telegramMessaging, null);
936
+ const codexCore = new BridgeSessionCore(seed.config, store, logger, seed.bot, seed.app, outbound, selfUpdater, coordinator, false);
937
+ const core = new UnifiedBridgeCore(seed.config, store, logger, seed.bot, seed.antigravityApp, seed.antigravityAuth, telegramMessaging, {
938
+ codexApp: seed.app,
939
+ codexCore,
940
+ antigravityApp: seed.antigravityApp,
941
+ antigravityAuth: seed.antigravityAuth,
942
+ defaultBackendId: seed.defaultBackend,
943
+ selfUpdater,
1143
944
  });
1144
- await singleMirror.initialize();
1145
- activeAuthMirror = singleMirror;
945
+ runtimes.push({ ...seed, core, telegram: new TelegramChannelAdapter(core) });
1146
946
  }
1147
- const singleOutbound = new BridgeMessagingRouter(telegramMessaging, null);
1148
- const singleCodexCore = new BridgeSessionCore(config, store, logger, bot, app, singleOutbound, selfUpdater, singleCoordinator, false);
1149
- core = new UnifiedBridgeCore(config, store, logger, bot, sharedAntigravityApp ?? undefined, sharedAntigravityAuth ?? undefined, telegramMessaging, {
1150
- codexApp: app,
1151
- codexCore: singleCodexCore,
1152
- antigravityApp: sharedAntigravityApp ?? undefined,
1153
- antigravityAuth: sharedAntigravityAuth ?? undefined,
1154
- defaultBackendId: 'codex',
1155
- });
1156
- if (config.authSyncEnabled && singleMirror) {
1157
- await bot.initializeIdentity();
1158
- singleAuthSync = new CrossNodeAuthSync(buildAuthSyncConfig(config, bot.username ? `@${bot.username}` : 'default'), logger, {
947
+ if (config.wxEnabled) {
948
+ const weixinApp = new CodexAppClient(config.codexCliBin, config.codexAppLaunchCmd, config.codexAppAutolaunch, config.codexAppServerStatePath, config.codexAppServerLogPath, logger, null, codexApiProviderOverrides);
949
+ const outbound = new BridgeMessagingRouter(new TelegramMessagingPort(seeds[0].bot), new WeixinMessagingPort(store, (id) => loadWeixinAccount(config.weixinAccountsDir, id)));
950
+ activeWeixinCore = new BridgeSessionCore(config, store, logger, seeds[0].bot, weixinApp, outbound, selfUpdater, coordinator, false);
951
+ managedApps.push(weixinApp);
952
+ weixinAdapter = new WeixinChannelAdapter(activeWeixinCore, store, config, logger);
953
+ }
954
+ activeTelegramAdapters = runtimes.map((runtime) => runtime.telegram);
955
+ if (config.authSyncEnabled) {
956
+ const authSyncTransportBot = seeds[0];
957
+ const authSyncTransportLabel = authSyncTransportBot.bot.username
958
+ ? `@${authSyncTransportBot.bot.username}`
959
+ : authSyncTransportBot.id;
960
+ authSync = new CrossNodeAuthSync(buildAuthSyncConfig(config, authSyncTransportLabel), logger, {
1159
961
  send: async (peer, envelope) => {
1160
- await bot.sendDocument(peer, `foxclaw-auth-sync-${Date.now()}.json`, Buffer.from(envelope, 'utf8'), 'FOXCLAW_AUTH_SYNC_V1');
962
+ await authSyncTransportBot.bot.sendDocument(peer, `foxclaw-auth-sync-${Date.now()}.json`, Buffer.from(envelope, 'utf8'), 'FOXCLAW_AUTH_SYNC_V1');
1161
963
  },
1162
964
  }, {
1163
- readLocalCandidate: (candidateName) => singleMirror.readNewestCandidate(candidateName),
1164
- listLocalCandidates: () => singleMirror.listNewestCandidates(),
1165
- listLocalCandidateCopies: () => singleMirror.listCandidateCopies(),
965
+ readLocalCandidate: (candidateName) => mirror.readNewestCandidate(candidateName),
966
+ listLocalCandidates: () => mirror.listNewestCandidates(),
967
+ listLocalCandidateCopies: () => mirror.listCandidateCopies(),
1166
968
  validateCandidate: async (candidateName, raw, expectedAccountId) => {
1167
- if (!singleAuthSyncLocalIdle()) {
969
+ if (!authSyncLocalIdle()) {
1168
970
  return { ok: false, reason: 'runtime is not idle' };
1169
971
  }
1170
- return core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
972
+ const runtime = runtimes.find((entry) => entry.core.isIdleForServiceUpdate()) ?? runtimes[0] ?? null;
973
+ if (!runtime) {
974
+ return { ok: false, reason: 'no validation runtime is available' };
975
+ }
976
+ return runtime.core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
1171
977
  },
1172
- importCandidate: (candidateName, raw, source) => singleMirror.importExternalCandidate(candidateName, raw, source),
1173
- markCandidateState: (candidateName, state, expected) => markAuditedAuthCandidateState(store, singleMirror, candidateName, state, expected, ['default']),
978
+ importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
979
+ markCandidateState: (candidateName, state, expected) => markAuditedAuthCandidateState(store, mirror, candidateName, state, expected, authRuntimeIds),
1174
980
  deleteLocalCandidate: async (candidateName, source) => {
1175
- if (!singleAuthSyncLocalIdle()) {
981
+ if (!authSyncLocalIdle()) {
1176
982
  return { ok: false, deleted: false, reason: 'runtime is not idle' };
1177
983
  }
1178
- await singleMirror.deleteCandidate(candidateName);
984
+ await mirror.deleteCandidate(candidateName);
1179
985
  store.deleteCodexAuthCandidate(candidateName);
1180
- await core.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null);
986
+ await Promise.all(runtimes.map(runtime => runtime.core.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null)));
987
+ if (activeWeixinCore) {
988
+ await activeWeixinCore.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null);
989
+ }
1181
990
  return { ok: true, deleted: true, reason: source.reason ?? null };
1182
991
  },
1183
- scheduleServiceUpdate: (source) => singleClusterUpdateScheduler.schedule(source),
1184
- isIdle: singleAuthSyncLocalIdle,
1185
- notify: createAuthSyncNotifier(store, bot, authNotificationAggregator, {
992
+ scheduleServiceUpdate: (source) => clusterUpdateScheduler.schedule(source),
993
+ isIdle: authSyncLocalIdle,
994
+ notify: createAuthSyncNotifier(store, authSyncTransportBot.bot, authNotificationAggregator, {
1186
995
  quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
1187
996
  suppressBackgroundNotifications: () => true,
1188
997
  }),
1189
998
  });
1190
- await singleAuthSync.initialize();
1191
- activeAuthSync = singleAuthSync;
1192
- attachTelegramAuthSync(bot, singleAuthSync, config, logger);
1193
- singleAuthSync.start();
1194
- void publishPendingClusterUpdateBroadcast(singleAuthSync, logger);
1195
- singleMirror.start();
1196
- }
1197
- const telegram = new TelegramChannelAdapter(core);
1198
- managedApps = [app];
1199
- activeTelegramAdapters = [telegram];
1200
- if (config.wxEnabled) {
1201
- weixinAdapter = new WeixinChannelAdapter(core, store, config, logger);
999
+ await authSync.initialize();
1000
+ activeAuthSync = authSync;
1001
+ attachTelegramAuthSync(authSyncTransportBot.bot, authSync, config, logger);
1002
+ authSync.start();
1003
+ void publishPendingClusterUpdateBroadcast(authSync, logger);
1202
1004
  }
1005
+ mirror.start();
1203
1006
  process.on('unhandledRejection', (error) => {
1204
1007
  logger.error('process.unhandled_rejection', { error: serializeError(error) });
1205
1008
  });
1206
1009
  process.on('uncaughtException', (error) => {
1207
1010
  logger.error('process.uncaught_exception', { error: serializeError(error) });
1208
1011
  });
1209
- await telegram.start();
1012
+ for (const runtime of runtimes) {
1013
+ await runtime.telegram.start();
1014
+ }
1210
1015
  if (weixinAdapter) {
1016
+ await activeWeixinCore.startCodexApp();
1211
1017
  await weixinAdapter.start();
1212
1018
  }
1213
- writeSingleStatus(true);
1214
- logger.info('bridge.started', core.getRuntimeStatus());
1019
+ writeAggregateStatus();
1020
+ logger.info('bridge.started', { bots: runtimes.map((runtime) => runtime.id) });
1215
1021
  const shutdown = async (signal) => {
1216
1022
  logger.info('bridge.shutting_down', { signal });
1217
1023
  await authNotificationAggregator.flushAll();
1218
- singleAuthSync?.stop();
1219
- singleMirror?.stop();
1024
+ authSync?.stop();
1025
+ mirror.stop();
1220
1026
  await weixinAdapter?.stop();
1221
- await telegram.stop();
1027
+ await activeWeixinCore?.stop();
1028
+ await Promise.all(runtimes.map((runtime) => runtime.telegram.stop()));
1222
1029
  await activeOpencodeRuntime?.stop();
1223
- await Promise.all(activeAntigravityRuntimes.map((runtime) => runtime.stop().catch(() => { })));
1224
- writeRuntimeStatus(config.statusPath, {
1225
- running: false,
1226
- connected: false,
1227
- userAgent: app.getUserAgent(),
1228
- codexHome: singleCodexHome,
1229
- codexAppServer: app.getServerStatus(),
1230
- botUsername: bot.username,
1231
- currentBindings: 0,
1232
- pendingApprovals: 0,
1233
- pendingUserInputs: 0,
1234
- queuedTurns: 0,
1235
- activeTurns: 0,
1236
- lastError: null,
1237
- updatedAt: new Date().toISOString(),
1238
- channels: { telegram: false, weixin: false },
1239
- });
1240
- await app.stop({ terminateServer: true }).catch((error) => {
1030
+ writeAggregateStatus(false);
1031
+ const allManaged = [...managedApps, ...(sharedCodexApp ? [sharedCodexApp] : [])];
1032
+ await Promise.all(allManaged.map((app) => app.stop({ terminateServer: true }).catch((error) => {
1241
1033
  logger.warn('codex.app-server.stop_failed', { error: serializeError(error) });
1242
- });
1034
+ })));
1243
1035
  store?.close();
1244
1036
  processLock.release();
1245
1037
  process.exit(0);
@@ -1255,7 +1047,6 @@ async function runServeCli() {
1255
1047
  await activeWeixinCore?.stop().catch(() => { });
1256
1048
  await Promise.allSettled(activeTelegramAdapters.map((adapter) => adapter.stop()));
1257
1049
  await activeOpencodeRuntime?.stop().catch(() => { });
1258
- await Promise.allSettled(activeAntigravityRuntimes.map((runtime) => runtime.stop()));
1259
1050
  await Promise.allSettled(managedApps.map((app) => app.stop({ terminateServer: true })));
1260
1051
  store?.close();
1261
1052
  processLock.release();
@@ -1716,15 +1507,22 @@ async function configureEnvInteractively(envPath, existed) {
1716
1507
  const skipped = [];
1717
1508
  const warnings = [];
1718
1509
  Object.assign(updates, await maybeSaveProxyEnvFromShell(rl, envPath));
1719
- const tokens = sanitizeEnvInput(await rl.question('Telegram bot token(s), comma-separated (TG_BOT_TOKENS): '));
1720
- if (tokens) {
1721
- updates.TG_BOT_TOKENS = tokens;
1722
- if (tokens.split(',').map((token) => token.trim()).some((token) => !/^\d+:[A-Za-z0-9_-]+$/.test(token))) {
1723
- warnings.push('One or more TG_BOT_TOKENS values do not look like standard Telegram bot tokens.');
1510
+ const codexTokens = sanitizeEnvInput(await rl.question('Codex Telegram bot token(s), comma-separated (CODEX_BOT_TOKENS): '));
1511
+ if (codexTokens) {
1512
+ updates.CODEX_BOT_TOKENS = codexTokens;
1513
+ if (codexTokens.split(',').map((token) => token.trim()).some((token) => !/^\d+:[A-Za-z0-9_-]+$/.test(token))) {
1514
+ warnings.push('One or more CODEX_BOT_TOKENS values do not look like standard Telegram bot tokens.');
1724
1515
  }
1725
1516
  }
1726
1517
  else {
1727
- skipped.push('TG_BOT_TOKENS');
1518
+ skipped.push('CODEX_BOT_TOKENS');
1519
+ }
1520
+ const agyTokens = sanitizeEnvInput(await rl.question('Antigravity Telegram bot token(s), comma-separated (ANTIGRAVITY_BOT_TOKENS) [optional]: '));
1521
+ if (agyTokens) {
1522
+ updates.ANTIGRAVITY_BOT_TOKENS = agyTokens;
1523
+ if (agyTokens.split(',').map((token) => token.trim()).some((token) => !/^\d+:[A-Za-z0-9_-]+$/.test(token))) {
1524
+ warnings.push('One or more ANTIGRAVITY_BOT_TOKENS values do not look like standard Telegram bot tokens.');
1525
+ }
1728
1526
  }
1729
1527
  const userId = sanitizeEnvInput(await rl.question('Telegram numeric user ID (TG_ALLOWED_USER_ID): '));
1730
1528
  if (userId) {
@@ -1935,32 +1733,29 @@ function stopService() {
1935
1733
  }
1936
1734
  function runDoctorChecks() {
1937
1735
  const configuredCodexBin = process.env.CODEX_CLI_BIN;
1736
+ const codexTokens = [
1737
+ ...(process.env.CODEX_BOT_TOKENS ?? '').split(','),
1738
+ process.env.CODEX_BOT_TOKEN ?? '',
1739
+ ].map((value) => value.trim()).filter(Boolean);
1740
+ const antigravityTokens = [
1741
+ ...(process.env.ANTIGRAVITY_BOT_TOKENS ?? '').split(','),
1742
+ process.env.ANTIGRAVITY_BOT_TOKEN ?? '',
1743
+ ].map((value) => value.trim()).filter(Boolean);
1938
1744
  const checks = [
1939
1745
  ['node >= 24', Number(process.versions.node.split('.')[0]) >= 24],
1940
1746
  ['codex cli available', hasConfiguredCommand(configuredCodexBin, 'codex')],
1941
- ['telegram bot token(s) configured', Boolean(process.env.TG_BOT_TOKENS?.trim() || process.env.TG_BOT_TOKEN?.trim())],
1747
+ ['telegram bot token(s) configured', codexTokens.length > 0 || antigravityTokens.length > 0],
1942
1748
  ['telegram allowed user configured', Boolean(process.env.TG_ALLOWED_USER_ID)],
1943
1749
  ];
1944
1750
  if (process.env.OPENCODE_BOT_TOKEN?.trim()) {
1945
1751
  const configuredOpencodeBin = process.env.OPENCODE_CLI_BIN;
1946
1752
  checks.push(['opencode cli available', hasConfiguredCommand(configuredOpencodeBin, 'opencode')]);
1947
- const codexTokens = [
1948
- ...(process.env.TG_BOT_TOKENS ?? '').split(','),
1949
- process.env.TG_BOT_TOKEN ?? '',
1950
- ].map((value) => value.trim()).filter(Boolean);
1951
- checks.push(['OpenCode uses an independent Telegram bot', !codexTokens.includes(process.env.OPENCODE_BOT_TOKEN.trim())]);
1753
+ const allOtherTokens = [...codexTokens, ...antigravityTokens];
1754
+ checks.push(['OpenCode uses an independent Telegram bot', !allOtherTokens.includes(process.env.OPENCODE_BOT_TOKEN.trim())]);
1952
1755
  }
1953
- const antigravityTokens = [
1954
- ...(process.env.ANTIGRAVITY_BOT_TOKENS ?? '').split(','),
1955
- process.env.ANTIGRAVITY_BOT_TOKEN ?? '',
1956
- ].map((value) => value.trim()).filter(Boolean);
1957
1756
  if (antigravityTokens.length > 0) {
1958
1757
  const configuredAgyBin = process.env.ANTIGRAVITY_CLI_BIN;
1959
1758
  checks.push(['antigravity (agy) cli available', hasConfiguredCommand(configuredAgyBin, 'agy')]);
1960
- const codexTokens = [
1961
- ...(process.env.TG_BOT_TOKENS ?? '').split(','),
1962
- process.env.TG_BOT_TOKEN ?? '',
1963
- ].map((value) => value.trim()).filter(Boolean);
1964
1759
  const hasConflict = antigravityTokens.some((t) => codexTokens.includes(t));
1965
1760
  checks.push(['Antigravity uses independent Telegram bot(s)', !hasConflict]);
1966
1761
  const authDir = process.env.ANTIGRAVITY_AUTH_DIR || path.join(os.homedir(), '.gemini', 'antigravity-cli');