@foxden-app/foxclaw 0.3.18 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.env.example +8 -3
  2. package/README.md +17 -7
  3. package/README_EN.md +17 -7
  4. package/dist/auth/mirror.d.ts +38 -0
  5. package/dist/auth/mirror.js +259 -0
  6. package/dist/codex_app/client.d.ts +4 -1
  7. package/dist/codex_app/client.js +23 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +18 -1
  10. package/dist/controller/controller.d.ts +24 -2
  11. package/dist/controller/controller.js +161 -35
  12. package/dist/core/bridge_scope.d.ts +5 -2
  13. package/dist/core/bridge_scope.js +7 -3
  14. package/dist/i18n.d.ts +20 -2
  15. package/dist/i18n.js +20 -2
  16. package/dist/main.js +198 -10
  17. package/dist/store/database.d.ts +4 -2
  18. package/dist/store/database.js +42 -6
  19. package/dist/telegram/addressing.d.ts +1 -0
  20. package/dist/telegram/addressing.js +3 -0
  21. package/dist/telegram/gateway.d.ts +4 -1
  22. package/dist/telegram/gateway.js +23 -5
  23. package/dist/types.d.ts +26 -0
  24. package/dist/update.d.ts +5 -0
  25. package/dist/update.js +93 -5
  26. package/docs/agent-assisted-install.md +7 -6
  27. package/docs/install-for-beginners.md +12 -4
  28. package/docs/troubleshooting.md +13 -1
  29. package/docs/user-manual.md +12 -12
  30. package/docs/zh/agent-assisted-install.md +7 -6
  31. package/docs/zh/foxclaw-skill.md +4 -2
  32. package/docs/zh/install-for-beginners.md +12 -4
  33. package/docs/zh/troubleshooting.md +13 -1
  34. package/docs/zh/user-manual.md +12 -12
  35. package/package.json +1 -1
  36. package/skills/foxclaw/SKILL.md +28 -20
  37. package/skills/foxclaw/references/telegram-setup.md +9 -6
  38. package/skills/foxclaw/scripts/bootstrap_host.py +11 -8
  39. package/skills/foxclaw/scripts/bootstrap_remote.py +8 -4
