@llblab/pi-telegram 0.18.4 → 0.18.6

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/lib/polling.ts CHANGED
@@ -20,7 +20,7 @@ const TELEGRAM_INITIAL_SYNC_LIMIT = 1;
20
20
  const TELEGRAM_INITIAL_SYNC_TIMEOUT_SECONDS = 0;
21
21
  const TELEGRAM_LONG_POLL_LIMIT = 10;
22
22
  const TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 30;
23
- const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 5_000;
23
+ const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 2_500;
24
24
  const TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES = 2;
25
25
  const TELEGRAM_POLLING_DEFAULT_MAX_UPDATE_FAILURES = 3;
26
26
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
@@ -426,6 +426,12 @@ export async function probeTelegramStartupThreadCapability(
426
426
  return threadModeEnabled;
427
427
  }
428
428
 
429
+ function hasTelegramClassicRestoreFailure(
430
+ state: TelegramThreadCapabilityState,
431
+ ): boolean {
432
+ return state.lastReconcileAction?.endsWith("-classic-restore-failed") ?? false;
433
+ }
434
+
429
435
  function hasTelegramThreadCapabilityBindings(
430
436
  store: TelegramThreadCapabilityStore,
431
437
  ): boolean {
@@ -451,6 +457,7 @@ export async function applyTelegramThreadCapability<TContext>(
451
457
  await deps.topicTargetStore.load();
452
458
  if (!deps.isBusConfigured()) return;
453
459
  const nowMs = (deps.getNowMs ?? Date.now)();
460
+ const previousBotState = deps.topicTargetStore.getBotState();
454
461
  if (!threadModeEnabled) {
455
462
  if (
456
463
  hasTelegramThreadCapabilityBindings(deps.topicTargetStore) &&
@@ -470,11 +477,26 @@ export async function applyTelegramThreadCapability<TContext>(
470
477
  await deps.topicTargetStore.persist();
471
478
  deps.setTopicModeUnavailable(true);
472
479
  deps.stopFollowerRegistration();
473
- if (deps.getPollingStartedWithTelegramBus()) {
480
+ if (
481
+ deps.getPollingStartedWithTelegramBus() ||
482
+ hasTelegramClassicRestoreFailure(previousBotState)
483
+ ) {
474
484
  deps.stopLeaderHealth();
475
485
  await deps.stopBusPolling();
476
486
  deps.setPollingStartedWithTelegramBus(false);
477
- await deps.startClassicPolling(ctx);
487
+ try {
488
+ await deps.startClassicPolling(ctx);
489
+ } catch (classicError) {
490
+ deps.topicTargetStore.setBotState({
491
+ threadMode: "disabled",
492
+ updatedAtMs: (deps.getNowMs ?? Date.now)(),
493
+ lastReconcileAction: `${phase}-classic-restore-failed`,
494
+ });
495
+ await deps.topicTargetStore.persist();
496
+ deps.recordEvent("bus", classicError, {
497
+ phase: `${phase}-classic-restore`,
498
+ });
499
+ }
478
500
  }
479
501
  deps.updateStatus(ctx);
480
502
  return;
@@ -509,6 +531,12 @@ export async function applyTelegramThreadCapability<TContext>(
509
531
  try {
510
532
  await deps.startClassicPolling(ctx);
511
533
  } catch (classicError) {
534
+ deps.topicTargetStore.setBotState({
535
+ threadMode: "disabled",
536
+ updatedAtMs: (deps.getNowMs ?? Date.now)(),
537
+ lastReconcileAction: `${phase}-classic-restore-failed`,
538
+ });
539
+ await deps.topicTargetStore.persist();
512
540
  deps.recordEvent("bus", classicError, {
513
541
  phase: `${phase}-classic-restore`,
514
542
  });
@@ -583,6 +611,19 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
583
611
  ): Promise<boolean | undefined> => {
584
612
  await deps.topicTargetStore.load();
585
613
  if (deps.topicTargetStore.getBotState().threadMode !== "enabled") {
614
+ if (hasTelegramThreadCapabilityBindings(deps.topicTargetStore)) {
615
+ deps.recordEvent(
616
+ "bus",
617
+ "Telegram Threaded Mode disabled; follower takeover blocked",
618
+ {
619
+ phase: "follower-register-thread-mode-disabled",
620
+ reason: "active-thread-bindings-present",
621
+ },
622
+ );
623
+ throw new Error(
624
+ "Telegram Threaded Mode is disabled; the current leader remains the classic polling owner.",
625
+ );
626
+ }
586
627
  return undefined;
587
628
  }
588
629
  if (!deps.isBusRuntimeEnabled()) return undefined;
@@ -653,9 +694,21 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
653
694
  return;
654
695
  }
655
696
  if (threadModeEnabled) consecutiveDisabledProbes = 0;
656
- const current = deps.topicTargetStore.getBotState().threadMode;
697
+ const botState = deps.topicTargetStore.getBotState();
698
+ const current = botState.threadMode;
657
699
  if (threadModeEnabled && current === "enabled") return;
658
- if (!threadModeEnabled && current === "disabled") return;
700
+ if (!threadModeEnabled && current === "disabled") {
701
+ if (!deps.ownsLock(ctx) || !hasTelegramClassicRestoreFailure(botState)) {
702
+ return;
703
+ }
704
+ await applyTelegramThreadCapability(
705
+ ctx,
706
+ false,
707
+ "capability-monitor-disabled-confirmed",
708
+ deps,
709
+ );
710
+ return;
711
+ }
659
712
  if (
660
713
  !threadModeEnabled &&
661
714
  hasTelegramThreadCapabilityBindings(deps.topicTargetStore)
package/lib/preview.ts CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  } from "./target.ts";
13
13
  import { shouldSuppressPreviewForVoice } from "./voice.ts";
14
14
 
15
- const TELEGRAM_PREVIEW_THROTTLE_MS = 0;
16
15
  const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
17
16
  const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
18
17
 
@@ -26,7 +25,6 @@ export interface TelegramPreviewState {
26
25
  }
27
26
 
28
27
  export interface TelegramPreviewRuntimeState extends TelegramPreviewState {
29
- flushTimer?: ReturnType<typeof setTimeout>;
30
28
  flushPromise?: Promise<void>;
31
29
  flushRequested?: boolean;
32
30
  }
@@ -36,7 +34,6 @@ export type TelegramPreviewReplyMarkup = unknown;
36
34
  export interface TelegramPreviewRuntimeDeps {
37
35
  getState: () => TelegramPreviewRuntimeState | undefined;
38
36
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
39
- clearScheduledFlush: (state: TelegramPreviewRuntimeState) => void;
40
37
  maxMessageLength: number;
41
38
  getDraftSupport: () => TelegramDraftSupport;
42
39
  setDraftSupport: (support: TelegramDraftSupport) => void;
@@ -132,13 +129,7 @@ export interface TelegramPreviewControllerDeps {
132
129
  },
133
130
  ) => Promise<unknown>;
134
131
  canSend?: () => boolean;
135
- throttleMs?: number;
136
132
  maxDraftId?: number;
137
- setTimer?: (
138
- callback: () => void,
139
- ms: number,
140
- ) => ReturnType<typeof setTimeout>;
141
- clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
142
133
  recordRuntimeEvent?: (
143
134
  category: string,
144
135
  error: unknown,
@@ -154,7 +145,7 @@ export interface TelegramPreviewController {
154
145
  resetState: () => void;
155
146
  clear: (
156
147
  chatId: number,
157
- options?: { target?: TelegramTarget },
148
+ options?: { awaitFlush?: boolean; target?: TelegramTarget },
158
149
  ) => Promise<void>;
159
150
  flush: (
160
151
  chatId: number,
@@ -171,7 +162,8 @@ export interface TelegramPreviewController {
171
162
  ) => Promise<boolean>;
172
163
  }
173
164
 
174
- export type TelegramPreviewControllerRuntimeDeps = TelegramPreviewControllerDeps;
165
+ export type TelegramPreviewControllerRuntimeDeps =
166
+ TelegramPreviewControllerDeps;
175
167
 
176
168
  export function createTelegramPreviewControllerRuntime(
177
169
  deps: TelegramPreviewControllerRuntimeDeps,
@@ -181,10 +173,7 @@ export function createTelegramPreviewControllerRuntime(
181
173
  maxMessageLength: deps.maxMessageLength,
182
174
  initialDraftSupport: deps.initialDraftSupport,
183
175
  sendDraft: deps.sendDraft,
184
- throttleMs: deps.throttleMs,
185
176
  maxDraftId: deps.maxDraftId,
186
- setTimer: deps.setTimer,
187
- clearTimer: deps.clearTimer,
188
177
  recordRuntimeEvent: deps.recordRuntimeEvent,
189
178
  });
190
179
  }
@@ -223,7 +212,7 @@ export function createTelegramNativeMarkdownPreviewFinalizer<
223
212
  getState: () => TelegramPreviewRuntimeState | undefined;
224
213
  clear: (
225
214
  chatId: number,
226
- options?: { target?: TelegramTarget },
215
+ options?: { awaitFlush?: boolean; target?: TelegramTarget },
227
216
  ) => Promise<void>;
228
217
  discard?: () => void;
229
218
  sendMarkdownReply: (
@@ -283,12 +272,6 @@ export function createTelegramPreviewController(
283
272
  deps: TelegramPreviewControllerDeps,
284
273
  ): TelegramPreviewController {
285
274
  let state: TelegramPreviewRuntimeState | undefined;
286
- const clearTimer = deps.clearTimer ?? clearTimeout;
287
- const setTimer =
288
- deps.setTimer ??
289
- ((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
290
- setTimeout(callback, ms));
291
- const throttleMs = deps.throttleMs ?? TELEGRAM_PREVIEW_THROTTLE_MS;
292
275
  const maxDraftId = deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX;
293
276
  const maxMessageLength =
294
277
  deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS;
@@ -299,11 +282,6 @@ export function createTelegramPreviewController(
299
282
  setState: (nextState) => {
300
283
  state = nextState;
301
284
  },
302
- clearScheduledFlush: (nextState) => {
303
- if (!nextState.flushTimer) return;
304
- clearTimer(nextState.flushTimer);
305
- nextState.flushTimer = undefined;
306
- },
307
285
  maxMessageLength,
308
286
  getDraftSupport: () => draftSupport,
309
287
  setDraftSupport: (support) => {
@@ -334,15 +312,8 @@ export function createTelegramPreviewController(
334
312
  flush: (chatId, options) =>
335
313
  flushTelegramPreview(chatId, getRuntimeDeps(), options),
336
314
  scheduleFlush: (chatId, options) => {
337
- if (!state || state.flushTimer) return;
338
- if (throttleMs <= 0) {
339
- void flushTelegramPreview(chatId, getRuntimeDeps(), options);
340
- return;
341
- }
342
- state.flushTimer = setTimer(() => {
343
- void flushTelegramPreview(chatId, getRuntimeDeps(), options);
344
- }, throttleMs);
345
- state.flushTimer.unref?.();
315
+ if (!state) return;
316
+ void flushTelegramPreview(chatId, getRuntimeDeps(), options);
346
317
  },
347
318
  finalize: (chatId, _replyToMessageId, options) =>
348
319
  finalizeTelegramPreview(chatId, getRuntimeDeps(), options),
@@ -464,7 +435,6 @@ export async function clearTelegramPreview(
464
435
  ): Promise<void> {
465
436
  const state = deps.getState();
466
437
  if (!state) return;
467
- deps.clearScheduledFlush(state);
468
438
  if (state.flushPromise && options.awaitFlush !== false) {
469
439
  state.flushRequested = false;
470
440
  await state.flushPromise.catch(() => {});
@@ -699,6 +669,10 @@ function findSafeTelegramRichMarkdownDraftEnd(markdown: string): number {
699
669
  return safeEnd;
700
670
  }
701
671
 
672
+ function hasTelegramPreviewVisibleContent(markdown: string): boolean {
673
+ return /[\p{L}\p{N}]/u.test(markdown);
674
+ }
675
+
702
676
  export function getSafeTelegramRichMarkdownDraftPrefix(
703
677
  markdown: string,
704
678
  maxMessageLength: number,
@@ -710,13 +684,21 @@ export function getSafeTelegramRichMarkdownDraftPrefix(
710
684
  ? source.slice(0, maxMessageLength)
711
685
  : source;
712
686
  const safeEnd = findSafeTelegramRichMarkdownDraftEnd(limited);
713
- if (safeEnd > 0) return limited.slice(0, safeEnd).trimEnd() || undefined;
687
+ if (safeEnd > 0) {
688
+ const safePrefix = limited.slice(0, safeEnd).trimEnd();
689
+ return hasTelegramPreviewVisibleContent(safePrefix)
690
+ ? safePrefix
691
+ : undefined;
692
+ }
714
693
  let candidateEnd = limited.length;
715
694
  while (candidateEnd > 0) {
716
695
  candidateEnd = limited.lastIndexOf(" ", candidateEnd - 1);
717
696
  if (candidateEnd <= 0) return undefined;
718
697
  const candidate = limited.slice(0, candidateEnd).trimEnd();
719
- if (findSafeTelegramRichMarkdownDraftEnd(candidate) === candidate.length) {
698
+ if (
699
+ hasTelegramPreviewVisibleContent(candidate) &&
700
+ findSafeTelegramRichMarkdownDraftEnd(candidate) === candidate.length
701
+ ) {
720
702
  return candidate || undefined;
721
703
  }
722
704
  }
@@ -795,7 +777,6 @@ export async function flushTelegramPreview(
795
777
  await state.flushPromise;
796
778
  return;
797
779
  }
798
- state.flushTimer = undefined;
799
780
  state.flushPromise = (async () => {
800
781
  do {
801
782
  state.flushRequested = false;
package/lib/routing.ts CHANGED
@@ -217,21 +217,13 @@ function formatTelegramAllTabMenuChooserText(command: string): string {
217
217
  function buildTelegramUnboundRerouteChooserMarkup(
218
218
  rerouteId: string,
219
219
  records: readonly Threads.TelegramTopicTargetRecord[],
220
- options: {
220
+ _options: {
221
221
  currentLeaderProfileKey?: string;
222
222
  currentInstanceId?: string;
223
223
  } = {},
224
224
  ): Menu.TelegramReplyMarkup {
225
225
  const activeRecords = records.filter((record) => record.status === "active");
226
- const canRestoreCurrentLeader = activeRecords.some(
227
- (record) =>
228
- typeof record.rerouteConfirmedAtMs === "number" &&
229
- isCurrentLeaderTopicRecord(
230
- record,
231
- options.currentLeaderProfileKey,
232
- options.currentInstanceId,
233
- ),
234
- );
226
+ const canRestoreAnyLiveThread = activeRecords.length > 0;
235
227
  const rows = activeRecords.map((record) => [
236
228
  {
237
229
  text: getTelegramRouteThreadButtonLabel(record),
@@ -242,7 +234,7 @@ function buildTelegramUnboundRerouteChooserMarkup(
242
234
  },
243
235
  ]);
244
236
  return {
245
- inline_keyboard: canRestoreCurrentLeader
237
+ inline_keyboard: canRestoreAnyLiveThread
246
238
  ? [
247
239
  ...rows,
248
240
  [
@@ -414,6 +406,7 @@ export interface TelegramInboundRouteRuntimeDeps<
414
406
  getCurrentInstanceId?: () => string | undefined;
415
407
  getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
416
408
  getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
409
+ recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
417
410
  getLiveThreadTargets?: () => Queue.TelegramQueueTarget[];
418
411
  getLocalThreadLabelForTarget?: (
419
412
  target: Queue.TelegramQueueTarget,
@@ -1861,6 +1854,7 @@ export function createTelegramInboundRouteRuntime<
1861
1854
  getCurrentInstanceId: deps.getCurrentInstanceId,
1862
1855
  getMessageOwnership: deps.getMessageOwnership,
1863
1856
  getTargetOwnership: deps.getTargetOwnership,
1857
+ recordMessageOwnership: deps.recordMessageOwnership,
1864
1858
  handleTelegramTopicLifecycleUpdate,
1865
1859
  foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
1866
1860
  setAllowedUserId: deps.configStore.setAllowedUserId,
@@ -4,7 +4,14 @@
4
4
  * Owns session-local append-only runtime evidence for debugging without becoming routing state
5
5
  */
6
6
 
7
- import { existsSync, mkdirSync, statSync, writeFileSync, appendFile } from "node:fs";
7
+ import {
8
+ copyFileSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ statSync,
12
+ writeFileSync,
13
+ appendFile,
14
+ } from "node:fs";
8
15
  import { homedir } from "node:os";
9
16
  import { dirname, join, resolve } from "node:path";
10
17
 
@@ -17,6 +24,7 @@ export interface TelegramRuntimeJsonlEvent {
17
24
 
18
25
  export interface TelegramRuntimeJsonlLogOptions {
19
26
  path?: string;
27
+ previousPath?: string;
20
28
  maxBytes?: number;
21
29
  getNowMs?: () => number;
22
30
  }
@@ -57,6 +65,8 @@ export function createTelegramRuntimeJsonlLog(
57
65
  options: TelegramRuntimeJsonlLogOptions = {},
58
66
  ): TelegramRuntimeJsonlLog {
59
67
  const path = options.path ?? getTelegramRuntimeLogPath();
68
+ const previousPath =
69
+ options.previousPath ?? path.replace(/\.jsonl$/u, ".previous.jsonl");
60
70
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_LOG_BYTES;
61
71
  const getNowMs = options.getNowMs ?? Date.now;
62
72
  let scopeKey: string | undefined;
@@ -66,8 +76,15 @@ export function createTelegramRuntimeJsonlLog(
66
76
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
67
77
  };
68
78
 
79
+ const preserveCurrentLog = () => {
80
+ if (!existsSync(path)) return;
81
+ mkdirSync(dirname(previousPath), { recursive: true, mode: 0o700 });
82
+ copyFileSync(path, previousPath);
83
+ };
84
+
69
85
  const writeReset = (reason: string, scope?: Record<string, unknown>) => {
70
86
  ensureParent();
87
+ preserveCurrentLog();
71
88
  writeFileSync(
72
89
  path,
73
90
  safeJsonLine({
@@ -75,6 +92,7 @@ export function createTelegramRuntimeJsonlLog(
75
92
  kind: "reset",
76
93
  reason,
77
94
  scope,
95
+ previousPath,
78
96
  }) + "\n",
79
97
  { mode: 0o600 },
80
98
  );
package/lib/runtime.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
5
5
  */
6
6
 
7
- const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 1500;
7
+ const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 2500;
8
8
  const TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS = 250;
9
9
 
10
10
  export interface TelegramRuntimeQueueCounters {
@@ -398,13 +398,9 @@ export function createTelegramTypingLoopStarter<TContext>(
398
398
  chatId: chatId ?? deps.getDefaultChatId(),
399
399
  target: options?.target,
400
400
  intervalMs: deps.intervalMs ?? TELEGRAM_TYPING_ACTION_INTERVAL_MS,
401
- sendTypingAction: async (targetChatId) => {
401
+ sendTypingAction: async (targetChatId, actionOptions) => {
402
402
  try {
403
- const threadParams = getTelegramTypingLoopThreadParams(options?.target);
404
- await deps.sendTypingAction(targetChatId, threadParams);
405
- if (threadParams?.message_thread_id !== undefined) {
406
- await deps.sendAggregateTypingAction?.(targetChatId);
407
- }
403
+ await deps.sendTypingAction(targetChatId, actionOptions);
408
404
  } catch (error) {
409
405
  const message =
410
406
  error instanceof Error ? error.message : String(error);
@@ -419,6 +415,26 @@ export function createTelegramTypingLoopStarter<TContext>(
419
415
  });
420
416
  }
421
417
  },
418
+ sendAggregateTypingAction: deps.sendAggregateTypingAction
419
+ ? async (targetChatId) => {
420
+ try {
421
+ await deps.sendAggregateTypingAction?.(targetChatId);
422
+ } catch (error) {
423
+ const message =
424
+ error instanceof Error ? error.message : String(error);
425
+ updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
426
+ error: message,
427
+ category: "typing",
428
+ phase: "status-update",
429
+ recordRuntimeEvent: deps.recordRuntimeEvent,
430
+ });
431
+ deps.recordRuntimeEvent?.("typing", error, {
432
+ chatId: targetChatId,
433
+ aggregate: true,
434
+ });
435
+ }
436
+ }
437
+ : undefined,
422
438
  });
423
439
  };
424
440
  }
package/lib/status.ts CHANGED
@@ -113,6 +113,17 @@ export interface TelegramBridgeStatusBusFollower {
113
113
  status?: string;
114
114
  }
115
115
 
116
+ export interface TelegramBridgeStatusLocalBus {
117
+ leaderSocketPath?: string;
118
+ leaderTransport?: "pipe" | "socket";
119
+ followerSocketPath?: string;
120
+ followerTransport?: "pipe" | "socket";
121
+ followerRegistered?: boolean;
122
+ followerTarget?: { chatId: number; threadId?: number };
123
+ followerSlot?: string;
124
+ followerThreadName?: string;
125
+ }
126
+
116
127
  export interface TelegramBridgeStatusTopicTarget {
117
128
  instanceId?: string;
118
129
  status?: string;
@@ -187,6 +198,7 @@ export interface TelegramBridgeStatusLineState {
187
198
  pendingModelSwitch: boolean;
188
199
  queuedItems: Array<{ queueLane: TelegramStatusQueueLane }>;
189
200
  busFollowers?: TelegramBridgeStatusBusFollower[];
201
+ localBus?: TelegramBridgeStatusLocalBus;
190
202
  topicTargets?: TelegramBridgeStatusTopicTarget[];
191
203
  threadReservations?: TelegramBridgeStatusThreadReservation[];
192
204
  topicSyncObservations?: TelegramBridgeStatusSyncObservation[];
@@ -266,6 +278,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
266
278
  }
267
279
  | undefined;
268
280
  getBusFollowers?: () => TelegramBridgeStatusBusFollower[];
281
+ getLocalBus?: () => TelegramBridgeStatusLocalBus | undefined;
269
282
  getTopicTargets?: () => TelegramBridgeStatusTopicTarget[];
270
283
  getThreadReservations?: () => TelegramBridgeStatusThreadReservation[];
271
284
  getTopicSyncObservations?: () => TelegramBridgeStatusSyncObservation[];
@@ -626,6 +639,7 @@ export function createTelegramBridgeStatusRuntime<
626
639
  pendingModelSwitch: deps.hasPendingModelSwitch(),
627
640
  queuedItems: deps.getQueuedItems(),
628
641
  busFollowers: deps.getBusFollowers?.(),
642
+ localBus: deps.getLocalBus?.(),
629
643
  topicTargets: deps.getTopicTargets?.(),
630
644
  threadReservations: deps.getThreadReservations?.(),
631
645
  topicSyncObservations: deps.getTopicSyncObservations?.(),
@@ -682,6 +696,7 @@ export function createTelegramStatusSnapshot(
682
696
  },
683
697
  liveRoster: {
684
698
  busFollowers: state.busFollowers ?? [],
699
+ ...(state.localBus ? { localBus: state.localBus } : {}),
685
700
  topicTargets: state.topicTargets ?? [],
686
701
  reservations: state.threadReservations ?? [],
687
702
  syncObservations: state.topicSyncObservations ?? [],
@@ -756,8 +771,6 @@ export function buildTelegramStatusBarText(
756
771
  return `${label} ${theme.fg("warning", "electing")}${queued}`;
757
772
  if (!state.pollingActive && state.busRole !== "follower")
758
773
  return `${label} ${theme.fg("muted", "disconnected")}${queued}`;
759
- if (state.busRole === "follower")
760
- return `${label} ${theme.fg("success", "follower")}${queued}`;
761
774
  if (state.compactionInProgress) {
762
775
  return `${label} ${theme.fg("warning", "compacting")}${queued}`;
763
776
  }
@@ -769,6 +782,8 @@ export function buildTelegramStatusBarText(
769
782
  processingStatus === "active" ? "warning" : "accent";
770
783
  return `${label} ${theme.fg(processingToken, processingStatus)}${queued}`;
771
784
  }
785
+ if (state.busRole === "follower")
786
+ return `${label} ${theme.fg("success", "follower")}${queued}`;
772
787
  if (state.busRole === "leader")
773
788
  return `${label} ${theme.fg("success", "leader")}`;
774
789
  return `${label} ${theme.fg("success", "connected")}`;
@@ -824,6 +839,38 @@ function buildTelegramBusFollowerLines(
824
839
  ];
825
840
  }
826
841
 
842
+ function buildTelegramLocalBusLines(
843
+ state: Pick<TelegramBridgeStatusLineState, "localBus">,
844
+ options: { verbose?: boolean } = {},
845
+ ): string[] {
846
+ const localBus = state.localBus;
847
+ if (!localBus) return [];
848
+ const target = formatTelegramStatusTarget(localBus.followerTarget);
849
+ const label = formatTelegramThreadStatusLabel({
850
+ slot: localBus.followerSlot,
851
+ threadName: localBus.followerThreadName,
852
+ });
853
+ const followerLine = `- follower registered: ${localBus.followerRegistered ? "yes" : "no"}${label ? ` ${label}` : ""}${target}`;
854
+ const lines = ["", "local bus:", followerLine];
855
+ if (options.verbose) {
856
+ if (localBus.leaderSocketPath) {
857
+ const transport = localBus.leaderTransport
858
+ ? ` [${localBus.leaderTransport}]`
859
+ : "";
860
+ lines.push(`- leader endpoint${transport}: ${localBus.leaderSocketPath}`);
861
+ }
862
+ if (localBus.followerSocketPath) {
863
+ const transport = localBus.followerTransport
864
+ ? ` [${localBus.followerTransport}]`
865
+ : "";
866
+ lines.push(
867
+ `- follower endpoint${transport}: ${localBus.followerSocketPath}`,
868
+ );
869
+ }
870
+ }
871
+ return lines;
872
+ }
873
+
827
874
  function buildTelegramSyncSliceLines(
828
875
  state: Pick<TelegramBridgeStatusLineState, "syncState">,
829
876
  ): string[] {
@@ -1038,6 +1085,8 @@ function buildTelegramBridgeCompactStatusLines(
1038
1085
  : []),
1039
1086
  ...(state.pendingModelSwitch ? ["- pending model switch: yes"] : []),
1040
1087
  ...buildTelegramBridgeCompactThreadLines(state),
1088
+ ...buildTelegramBusFollowerLines(state),
1089
+ ...buildTelegramLocalBusLines(state),
1041
1090
  ...buildTelegramThreadReconciliationLines(state),
1042
1091
  "",
1043
1092
  "diagnostics:",
@@ -1100,6 +1149,7 @@ export function buildTelegramBridgeDiagnosticStatusLines(
1100
1149
  `- queued turns: ${state.queuedItems.length}`,
1101
1150
  `- lanes: control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
1102
1151
  ...buildTelegramBusFollowerLines(state),
1152
+ ...buildTelegramLocalBusLines(state, { verbose: true }),
1103
1153
  ...buildTelegramTopicTargetDiagnosticLines(state),
1104
1154
  ...buildTelegramThreadReconciliationLines(state),
1105
1155
  ...buildTelegramSyncSliceLines(state),
package/lib/threads.ts CHANGED
@@ -1438,7 +1438,7 @@ function getTelegramThreadNameEntropyIndex(
1438
1438
 
1439
1439
  export function getTelegramTopicThreadNameValidationError(
1440
1440
  threadName: string,
1441
- slot: string | undefined,
1441
+ _slot: string | undefined,
1442
1442
  ): string | undefined {
1443
1443
  const identity = getTelegramTopicIdentityName(threadName);
1444
1444
  const reasons: string[] = [];
@@ -1501,6 +1501,60 @@ function asInteger(value: unknown): number | undefined {
1501
1501
  return Number.isInteger(parsed) ? parsed : undefined;
1502
1502
  }
1503
1503
 
1504
+ export interface TelegramPromoteFollowerBindingToLeaderDeps {
1505
+ store: TelegramTopicTargetStore;
1506
+ instanceId: string;
1507
+ cwd?: string;
1508
+ target?: TelegramTarget;
1509
+ slot?: string;
1510
+ threadName?: string;
1511
+ nowMs?: number;
1512
+ }
1513
+
1514
+ export async function promoteTelegramFollowerBindingToLeader(
1515
+ deps: TelegramPromoteFollowerBindingToLeaderDeps,
1516
+ ): Promise<TelegramTopicTargetRecord | undefined> {
1517
+ const target = deps.target;
1518
+ if (typeof target?.threadId !== "number") return undefined;
1519
+ await deps.store.load();
1520
+ const nowMs = deps.nowMs ?? Date.now();
1521
+ const existing = deps.store
1522
+ .list()
1523
+ .find(
1524
+ (record) =>
1525
+ record.target.chatId === target.chatId &&
1526
+ record.target.threadId === target.threadId,
1527
+ );
1528
+ const owner: TelegramThreadOwner = {
1529
+ kind: "leader",
1530
+ cwd: deps.cwd,
1531
+ instanceId: deps.instanceId,
1532
+ };
1533
+ const record = deps.store.upsert({
1534
+ profileKey: getTelegramThreadOwnerKey(owner),
1535
+ owner,
1536
+ target: { chatId: target.chatId, threadId: target.threadId },
1537
+ status: "active",
1538
+ createdAtMs: existing?.createdAtMs ?? nowMs,
1539
+ updatedAtMs: nowMs,
1540
+ ...(existing?.threadName ?? deps.threadName
1541
+ ? { threadName: existing?.threadName ?? deps.threadName }
1542
+ : {}),
1543
+ instanceId: deps.instanceId,
1544
+ ...(existing?.slot ?? deps.slot ? { slot: existing?.slot ?? deps.slot } : {}),
1545
+ ...(existing?.syncStatus ? { syncStatus: existing.syncStatus } : {}),
1546
+ ...(existing?.lastSyncObservedAtMs !== undefined
1547
+ ? { lastSyncObservedAtMs: existing.lastSyncObservedAtMs }
1548
+ : {}),
1549
+ lastReconcileAction: "follower-promoted-to-leader",
1550
+ ...(existing?.rerouteConfirmedAtMs !== undefined
1551
+ ? { rerouteConfirmedAtMs: existing.rerouteConfirmedAtMs }
1552
+ : {}),
1553
+ });
1554
+ await deps.store.persist();
1555
+ return record;
1556
+ }
1557
+
1504
1558
  export interface TelegramOwnTopicProvisionDeps {
1505
1559
  getAllowedUserId: () => number | undefined;
1506
1560
  instanceId: string;
@@ -2077,7 +2131,7 @@ export function createTelegramTopicTargetProvisioner(
2077
2131
  existing.threadName ?? identityThreadName ?? bakedThreadName,
2078
2132
  instanceId: request.instanceId,
2079
2133
  slot,
2080
- owner: existing.owner ?? request.owner,
2134
+ owner: request.owner ?? existing.owner,
2081
2135
  lastError: undefined,
2082
2136
  });
2083
2137
  return { target: record.target, reused: true, record };