@@ -5,7 +5,19 @@ 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
+ export interface CoreCoordinator {
10
+ canSelfUpdate?: () => boolean;
11
+ authCandidateUpdated?: (runtimeId: string, candidateName: string) => Promise<void>;
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;
20
+ }
9
21
  export declare class BridgeSessionCore {
10
22
  private readonly config;
11
23
  private readonly store;
@@ -13,6 +25,8 @@ export declare class BridgeSessionCore {
13
25
  private readonly bot;
14
26
  private readonly app;
15
27
  private readonly selfUpdater;
28
+ private readonly coordinator;
29
+ private readonly ownsTelegramRuntime;
16
30
  private activeTurns;
17
31
  private activeTurnsByTurnId;
18
32
  private observedThreadWatchers;
@@ -51,7 +65,7 @@ export declare class BridgeSessionCore {
51
65
  /** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
52
66
  private threadListPresentationState;
53
67
  private readonly messaging;
54
- constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter, selfUpdater?: SelfUpdateRuntime | null);
68
+ constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter, selfUpdater?: SelfUpdateRuntime | null, coordinator?: CoreCoordinator | null, ownsTelegramRuntime?: boolean);
55
69
  /** Wire Telegram inbound events. Call before {@link startCodexApp}. */
56
70
  registerTelegramInboundHandlers(): void;
57
71
  /**
@@ -140,6 +154,12 @@ export declare class BridgeSessionCore {
140
154
  private stageAttachments;
141
155
  private registerActiveTurn;
142
156
  private createActiveTurnState;
157
+ getCurrentAuthLabel(): Promise<string | null>;
158
+ isIdleForServiceUpdate(): boolean;
159
+ private hasLocalBlockingActivity;
160
+ private authRuntimeId;
161
+ private authDisplayBotLabel;
162
+ private ownsScope;
143
163
  private setActiveTurn;
144
164
  private getActiveTurn;
145
165
  private getActiveTurnsForTurn;
@@ -261,6 +281,7 @@ export declare class BridgeSessionCore {
261
281
  private retryTurnAfterAuthRotation;
262
282
  private selectNextCodexAuthCandidate;
263
283
  private listCodexAuthState;
284
+ private resolveAuthDir;
264
285
  private readCodexAuthSwitchLabels;
265
286
  private codexAuthSwitchParams;
266
287
  private switchCodexAuthAndRestart;
@@ -277,6 +298,7 @@ export declare class BridgeSessionCore {
277
298
  private readCodexAuthQuotaSnapshots;
278
299
  private writeCodexAuthQuotaSnapshots;
279
300
  private codexAuthQuotaSnapshotPath;
301
+ private runtimeSnapshotFilename;
280
302
  private sendThreadContextSummary;
281
303
  private handleModelCommand;
282
304
  private handleEffortCommand;
@@ -93,6 +93,8 @@ export class BridgeSessionCore {
93
93
  bot;
94
94
  app;
95
95
  selfUpdater;
96
+ coordinator;
97
+ ownsTelegramRuntime;
96
98
  activeTurns = new Map();
97
99
  activeTurnsByTurnId = new Map();
98
100
  observedThreadWatchers = new Map();
@@ -131,13 +133,15 @@ export class BridgeSessionCore {
131
133
  /** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
132
134
  threadListPresentationState = new Map();
133
135
  messaging;
134
- constructor(config, store, logger, bot, app, outbound, selfUpdater = null) {
136
+ constructor(config, store, logger, bot, app, outbound, selfUpdater = null, coordinator = null, ownsTelegramRuntime = true) {
135
137
  this.config = config;
136
138
  this.store = store;
137
139
  this.logger = logger;
138
140
  this.bot = bot;
139
141
  this.app = app;
140
142
  this.selfUpdater = selfUpdater;
143
+ this.coordinator = coordinator;
144
+ this.ownsTelegramRuntime = ownsTelegramRuntime;
141
145
  this.messaging = outbound;
142
146
  }
143
147
  /** Wire Telegram inbound events. Call before {@link startCodexApp}. */
@@ -196,13 +200,13 @@ export class BridgeSessionCore {
196
200
  this.logger.warn('codex.local_usage_background_refresh_failed', { error: formatUserError(error) });
197
201
  });
198
202
  this.updateStatus();
203
+ this.scheduleSelfUpdateStatusPoll(0);
199
204
  }
200
205
  /** Begin Telegram Bot API long-polling after handlers and Codex are ready. */
201
206
  async startTelegramPolling() {
202
207
  await this.bot.start();
203
208
  this.botUsername = this.bot.username;
204
209
  this.updateStatus();
205
- this.scheduleSelfUpdateStatusPoll(0);
206
210
  }
207
211
  /** Telegram-only default startup (single channel). */
208
212
  async start() {
@@ -256,7 +260,7 @@ export class BridgeSessionCore {
256
260
  lastError: this.lastError,
257
261
  updatedAt: new Date().toISOString(),
258
262
  channels: {
259
- telegram: true,
263
+ telegram: this.ownsTelegramRuntime,
260
264
  weixin: Boolean(this.config.wxEnabled && this.messaging.hasWeixinTransport),
261
265
  },
262
266
  };
@@ -323,6 +327,7 @@ export class BridgeSessionCore {
323
327
  allowedChatId: this.config.tgAllowedChatId,
324
328
  allowedTopicId: this.config.tgAllowedTopicId,
325
329
  topicId: event.topicId,
330
+ requireExplicitGroupAddressing: this.config.tgRequireExplicitGroupAddressing,
326
331
  }),
327
332
  replyToBot: event.replyToBot,
328
333
  });
@@ -357,10 +362,13 @@ export class BridgeSessionCore {
357
362
  const binding = this.store.getBinding(scopeId);
358
363
  const settings = this.store.getChatSettings(scopeId);
359
364
  const access = this.resolveEffectiveAccess(scopeId, settings);
360
- const [fastStatus, codexUsageLines, codexLocalUsageLines] = await Promise.all([
365
+ const [fastStatus, codexUsageLines, codexLocalUsageLines, serviceStatus] = await Promise.all([
361
366
  this.resolveFastStatusLabel(locale, settings),
362
367
  this.buildCodexUsageStatusLines(locale),
363
368
  this.buildCodexLocalUsageStatusLines(locale),
369
+ this.config.tgMultiBotMode
370
+ ? (this.coordinator?.getServiceStatus?.() ?? Promise.resolve(null))
371
+ : Promise.resolve(null),
364
372
  ]);
365
373
  const appServer = this.app.getServerStatus();
366
374
  const appServerLabel = appServer.pid && appServer.port
@@ -389,6 +397,43 @@ export class BridgeSessionCore {
389
397
  t(locale, 'status_pending_user_inputs', { value: this.store.countPendingUserInputs() }),
390
398
  t(locale, 'status_active_turns', { value: this.activeTurns.size }),
391
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
+ }
392
437
  lines.push(...codexUsageLines);
393
438
  lines.push(...codexLocalUsageLines);
394
439
  await this.sendMessage(scopeId, lines.join('\n'));
@@ -1563,6 +1608,16 @@ export class BridgeSessionCore {
1563
1608
  return;
1564
1609
  }
1565
1610
  const lines = [t(locale, 'auth_add_done', { value: pendingAuthAdd.name })];
1611
+ try {
1612
+ await this.coordinator?.authCandidateUpdated?.(this.authRuntimeId(), pendingAuthAdd.name);
1613
+ }
1614
+ catch (error) {
1615
+ this.logger.warn('codex.auth_candidate_sync_failed', {
1616
+ candidate: pendingAuthAdd.name,
1617
+ runtimeId: this.authRuntimeId(),
1618
+ error: toErrorMeta(error),
1619
+ });
1620
+ }
1566
1621
  lines.push(...await this.buildCodexUsageStatusLines(locale));
1567
1622
  await this.sendMessage(scopeId, lines.join('\n'));
1568
1623
  return;
@@ -1572,7 +1627,7 @@ export class BridgeSessionCore {
1572
1627
  : t(locale, 'login_failed', { error: params?.error ?? t(locale, 'unknown') }));
1573
1628
  }
1574
1629
  async restorePendingAuthAdd(record) {
1575
- const state = await listCodexAuthState();
1630
+ const state = await this.listCodexAuthState();
1576
1631
  await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, record.previousTargetPath);
1577
1632
  }
1578
1633
  async restoreAuthAfterAddFailure(authDir, authPath, previousTargetPath) {
@@ -1849,6 +1904,9 @@ export class BridgeSessionCore {
1849
1904
  }
1850
1905
  async restorePendingUserInputs() {
1851
1906
  for (const stored of this.store.listPendingUserInputs()) {
1907
+ if (!this.ownsScope(stored.chatId)) {
1908
+ continue;
1909
+ }
1852
1910
  const record = parseStoredPendingUserInput(stored);
1853
1911
  if (!record) {
1854
1912
  this.store.markPendingUserInputResolved(stored.localId);
@@ -2535,6 +2593,45 @@ export class BridgeSessionCore {
2535
2593
  resolver,
2536
2594
  };
2537
2595
  }
2596
+ async getCurrentAuthLabel() {
2597
+ return (await this.listCodexAuthState()).currentLabel;
2598
+ }
2599
+ isIdleForServiceUpdate() {
2600
+ return this.activeTurns.size === 0
2601
+ && this.pendingApprovalMessages.size === 0
2602
+ && this.pendingUserInputs.size === 0
2603
+ && this.pendingMcpElicitations.size === 0
2604
+ && this.pendingLoginsByScope.size === 0
2605
+ && !this.authRotationInProgress;
2606
+ }
2607
+ hasLocalBlockingActivity() {
2608
+ return !this.isIdleForServiceUpdate();
2609
+ }
2610
+ authRuntimeId() {
2611
+ return this.config.tgScopeBotId ?? 'default';
2612
+ }
2613
+ authDisplayBotLabel() {
2614
+ if (!this.config.tgScopeBotId)
2615
+ return null;
2616
+ return this.botUsername ? `@${this.botUsername}` : this.config.tgScopeBotId;
2617
+ }
2618
+ ownsScope(scopeId) {
2619
+ if (scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)) {
2620
+ return this.messaging.hasWeixinTransport;
2621
+ }
2622
+ if (!this.ownsTelegramRuntime) {
2623
+ return false;
2624
+ }
2625
+ if (!this.config.tgScopeBotId) {
2626
+ return parseTelegramTargetFromBridgeScope(scopeId).botId === null;
2627
+ }
2628
+ try {
2629
+ return parseTelegramTargetFromBridgeScope(scopeId).botId === this.config.tgScopeBotId;
2630
+ }
2631
+ catch {
2632
+ return false;
2633
+ }
2634
+ }
2538
2635
  setActiveTurn(scopeId, turnId, active) {
2539
2636
  const key = activeTurnKey(scopeId, turnId);
2540
2637
  this.activeTurns.set(key, active);
@@ -2785,7 +2882,7 @@ export class BridgeSessionCore {
2785
2882
  }
2786
2883
  }
2787
2884
  for (const scopeId of this.store.findAllChatIdsByThreadId(threadId)) {
2788
- if (!this.messaging.canSendToScope(scopeId)) {
2885
+ if (!this.ownsScope(scopeId) || !this.messaging.canSendToScope(scopeId)) {
2789
2886
  continue;
2790
2887
  }
2791
2888
  scopes.add(scopeId);
@@ -2818,7 +2915,12 @@ export class BridgeSessionCore {
2818
2915
  return next;
2819
2916
  }
2820
2917
  updateStatus() {
2821
- writeRuntimeStatus(this.config.statusPath, this.getRuntimeStatus());
2918
+ const status = this.getRuntimeStatus();
2919
+ if (this.coordinator?.statusUpdated) {
2920
+ this.coordinator.statusUpdated(status);
2921
+ return;
2922
+ }
2923
+ writeRuntimeStatus(this.config.statusPath, status);
2822
2924
  }
2823
2925
  async sendMessage(scopeId, text, inlineKeyboard) {
2824
2926
  return this.messaging.sendPlain(scopeId, text, inlineKeyboard);
@@ -3653,7 +3755,7 @@ export class BridgeSessionCore {
3653
3755
  ].join('\n'));
3654
3756
  }
3655
3757
  async handleAuthReloadCommand(scopeId, locale) {
3656
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
3758
+ if (this.hasLocalBlockingActivity()) {
3657
3759
  await this.sendMessage(scopeId, t(locale, 'auth_reload_blocked_active'));
3658
3760
  return;
3659
3761
  }
@@ -3670,7 +3772,7 @@ export class BridgeSessionCore {
3670
3772
  await this.sendMessage(scopeId, t(locale, 'update_unavailable'));
3671
3773
  return;
3672
3774
  }
3673
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
3775
+ if (!this.isIdleForServiceUpdate() || this.store.countPendingApprovals() > 0 || this.store.countPendingUserInputs() > 0 || (this.coordinator?.canSelfUpdate && !this.coordinator.canSelfUpdate())) {
3674
3776
  await this.sendMessage(scopeId, t(locale, 'update_blocked_active'));
3675
3777
  return;
3676
3778
  }
@@ -3721,17 +3823,24 @@ export class BridgeSessionCore {
3721
3823
  this.scheduleSelfUpdateStatusPoll();
3722
3824
  return;
3723
3825
  }
3826
+ if (!this.ownsScope(status.scopeId)) {
3827
+ this.scheduleSelfUpdateStatusPoll();
3828
+ return;
3829
+ }
3830
+ this.coordinator?.selfUpdateCompleted?.(status);
3724
3831
  await this.sendMessage(status.scopeId, this.formatSelfUpdateResult(status));
3725
3832
  await this.selfUpdater?.clearStatus();
3726
3833
  }
3727
3834
  formatSelfUpdateResult(status) {
3728
3835
  if (status.state === 'succeeded') {
3729
- return t(status.locale, 'update_succeeded', {
3836
+ const result = t(status.locale, 'update_succeeded', {
3730
3837
  from: status.fromVersion,
3731
3838
  to: status.toVersion ?? t(status.locale, 'unknown'),
3732
3839
  });
3840
+ return status.codexUpdate ? `${result}\n${status.codexUpdate}` : result;
3733
3841
  }
3734
- return t(status.locale, 'update_failed', { error: status.error ?? t(status.locale, 'unknown') });
3842
+ const result = t(status.locale, 'update_failed', { error: status.error ?? t(status.locale, 'unknown') });
3843
+ return status.codexUpdate ? `${result}\n${status.codexUpdate}` : result;
3735
3844
  }
3736
3845
  async handleAuthCommand(scopeId, locale, args) {
3737
3846
  const action = args[0]?.toLowerCase() ?? 'list';
@@ -3765,11 +3874,11 @@ export class BridgeSessionCore {
3765
3874
  createdAt: Date.now(),
3766
3875
  };
3767
3876
  this.pendingAuthChoiceLists.set(record.localId, record);
3768
- 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));
3769
3878
  record.messageId = messageId;
3770
3879
  }
3771
3880
  async handleAuthUseCommand(scopeId, locale, args) {
3772
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
3881
+ if (this.hasLocalBlockingActivity()) {
3773
3882
  await this.sendMessage(scopeId, t(locale, 'auth_reload_blocked_active'));
3774
3883
  return;
3775
3884
  }
@@ -3787,7 +3896,7 @@ export class BridgeSessionCore {
3787
3896
  if (!candidate) {
3788
3897
  await this.sendMessage(scopeId, t(locale, 'auth_choice_expired'));
3789
3898
  const state = await this.listCodexAuthState();
3790
- await this.sendMessage(scopeId, renderAuthListMessage(locale, state, parseWeixinBridgeScope(scopeId) !== null));
3899
+ await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null));
3791
3900
  return;
3792
3901
  }
3793
3902
  const switchLabels = await this.readCodexAuthSwitchLabels(candidate);
@@ -3804,10 +3913,10 @@ export class BridgeSessionCore {
3804
3913
  const candidate = state.candidates[index - 1];
3805
3914
  if (!candidate) {
3806
3915
  await this.sendMessage(scopeId, t(locale, 'auth_choice_expired'));
3807
- await this.sendMessage(scopeId, renderAuthListMessage(locale, state, parseWeixinBridgeScope(scopeId) !== null));
3916
+ await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null));
3808
3917
  return;
3809
3918
  }
3810
- this.store.setCodexAuthCandidateDisabled(candidate.name, disabled);
3919
+ this.store.setCodexAuthCandidateDisabled(candidate.name, disabled, this.authRuntimeId());
3811
3920
  await this.sendMessage(scopeId, t(locale, disabled ? 'auth_candidate_disabled' : 'auth_candidate_enabled', {
3812
3921
  value: candidate.name,
3813
3922
  }));
@@ -3825,7 +3934,7 @@ export class BridgeSessionCore {
3825
3934
  return latest;
3826
3935
  }
3827
3936
  async handleAuthAddCommand(scopeId, locale, args) {
3828
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
3937
+ if (this.hasLocalBlockingActivity()) {
3829
3938
  await this.sendMessage(scopeId, t(locale, 'auth_reload_blocked_active'));
3830
3939
  return;
3831
3940
  }
@@ -4259,12 +4368,12 @@ export class BridgeSessionCore {
4259
4368
  return;
4260
4369
  }
4261
4370
  const disabled = !candidate.disabled;
4262
- this.store.setCodexAuthCandidateDisabled(candidate.name, disabled);
4371
+ this.store.setCodexAuthCandidateDisabled(candidate.name, disabled, this.authRuntimeId());
4263
4372
  const state = await this.listCodexAuthState();
4264
4373
  record.candidates = state.candidates;
4265
4374
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, disabled ? 'auth_candidate_disabled_short' : 'auth_candidate_enabled_short'));
4266
4375
  if (record.messageId !== null) {
4267
- 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));
4268
4377
  }
4269
4378
  }
4270
4379
  async handleAuthSwitchCallback(event, localId, index, locale) {
@@ -4277,7 +4386,7 @@ export class BridgeSessionCore {
4277
4386
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_choice_mismatch'));
4278
4387
  return;
4279
4388
  }
4280
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
4389
+ if (this.hasLocalBlockingActivity()) {
4281
4390
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_reload_blocked_active'));
4282
4391
  return;
4283
4392
  }
@@ -4299,7 +4408,7 @@ export class BridgeSessionCore {
4299
4408
  if (!this.pendingAuthRotation || this.authRotationInProgress) {
4300
4409
  return false;
4301
4410
  }
4302
- if (this.activeTurns.size > 0 || this.store.countPendingApprovals() > 0 || this.pendingUserInputs.size > 0 || this.pendingMcpElicitations.size > 0) {
4411
+ if (this.hasLocalBlockingActivity()) {
4303
4412
  return false;
4304
4413
  }
4305
4414
  const rotation = this.pendingAuthRotation;
@@ -4398,13 +4507,19 @@ export class BridgeSessionCore {
4398
4507
  return candidate ? { candidate, fromLabel: state.currentLabel, toLabel: await authPathDisplayLabel(candidate.path) } : null;
4399
4508
  }
4400
4509
  async listCodexAuthState() {
4401
- const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames());
4510
+ const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.resolveAuthDir());
4402
4511
  const snapshots = await this.readCodexAuthQuotaSnapshots();
4403
4512
  state.candidates.forEach((candidate) => {
4404
4513
  candidate.quota = snapshots[candidate.name] ?? null;
4405
4514
  });
4406
4515
  return state;
4407
4516
  }
4517
+ resolveAuthDir() {
4518
+ return this.config.codexAuthDir
4519
+ ?? (this.config.tgMultiBotMode ? this.config.codexHome : null)
4520
+ ?? process.env.CODEX_AUTH_DIR
4521
+ ?? path.join(os.homedir(), '.codex');
4522
+ }
4408
4523
  async readCodexAuthSwitchLabels(candidate) {
4409
4524
  const state = await this.listCodexAuthState();
4410
4525
  return {
@@ -4419,7 +4534,7 @@ export class BridgeSessionCore {
4419
4534
  };
4420
4535
  }
4421
4536
  async switchCodexAuthAndRestart(scopeId, locale, candidate, automatic) {
4422
- const result = await switchCodexAuth(candidate.path);
4537
+ const result = await switchCodexAuth(candidate.path, this.resolveAuthDir());
4423
4538
  this.authRotationFailedTargets.delete(candidate.path);
4424
4539
  this.pendingTurnErrors.clear();
4425
4540
  this.attachedThreads.clear();
@@ -4610,14 +4725,14 @@ export class BridgeSessionCore {
4610
4725
  await this.localUsageRefresh;
4611
4726
  }
4612
4727
  async refreshCodexLocalUsageStats() {
4613
- const stats = await readCodexLocalUsageStats();
4728
+ const stats = await readCodexLocalUsageStats(this.config.codexHome ?? undefined);
4614
4729
  const snapshot = { computedAtMs: Date.now(), stats };
4615
4730
  this.localUsageCache = snapshot;
4616
4731
  this.localUsageCacheLoaded = true;
4617
4732
  await writeCodexLocalUsageSnapshot(this.codexLocalUsageSnapshotPath(), snapshot);
4618
4733
  }
4619
4734
  codexLocalUsageSnapshotPath() {
4620
- return path.join(path.dirname(this.config.statusPath), CODEX_LOCAL_USAGE_SNAPSHOT_FILENAME);
4735
+ return path.join(path.dirname(this.config.statusPath), this.runtimeSnapshotFilename(CODEX_LOCAL_USAGE_SNAPSHOT_FILENAME));
4621
4736
  }
4622
4737
  async refreshCurrentCodexAuthQuota(state) {
4623
4738
  const candidate = state.candidates.find(entry => entry.isCurrent);
@@ -4669,7 +4784,13 @@ export class BridgeSessionCore {
4669
4784
  await fs.rename(temporaryPath, snapshotPath);
4670
4785
  }
4671
4786
  codexAuthQuotaSnapshotPath() {
4672
- return path.join(path.dirname(this.config.statusPath), CODEX_AUTH_QUOTA_SNAPSHOT_FILENAME);
4787
+ return path.join(path.dirname(this.config.statusPath), this.runtimeSnapshotFilename(CODEX_AUTH_QUOTA_SNAPSHOT_FILENAME));
4788
+ }
4789
+ runtimeSnapshotFilename(filename) {
4790
+ if (!this.config.tgScopeBotId) {
4791
+ return filename;
4792
+ }
4793
+ return filename.replace(/\.json$/, `-${this.config.tgScopeBotId}.json`);
4673
4794
  }
4674
4795
  async sendThreadContextSummary(scopeId, locale, threadId) {
4675
4796
  try {
@@ -5588,6 +5709,9 @@ export class BridgeSessionCore {
5588
5709
  }
5589
5710
  async cleanupStaleTurnPreviews() {
5590
5711
  for (const preview of this.store.listActiveTurnPreviews()) {
5712
+ if (!this.ownsScope(preview.scopeId)) {
5713
+ continue;
5714
+ }
5591
5715
  if (!this.messaging.canSendToScope(preview.scopeId)) {
5592
5716
  this.store.removeActiveTurnPreview(preview.turnId);
5593
5717
  this.logger.info('telegram.preview_dropped_disabled_channel', {
@@ -7119,11 +7243,11 @@ function parseActiveTurnKey(key) {
7119
7243
  turnId: decodeURIComponent(key.slice(split + 1)),
7120
7244
  };
7121
7245
  }
7122
- function codexAuthDir() {
7123
- return process.env.CODEX_AUTH_DIR || path.join(os.homedir(), '.codex');
7246
+ function codexAuthDir(explicitAuthDir = null) {
7247
+ return explicitAuthDir || process.env.CODEX_AUTH_DIR || path.join(os.homedir(), '.codex');
7124
7248
  }
7125
- async function listCodexAuthState(disabledNames = new Set()) {
7126
- const authDir = codexAuthDir();
7249
+ async function listCodexAuthState(disabledNames = new Set(), explicitAuthDir = null) {
7250
+ const authDir = codexAuthDir(explicitAuthDir);
7127
7251
  const authPath = path.join(authDir, 'auth.json');
7128
7252
  const currentTargetPath = await resolveCurrentAuthTarget(authDir, authPath);
7129
7253
  const candidates = [];
@@ -7222,8 +7346,8 @@ async function pointCodexAuthAtTarget(authDir, authPath, targetPath) {
7222
7346
  throw error;
7223
7347
  }
7224
7348
  }
7225
- async function switchCodexAuth(targetPath) {
7226
- const state = await listCodexAuthState();
7349
+ async function switchCodexAuth(targetPath, explicitAuthDir = null) {
7350
+ const state = await listCodexAuthState(new Set(), explicitAuthDir);
7227
7351
  const candidate = state.candidates.find(entry => entry.path === targetPath);
7228
7352
  if (!candidate) {
7229
7353
  throw new Error(`Auth candidate is no longer available: ${path.basename(targetPath)}`);
@@ -7234,12 +7358,14 @@ async function switchCodexAuth(targetPath) {
7234
7358
  toLabel: await authPathDisplayLabel(candidate.path),
7235
7359
  };
7236
7360
  }
7237
- function renderAuthListMessage(locale, state, includeWeixinCopyPaste = false) {
7361
+ function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopyPaste = false) {
7238
7362
  const lines = [
7239
7363
  t(locale, 'auth_list_title'),
7240
- t(locale, 'auth_current', { value: state.currentLabel ?? t(locale, 'none') }),
7241
- t(locale, 'auth_dir', { value: state.authDir }),
7242
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 }));
7243
7369
  if (state.candidates.length === 0) {
7244
7370
  lines.push(t(locale, 'auth_no_candidates'));
7245
7371
  if (includeWeixinCopyPaste) {
@@ -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/i18n.d.ts CHANGED
@@ -118,18 +118,27 @@ declare const MESSAGES: {
118
118
  readonly status_codex_local_usage_unavailable: "Codex local history: unavailable ({error})";
119
119
  readonly status_codex_credits: "Codex credits: {value}";
120
120
  readonly status_codex_limit_reached: "Codex limit: {value}";
121
+ readonly status_runtime_overview: "Telegram bot runtimes:";
122
+ readonly status_runtime_bot: "- {bot}: connected {connected}, auth {auth}, active turns {turns}";
123
+ readonly status_runtime_weixin: "- Weixin default runtime: connected {connected}, active turns {turns}";
124
+ readonly status_auth_mirror_none: "Last auth mirror: none recorded";
125
+ readonly status_auth_mirror_synced: "Last auth mirror: {candidate} from {source} at {time}";
126
+ readonly status_last_update_none: "Last service update: none recorded";
127
+ readonly status_last_update: "Last service update: {from} -> {to} at {time}";
128
+ readonly status_last_codex_update: "Last Codex update: {value}";
121
129
  readonly update_started: "FoxClaw update started. I will report here after installation, checks, and service restart complete.";
122
130
  readonly update_succeeded: "FoxClaw updated and restarted: {from} -> {to}.";
123
131
  readonly update_failed: "FoxClaw update failed: {error}\nRun foxclaw update in a terminal for details.";
124
132
  readonly update_unavailable: "Self-update is unavailable in this runtime. Run foxclaw update in a terminal.";
125
133
  readonly update_already_running: "A FoxClaw update is already running. I will report here when it finishes.";
126
- readonly update_blocked_active: "Cannot update FoxClaw while a turn, approval, or question is active. Wait or use /interrupt first.";
134
+ readonly update_blocked_active: "Cannot update FoxClaw while a runtime activity or auth mirror write is active. Wait or use /interrupt first.";
127
135
  readonly auth_reload_restarting: "Restarting Codex app-server to reload auth...";
128
136
  readonly auth_reload_done: "Codex app-server restarted. Current auth has been reloaded.";
129
137
  readonly auth_reload_blocked_active: "Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.";
130
138
  readonly usage_auth: "Usage: /auth [list|use <n>|enable <n>|disable <n>|reload|add <name>]";
131
139
  readonly usage_auth_add: "Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.";
132
140
  readonly auth_list_title: "Codex auth files:";
141
+ readonly auth_bot: "Bot runtime: {value}";
133
142
  readonly auth_current: "Current auth: {value}";
134
143
  readonly auth_dir: "Auth dir: {value}";
135
144
  readonly auth_candidate_count: "Candidates: {value}";
@@ -682,18 +691,27 @@ declare const MESSAGES: {
682
691
  readonly status_codex_local_usage_unavailable: "Codex 本地历史:无法获取({error})";
683
692
  readonly status_codex_credits: "Codex 额度:{value}";
684
693
  readonly status_codex_limit_reached: "Codex 限制:{value}";
694
+ readonly status_runtime_overview: "Telegram bot 运行时:";
695
+ readonly status_runtime_bot: "- {bot}:连接 {connected},auth {auth},进行中回复 {turns}";
696
+ readonly status_runtime_weixin: "- 微信默认运行时:连接 {connected},进行中回复 {turns}";
697
+ readonly status_auth_mirror_none: "最近 auth 镜像:暂无记录";
698
+ readonly status_auth_mirror_synced: "最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步";
699
+ readonly status_last_update_none: "最近服务升级:暂无记录";
700
+ readonly status_last_update: "最近服务升级:{from} -> {to}({time})";
701
+ readonly status_last_codex_update: "最近 Codex 升级:{value}";
685
702
  readonly update_started: "已开始升级 FoxClaw。安装、自检和服务重启完成后,我会在这里回报结果。";
686
703
  readonly update_succeeded: "FoxClaw 已升级并重启:{from} -> {to}。";
687
704
  readonly update_failed: "FoxClaw 升级失败:{error}\n请在终端运行 foxclaw update 查看详情。";
688
705
  readonly update_unavailable: "当前运行方式不支持自升级,请在终端运行 foxclaw update。";
689
706
  readonly update_already_running: "FoxClaw 升级已经在进行中,结束后我会在这里回报结果。";
690
- readonly update_blocked_active: "当前有回复、审批或问题在进行中,不能升级 FoxClaw。请先等待,或使用 /interrupt。";
707
+ readonly update_blocked_active: "当前有 runtime 操作或 auth 镜像写入在进行中,不能升级 FoxClaw。请先等待,或使用 /interrupt。";
691
708
  readonly auth_reload_restarting: "正在重启 Codex app-server 以重新读取 auth...";
692
709
  readonly auth_reload_done: "Codex app-server 已重启,当前 auth 已重新读取。";
693
710
  readonly auth_reload_blocked_active: "当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。";
694
711
  readonly usage_auth: "用法:/auth [list|use <编号>|enable <编号>|disable <编号>|reload|add <名称>]";
695
712
  readonly usage_auth_add: "用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。";
696
713
  readonly auth_list_title: "Codex auth 文件:";
714
+ readonly auth_bot: "Bot runtime:{value}";
697
715
  readonly auth_current: "当前 auth:{value}";
698
716
  readonly auth_dir: "Auth 目录:{value}";
699
717
  readonly auth_candidate_count: "候选数量:{value}";