@llblab/pi-telegram 0.17.5 → 0.18.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 (61) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -19
  3. package/CHANGELOG.md +36 -15
  4. package/README.md +63 -35
  5. package/docs/README.md +3 -1
  6. package/docs/architecture.md +55 -23
  7. package/docs/callback-namespaces.md +1 -1
  8. package/docs/inbound.md +1 -1
  9. package/docs/locks.md +0 -2
  10. package/docs/multi-instance-bus.md +483 -0
  11. package/docs/outbound.md +4 -3
  12. package/docs/public-api.md +12 -10
  13. package/docs/sections.md +2 -2
  14. package/docs/ui-style.md +76 -0
  15. package/index.ts +789 -32
  16. package/lib/bindings.ts +68 -12
  17. package/lib/bus-api.ts +314 -0
  18. package/lib/bus-follower.ts +853 -0
  19. package/lib/bus-leader.ts +915 -0
  20. package/lib/bus.ts +866 -0
  21. package/lib/command-templates.ts +9 -11
  22. package/lib/commands.ts +133 -47
  23. package/lib/config.ts +53 -5
  24. package/lib/lifecycle.ts +23 -7
  25. package/lib/locks.ts +230 -66
  26. package/lib/media.ts +30 -2
  27. package/lib/menu-model.ts +48 -17
  28. package/lib/menu-queue.ts +51 -20
  29. package/lib/menu-settings.ts +9 -5
  30. package/lib/menu-status.ts +3 -0
  31. package/lib/menu-thinking.ts +3 -0
  32. package/lib/menu.ts +67 -26
  33. package/lib/outbound-attachments.ts +102 -17
  34. package/lib/outbound-buttons.ts +6 -2
  35. package/lib/outbound-voice.ts +31 -11
  36. package/lib/outbound.ts +6 -4
  37. package/lib/ownership.ts +119 -0
  38. package/lib/pi.ts +26 -3
  39. package/lib/polling.ts +477 -7
  40. package/lib/preview.ts +141 -88
  41. package/lib/prompt-templates.ts +3 -3
  42. package/lib/prompts.ts +80 -30
  43. package/lib/queue.ts +193 -91
  44. package/lib/rendering.ts +0 -25
  45. package/lib/replies.ts +187 -55
  46. package/lib/routing.ts +1673 -9
  47. package/lib/runtime-log.ts +123 -0
  48. package/lib/runtime.ts +84 -12
  49. package/lib/sections.ts +28 -21
  50. package/lib/setup.ts +1 -1
  51. package/lib/status.ts +532 -9
  52. package/lib/sync.ts +618 -0
  53. package/lib/target.ts +49 -0
  54. package/lib/telegram-api.ts +405 -40
  55. package/lib/text-groups.ts +5 -1
  56. package/lib/thread-reconciler.ts +915 -0
  57. package/lib/threads.ts +2205 -0
  58. package/lib/turns.ts +48 -3
  59. package/lib/updates.ts +355 -32
  60. package/package.json +24 -2
  61. package/docs/telegram-bot-api-rich-messages.md +0 -890
package/lib/status.ts CHANGED
@@ -103,10 +103,80 @@ export interface TelegramRuntimeEventRecorderOptions {
103
103
  now?: () => number;
104
104
  }
105
105
 
106
+ export interface TelegramBridgeStatusBusFollower {
107
+ instanceId: string;
108
+ cwd?: string;
109
+ lastHeartbeatMs: number;
110
+ target?: { chatId: number; threadId?: number };
111
+ slot?: string;
112
+ threadName?: string;
113
+ status?: string;
114
+ }
115
+
116
+ export interface TelegramBridgeStatusTopicTarget {
117
+ instanceId?: string;
118
+ status?: string;
119
+ target?: { chatId: number; threadId?: number };
120
+ slot?: string;
121
+ threadName?: string;
122
+ syncStatus?: string;
123
+ lastSyncObservedAtMs?: number;
124
+ lastSyncProbeAtMs?: number;
125
+ lastSyncError?: string;
126
+ lastReconcileAction?: string;
127
+ }
128
+
129
+ export interface TelegramBridgeStatusThreadReservation {
130
+ target?: { chatId: number; threadId?: number };
131
+ slot?: string;
132
+ reason?: string;
133
+ instanceId?: string;
134
+ expiresAtMs?: number;
135
+ lastReconcileAction?: string;
136
+ }
137
+
138
+ export interface TelegramBridgeStatusSyncObservation {
139
+ target?: { chatId: number; threadId?: number };
140
+ syncStatus: string;
141
+ observedAtMs: number;
142
+ instanceId?: string;
143
+ slot?: string;
144
+ lastSyncError?: string;
145
+ lastReconcileAction?: string;
146
+ }
147
+
148
+ export interface TelegramBridgeStatusSyncSlice {
149
+ status: string;
150
+ updatedAtMs?: number;
151
+ suspectAtMs?: number;
152
+ reason?: string;
153
+ lastReconcileAction?: string;
154
+ }
155
+
156
+ export interface TelegramBridgeThreadReconciliationState {
157
+ phase: string;
158
+ event: string;
159
+ atMs: number;
160
+ leaderEpoch?: number | string;
161
+ pendingProvisionCount: number;
162
+ syncActionCount: number;
163
+ cleanupActionCount: number;
164
+ }
165
+
166
+ export type TelegramBridgeBusRole = "leader" | "follower";
167
+ export type TelegramBridgeBusLifecyclePhase = "electing";
168
+
106
169
  export interface TelegramBridgeStatusLineState {
107
170
  hasBotToken?: boolean;
108
171
  botUsername?: string;
109
172
  allowedUserId?: number;
173
+ botThreadMode?: "unknown" | "enabled" | "disabled";
174
+ botThreadModeUpdatedAtMs?: number;
175
+ botThreadModeAction?: string;
176
+ busRole?: TelegramBridgeBusRole;
177
+ busLifecyclePhase?: TelegramBridgeBusLifecyclePhase;
178
+ instanceSlot?: string;
179
+ instanceThreadName?: string;
110
180
  lockState?: string;
111
181
  pollingActive: boolean;
112
182
  lastUpdateId?: number;
@@ -116,6 +186,13 @@ export interface TelegramBridgeStatusLineState {
116
186
  activeToolExecutions: number;
117
187
  pendingModelSwitch: boolean;
118
188
  queuedItems: Array<{ queueLane: TelegramStatusQueueLane }>;
189
+ busFollowers?: TelegramBridgeStatusBusFollower[];
190
+ topicTargets?: TelegramBridgeStatusTopicTarget[];
191
+ threadReservations?: TelegramBridgeStatusThreadReservation[];
192
+ topicSyncObservations?: TelegramBridgeStatusSyncObservation[];
193
+ syncState?: Record<string, TelegramBridgeStatusSyncSlice | undefined>;
194
+ threadReconciliation?: TelegramBridgeThreadReconciliationState;
195
+ busNowMs?: number;
119
196
  recentRuntimeEvents: TelegramRuntimeEvent[];
120
197
  }
121
198
 
@@ -130,6 +207,10 @@ export interface TelegramStatusBarState {
130
207
  hasBotToken: boolean;
131
208
  pollingActive: boolean;
132
209
  paired: boolean;
210
+ busRole?: TelegramBridgeBusRole;
211
+ busLifecyclePhase?: TelegramBridgeBusLifecyclePhase;
212
+ instanceSlot?: string;
213
+ instanceThreadName?: string;
133
214
  compactionInProgress: boolean;
134
215
  processing: boolean;
135
216
  processingStatus?: string;
@@ -175,13 +256,41 @@ export interface TelegramBridgeStatusRuntimeDeps<
175
256
  formatQueuedStatus: (items: TQueueItem[]) => string;
176
257
  getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
177
258
  getRuntimeLockState?: () => string;
259
+ getBusRole?: () => TelegramBridgeBusRole | undefined;
260
+ getBusLifecyclePhase?: () => TelegramBridgeBusLifecyclePhase | undefined;
261
+ getBotThreadMode?: () =>
262
+ | {
263
+ threadMode: "unknown" | "enabled" | "disabled";
264
+ updatedAtMs?: number;
265
+ lastReconcileAction?: string;
266
+ }
267
+ | undefined;
268
+ getBusFollowers?: () => TelegramBridgeStatusBusFollower[];
269
+ getTopicTargets?: () => TelegramBridgeStatusTopicTarget[];
270
+ getThreadReservations?: () => TelegramBridgeStatusThreadReservation[];
271
+ getTopicSyncObservations?: () => TelegramBridgeStatusSyncObservation[];
272
+ getSyncState?: () => Record<
273
+ string,
274
+ TelegramBridgeStatusSyncSlice | undefined
275
+ >;
276
+ getThreadReconciliationState?: () =>
277
+ | TelegramBridgeThreadReconciliationState
278
+ | undefined;
279
+ getInstanceSlot?: () => string | undefined;
280
+ getInstanceThreadName?: () => string | undefined;
281
+ getNowMs?: () => number;
282
+ }
283
+
284
+ export interface TelegramBridgeStatusLineOptions {
285
+ verbose?: boolean;
178
286
  }
179
287
 
180
288
  export interface TelegramStatusRuntime<
181
289
  TContext extends TelegramStatusRuntimeContext,
182
290
  > {
183
291
  updateStatus: (ctx: TContext, error?: string) => void;
184
- getStatusLines: () => string[];
292
+ getStatusLines: (options?: TelegramBridgeStatusLineOptions) => string[];
293
+ getStatusState: () => TelegramBridgeStatusLineState;
185
294
  }
186
295
 
187
296
  function truncateTelegramRuntimeEventText(
@@ -421,11 +530,13 @@ export function buildTelegramRuntimeEventLines(
421
530
  export function createTelegramStatusHtmlBuilder<TContext>(deps: {
422
531
  getActiveModel: (ctx: TContext) => TelegramStatusActiveModel | undefined;
423
532
  isCompactionInProgress?: () => boolean;
533
+ getBridgeStatusLineState?: () => TelegramBridgeStatusLineState;
424
534
  }): (ctx: TContext & TelegramStatusContext) => string {
425
535
  return (ctx) =>
426
536
  buildStatusHtml(
427
537
  { ...ctx, isCompactionInProgress: deps.isCompactionInProgress },
428
538
  deps.getActiveModel(ctx),
539
+ deps.getBridgeStatusLineState?.(),
429
540
  );
430
541
  }
431
542
 
@@ -443,8 +554,9 @@ export function createTelegramStatusRuntime<
443
554
  ),
444
555
  );
445
556
  },
446
- getStatusLines: () =>
447
- buildTelegramBridgeStatusLines(deps.getBridgeStatusLineState()),
557
+ getStatusLines: (options) =>
558
+ buildTelegramBridgeStatusLines(deps.getBridgeStatusLineState(), options),
559
+ getStatusState: deps.getBridgeStatusLineState,
448
560
  };
449
561
  }
450
562
 
@@ -468,6 +580,10 @@ export function createTelegramBridgeStatusRuntime<
468
580
  hasBotToken: !!config.botToken,
469
581
  pollingActive: deps.isPollingActive(),
470
582
  paired: !!config.allowedUserId,
583
+ busRole: deps.getBusRole?.(),
584
+ busLifecyclePhase: deps.getBusLifecyclePhase?.(),
585
+ instanceSlot: deps.getInstanceSlot?.(),
586
+ instanceThreadName: deps.getInstanceThreadName?.(),
471
587
  compactionInProgress,
472
588
  processing:
473
589
  hasActiveTurn ||
@@ -488,10 +604,18 @@ export function createTelegramBridgeStatusRuntime<
488
604
  },
489
605
  getBridgeStatusLineState: () => {
490
606
  const config = deps.getConfig();
607
+ const botThreadMode = deps.getBotThreadMode?.();
491
608
  return {
492
609
  hasBotToken: Boolean(config.botToken),
493
610
  botUsername: config.botUsername,
494
611
  allowedUserId: config.allowedUserId,
612
+ botThreadMode: botThreadMode?.threadMode,
613
+ botThreadModeUpdatedAtMs: botThreadMode?.updatedAtMs,
614
+ botThreadModeAction: botThreadMode?.lastReconcileAction,
615
+ busRole: deps.getBusRole?.(),
616
+ busLifecyclePhase: deps.getBusLifecyclePhase?.(),
617
+ instanceSlot: deps.getInstanceSlot?.(),
618
+ instanceThreadName: deps.getInstanceThreadName?.(),
495
619
  lockState: deps.getRuntimeLockState?.(),
496
620
  pollingActive: deps.isPollingActive(),
497
621
  lastUpdateId: config.lastUpdateId,
@@ -501,12 +625,96 @@ export function createTelegramBridgeStatusRuntime<
501
625
  activeToolExecutions: deps.getActiveToolExecutions(),
502
626
  pendingModelSwitch: deps.hasPendingModelSwitch(),
503
627
  queuedItems: deps.getQueuedItems(),
628
+ busFollowers: deps.getBusFollowers?.(),
629
+ topicTargets: deps.getTopicTargets?.(),
630
+ threadReservations: deps.getThreadReservations?.(),
631
+ topicSyncObservations: deps.getTopicSyncObservations?.(),
632
+ syncState: deps.getSyncState?.(),
633
+ threadReconciliation: deps.getThreadReconciliationState?.(),
634
+ busNowMs: deps.getNowMs?.(),
504
635
  recentRuntimeEvents: deps.getRecentRuntimeEvents(),
505
636
  };
506
637
  },
507
638
  });
508
639
  }
509
640
 
641
+ export interface TelegramRuntimeLogScope extends Record<string, unknown> {
642
+ instanceId: string;
643
+ role: string;
644
+ slot?: string;
645
+ threadName?: string;
646
+ lockState?: string;
647
+ }
648
+
649
+ export function createTelegramRuntimeLogScope(input: {
650
+ state: TelegramBridgeStatusLineState;
651
+ instanceId: string;
652
+ }): TelegramRuntimeLogScope {
653
+ return {
654
+ instanceId: input.instanceId,
655
+ role: input.state.busRole ?? "classic-or-disconnected",
656
+ slot: input.state.instanceSlot,
657
+ threadName: input.state.instanceThreadName,
658
+ lockState: input.state.lockState,
659
+ };
660
+ }
661
+
662
+ export function createTelegramStatusSnapshot(
663
+ state: TelegramBridgeStatusLineState,
664
+ ): {
665
+ runtime: Record<string, unknown>;
666
+ liveRoster: Record<string, unknown>;
667
+ diagnostics: Record<string, unknown>;
668
+ } {
669
+ return {
670
+ runtime: {
671
+ busRole: state.busRole,
672
+ ...(state.busLifecyclePhase
673
+ ? { busLifecyclePhase: state.busLifecyclePhase }
674
+ : {}),
675
+ botThreadMode: state.botThreadMode,
676
+ botThreadModeUpdatedAtMs: state.botThreadModeUpdatedAtMs,
677
+ botThreadModeAction: state.botThreadModeAction,
678
+ instanceSlot: state.instanceSlot,
679
+ instanceThreadName: state.instanceThreadName,
680
+ pollingActive: state.pollingActive,
681
+ lockState: state.lockState,
682
+ },
683
+ liveRoster: {
684
+ busFollowers: state.busFollowers ?? [],
685
+ topicTargets: state.topicTargets ?? [],
686
+ reservations: state.threadReservations ?? [],
687
+ syncObservations: state.topicSyncObservations ?? [],
688
+ },
689
+ diagnostics: {
690
+ pendingDispatch: state.pendingDispatch,
691
+ compactionInProgress: state.compactionInProgress,
692
+ activeToolExecutions: state.activeToolExecutions,
693
+ pendingModelSwitch: state.pendingModelSwitch,
694
+ syncState: state.syncState,
695
+ threadReconciliation: state.threadReconciliation,
696
+ recentRuntimeEvents: state.recentRuntimeEvents,
697
+ },
698
+ };
699
+ }
700
+
701
+ export function createTelegramRuntimeDiagnosticsSnapshotScheduler(deps: {
702
+ persistSnapshot: () => Promise<void>;
703
+ recordError: (error: unknown) => void;
704
+ setTimer?: (callback: () => void, ms: number) => { unref?: () => void };
705
+ }): () => void {
706
+ const setTimer = deps.setTimer ?? setTimeout;
707
+ let timer: { unref?: () => void } | number | undefined;
708
+ return () => {
709
+ if (timer) return;
710
+ timer = setTimer(() => {
711
+ timer = undefined;
712
+ void deps.persistSnapshot().catch(deps.recordError);
713
+ }, 0);
714
+ if (typeof timer !== "number") timer?.unref?.();
715
+ };
716
+ }
717
+
510
718
  export function getTelegramStatusBarProcessingStatus(state: {
511
719
  hasActiveTurn: boolean;
512
720
  hasPendingDispatch: boolean;
@@ -515,19 +723,25 @@ export function getTelegramStatusBarProcessingStatus(state: {
515
723
  queuedItems: number;
516
724
  }): string | undefined {
517
725
  if (state.hasPendingModelSwitch) return "model";
518
- if (state.hasActiveTurn && state.activeToolExecutions > 0)
519
- return "tool running";
520
726
  if (state.hasActiveTurn || state.activeToolExecutions > 0) return "active";
521
727
  if (state.hasPendingDispatch) return "dispatching";
522
728
  if (state.queuedItems > 0) return "queued";
523
729
  return undefined;
524
730
  }
525
731
 
732
+ function getTelegramStatusBarLabel(state: TelegramStatusBarState): string {
733
+ const threadName = state.instanceThreadName?.trim();
734
+ if (!threadName) return "telegram";
735
+ const genericLabels = new Set(["telegram", "leader", "follower"]);
736
+ if (genericLabels.has(threadName.toLowerCase())) return "telegram";
737
+ return threadName;
738
+ }
739
+
526
740
  export function buildTelegramStatusBarText(
527
741
  theme: TelegramStatusBarTheme,
528
742
  state: TelegramStatusBarState,
529
743
  ): string {
530
- const label = theme.fg("accent", "telegram");
744
+ const label = theme.fg("accent", getTelegramStatusBarLabel(state));
531
745
  if (state.error) {
532
746
  return `${label} ${theme.fg("error", "error")} ${theme.fg("muted", state.error)}`;
533
747
  }
@@ -536,10 +750,14 @@ export function buildTelegramStatusBarText(
536
750
  : "";
537
751
  if (!state.hasBotToken)
538
752
  return `${label} ${theme.fg("muted", "not configured")}${queued}`;
539
- if (!state.pollingActive)
540
- return `${label} ${theme.fg("muted", "disconnected")}${queued}`;
541
753
  if (!state.paired)
542
754
  return `${label} ${theme.fg("warning", "awaiting pairing")}${queued}`;
755
+ if (state.busLifecyclePhase === "electing")
756
+ return `${label} ${theme.fg("warning", "electing")}${queued}`;
757
+ if (!state.pollingActive && state.busRole !== "follower")
758
+ return `${label} ${theme.fg("muted", "disconnected")}${queued}`;
759
+ if (state.busRole === "follower")
760
+ return `${label} ${theme.fg("success", "follower")}${queued}`;
543
761
  if (state.compactionInProgress) {
544
762
  return `${label} ${theme.fg("warning", "compacting")}${queued}`;
545
763
  }
@@ -551,6 +769,8 @@ export function buildTelegramStatusBarText(
551
769
  processingStatus === "active" ? "warning" : "accent";
552
770
  return `${label} ${theme.fg(processingToken, processingStatus)}${queued}`;
553
771
  }
772
+ if (state.busRole === "leader")
773
+ return `${label} ${theme.fg("success", "leader")}`;
554
774
  return `${label} ${theme.fg("success", "connected")}`;
555
775
  }
556
776
 
@@ -561,8 +781,282 @@ function formatTelegramBridgeBotStatus(
561
781
  return state.hasBotToken ? "unknown" : "not configured";
562
782
  }
563
783
 
784
+ function formatTelegramStatusTarget(
785
+ target: { chatId: number; threadId?: number } | undefined,
786
+ ): string {
787
+ if (!target) return "";
788
+ return target.threadId === undefined
789
+ ? ` target ${target.chatId}`
790
+ : ` target ${target.chatId}:${target.threadId}`;
791
+ }
792
+
793
+ function formatTelegramThreadStatusLabel(input: {
794
+ threadName?: string;
795
+ slot?: string;
796
+ }): string {
797
+ const threadName = input.threadName?.trim();
798
+ if (threadName) return threadName;
799
+ return input.slot ? `[${input.slot}]` : "";
800
+ }
801
+
802
+ function buildTelegramBusFollowerLines(
803
+ state: Pick<TelegramBridgeStatusLineState, "busFollowers" | "busNowMs">,
804
+ ): string[] {
805
+ const followers = state.busFollowers ?? [];
806
+ if (followers.length === 0) return [];
807
+ const nowMs = state.busNowMs ?? Date.now();
808
+ return [
809
+ "",
810
+ "bus:",
811
+ `- followers: ${followers.length}`,
812
+ ...followers.map((follower) => {
813
+ const ageSeconds = Math.max(
814
+ 0,
815
+ Math.round((nowMs - follower.lastHeartbeatMs) / 1000),
816
+ );
817
+ const label = formatTelegramThreadStatusLabel(follower);
818
+ const labelSuffix = label ? ` ${label}` : "";
819
+ const statusLabel = follower.status ? ` (${follower.status})` : "";
820
+ const cwd = follower.cwd ? ` ${follower.cwd}` : "";
821
+ const target = formatTelegramStatusTarget(follower.target);
822
+ return `- ${follower.instanceId}:${labelSuffix} heartbeat ${ageSeconds}s ago${statusLabel}${target}${cwd}`;
823
+ }),
824
+ ];
825
+ }
826
+
827
+ function buildTelegramSyncSliceLines(
828
+ state: Pick<TelegramBridgeStatusLineState, "syncState">,
829
+ ): string[] {
830
+ const syncState = state.syncState;
831
+ if (!syncState || Object.keys(syncState).length === 0) return [];
832
+ return [
833
+ "sync:",
834
+ ...Object.entries(syncState).map(([slice, value]) => {
835
+ const status = value?.status ?? "unknown";
836
+ const action = value?.lastReconcileAction
837
+ ? ` reconcile=${value.lastReconcileAction}`
838
+ : "";
839
+ const reason = value?.reason ? ` reason=${value.reason}` : "";
840
+ return `- ${slice}: ${status}${action}${reason}`;
841
+ }),
842
+ ];
843
+ }
844
+
845
+ function buildTelegramThreadReconciliationLines(
846
+ state: Pick<TelegramBridgeStatusLineState, "threadReconciliation">,
847
+ ): string[] {
848
+ const reconciliation = state.threadReconciliation;
849
+ if (!reconciliation) return [];
850
+ const epoch =
851
+ reconciliation.leaderEpoch !== undefined
852
+ ? ` epoch=${reconciliation.leaderEpoch}`
853
+ : "";
854
+ return [
855
+ "reconciliation:",
856
+ `- phase: ${reconciliation.phase} event=${reconciliation.event}${epoch}`,
857
+ `- counts: pending=${reconciliation.pendingProvisionCount}, sync=${reconciliation.syncActionCount}, cleanup=${reconciliation.cleanupActionCount}`,
858
+ ];
859
+ }
860
+
861
+ function buildTelegramTopicTargetDiagnosticLines(
862
+ state: Pick<
863
+ TelegramBridgeStatusLineState,
864
+ "topicTargets" | "threadReservations" | "topicSyncObservations"
865
+ >,
866
+ ): string[] {
867
+ const activeTargets = (state.topicTargets ?? []).filter(
868
+ (record) =>
869
+ !!record.instanceId &&
870
+ (record.status === "active" || record.status === "starting"),
871
+ );
872
+ const reservations = state.threadReservations ?? [];
873
+ const observations = state.topicSyncObservations ?? [];
874
+ if (
875
+ activeTargets.length === 0 &&
876
+ reservations.length === 0 &&
877
+ observations.length === 0
878
+ )
879
+ return [];
880
+ const byInstance = new Map<string, TelegramBridgeStatusTopicTarget[]>();
881
+ for (const record of activeTargets) {
882
+ const key = record.instanceId;
883
+ if (!key) continue;
884
+ const records = byInstance.get(key) ?? [];
885
+ records.push(record);
886
+ byInstance.set(key, records);
887
+ }
888
+ const duplicateLines = Array.from(byInstance.entries())
889
+ .filter(([, records]) => records.length > 1)
890
+ .map(([instanceId, records]) => {
891
+ const targets = records
892
+ .map((record) => {
893
+ const label = formatTelegramThreadStatusLabel(record);
894
+ return `${label}${formatTelegramStatusTarget(record.target) || " unknown"}`.trim();
895
+ })
896
+ .join(", ");
897
+ return `- duplicate ${instanceId}: ${records.length} active threads ${targets}`;
898
+ });
899
+ const twinLines = activeTargets.map((record) => {
900
+ const label = formatTelegramThreadStatusLabel(record);
901
+ const target = formatTelegramStatusTarget(record.target) || " unknown";
902
+ const sync = record.syncStatus ? ` sync=${record.syncStatus}` : "";
903
+ const observed = record.lastSyncObservedAtMs
904
+ ? ` observed=${new Date(record.lastSyncObservedAtMs).toISOString()}`
905
+ : "";
906
+ const probe = record.lastSyncProbeAtMs
907
+ ? ` probed=${new Date(record.lastSyncProbeAtMs).toISOString()}`
908
+ : "";
909
+ const error = record.lastSyncError
910
+ ? ` syncError=${record.lastSyncError}`
911
+ : "";
912
+ const action = record.lastReconcileAction
913
+ ? ` reconcile=${record.lastReconcileAction}`
914
+ : "";
915
+ return `- ${label}${target}${sync}${observed}${probe}${error}${action}`.trim();
916
+ });
917
+ const reservationLines = reservations.map((reservation) => {
918
+ const slot = reservation.slot ? `[${reservation.slot}]` : "";
919
+ const target = formatTelegramStatusTarget(reservation.target) || " unknown";
920
+ const reason = reservation.reason ? ` reason=${reservation.reason}` : "";
921
+ const instance = reservation.instanceId
922
+ ? ` instance=${reservation.instanceId}`
923
+ : "";
924
+ const action = reservation.lastReconcileAction
925
+ ? ` reconcile=${reservation.lastReconcileAction}`
926
+ : "";
927
+ return `- reservation ${slot}${target}${reason}${instance}${action}`.trim();
928
+ });
929
+ const observationLines = observations.map((observation) => {
930
+ const slot = observation.slot ? `[${observation.slot}]` : "";
931
+ const target = formatTelegramStatusTarget(observation.target) || " unknown";
932
+ const observed = ` observed=${new Date(observation.observedAtMs).toISOString()}`;
933
+ const instance = observation.instanceId
934
+ ? ` instance=${observation.instanceId}`
935
+ : "";
936
+ const error = observation.lastSyncError
937
+ ? ` syncError=${observation.lastSyncError}`
938
+ : "";
939
+ const action = observation.lastReconcileAction
940
+ ? ` reconcile=${observation.lastReconcileAction}`
941
+ : "";
942
+ return `- sync ${slot}${target} sync=${observation.syncStatus}${observed}${instance}${error}${action}`.trim();
943
+ });
944
+ return [
945
+ "topics:",
946
+ `- active bindings: instances=${byInstance.size}, targets=${activeTargets.length}`,
947
+ ...duplicateLines,
948
+ ...twinLines,
949
+ ...reservationLines,
950
+ ...observationLines,
951
+ ];
952
+ }
953
+
954
+ function buildTelegramBridgeCompactThreadLines(
955
+ state: Pick<
956
+ TelegramBridgeStatusLineState,
957
+ | "busFollowers"
958
+ | "topicTargets"
959
+ | "threadReservations"
960
+ | "topicSyncObservations"
961
+ >,
962
+ ): string[] {
963
+ const activeTargets = (state.topicTargets ?? []).filter(
964
+ (record) =>
965
+ !!record.instanceId &&
966
+ (record.status === "active" || record.status === "starting"),
967
+ );
968
+ const activeLabels = activeTargets
969
+ .map(formatTelegramThreadStatusLabel)
970
+ .filter((label) => label.length > 0);
971
+ const followers = state.busFollowers ?? [];
972
+ const reservations = state.threadReservations ?? [];
973
+ const observations = state.topicSyncObservations ?? [];
974
+ if (
975
+ activeLabels.length === 0 &&
976
+ followers.length === 0 &&
977
+ reservations.length === 0 &&
978
+ observations.length === 0
979
+ ) {
980
+ return [];
981
+ }
982
+ const lines = ["threads:"];
983
+ if (activeLabels.length > 0)
984
+ lines.push(`- active: ${activeLabels.join(", ")}`);
985
+ if (followers.length > 0) lines.push(`- followers: ${followers.length}`);
986
+ if (reservations.length > 0) lines.push(`- reserved: ${reservations.length}`);
987
+ const syncIssueCount = observations.filter(
988
+ (observation) => observation.syncStatus !== "open",
989
+ ).length;
990
+ if (syncIssueCount > 0) lines.push(`- sync issues: ${syncIssueCount}`);
991
+ return lines;
992
+ }
993
+
994
+ function buildTelegramBridgeCompactStatusLines(
995
+ state: TelegramBridgeStatusLineState,
996
+ ): string[] {
997
+ const controlQueueCount = state.queuedItems.filter(
998
+ (item) => item.queueLane === "control",
999
+ ).length;
1000
+ const priorityQueueCount = state.queuedItems.filter(
1001
+ (item) => item.queueLane === "priority",
1002
+ ).length;
1003
+ const defaultQueueCount = state.queuedItems.filter(
1004
+ (item) => item.queueLane === "default",
1005
+ ).length;
1006
+ const queueLine = `- queued turns: ${state.queuedItems.length}${
1007
+ state.queuedItems.length > 0
1008
+ ? ` (control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
1009
+ : ""
1010
+ }`;
1011
+ const executionState = state.compactionInProgress
1012
+ ? "compacting"
1013
+ : state.pendingDispatch
1014
+ ? "pending dispatch"
1015
+ : state.activeSourceMessageIds?.length
1016
+ ? "active"
1017
+ : "idle";
1018
+ return [
1019
+ "connection:",
1020
+ `- bot: ${formatTelegramBridgeBotStatus(state)}`,
1021
+ `- user: ${state.allowedUserId ?? "not paired"}`,
1022
+ ...(state.botThreadMode ? [`- thread mode: ${state.botThreadMode}`] : []),
1023
+ ...(state.busRole ? [`- role: ${state.busRole}`] : []),
1024
+ ...(state.busLifecyclePhase
1025
+ ? [`- lifecycle: ${state.busLifecyclePhase}`]
1026
+ : []),
1027
+ ...(state.instanceThreadName || state.instanceSlot
1028
+ ? [`- instance: ${state.instanceThreadName ?? state.instanceSlot}`]
1029
+ : []),
1030
+ ...(state.lockState ? [`- owner: ${state.lockState}`] : []),
1031
+ "",
1032
+ "health:",
1033
+ `- polling: ${state.pollingActive ? "running" : "stopped"}`,
1034
+ `- state: ${executionState}`,
1035
+ queueLine,
1036
+ ...(state.activeToolExecutions > 0
1037
+ ? [`- active tools: ${state.activeToolExecutions}`]
1038
+ : []),
1039
+ ...(state.pendingModelSwitch ? ["- pending model switch: yes"] : []),
1040
+ ...buildTelegramBridgeCompactThreadLines(state),
1041
+ ...buildTelegramThreadReconciliationLines(state),
1042
+ "",
1043
+ "diagnostics:",
1044
+ "- state: ~/.pi/agent/tmp/telegram/state.json",
1045
+ "- logs: ~/.pi/agent/tmp/telegram/logs.jsonl",
1046
+ "- full dump: /telegram-status --debug",
1047
+ ];
1048
+ }
1049
+
564
1050
  export function buildTelegramBridgeStatusLines(
565
1051
  state: TelegramBridgeStatusLineState,
1052
+ options: TelegramBridgeStatusLineOptions = {},
1053
+ ): string[] {
1054
+ if (options.verbose) return buildTelegramBridgeDiagnosticStatusLines(state);
1055
+ return buildTelegramBridgeCompactStatusLines(state);
1056
+ }
1057
+
1058
+ export function buildTelegramBridgeDiagnosticStatusLines(
1059
+ state: TelegramBridgeStatusLineState,
566
1060
  ): string[] {
567
1061
  const controlQueueCount = state.queuedItems.filter(
568
1062
  (item) => item.queueLane === "control",
@@ -577,6 +1071,18 @@ export function buildTelegramBridgeStatusLines(
577
1071
  "connection:",
578
1072
  `- bot: ${formatTelegramBridgeBotStatus(state)}`,
579
1073
  `- allowed user: ${state.allowedUserId ?? "not paired"}`,
1074
+ ...(state.botThreadMode
1075
+ ? [
1076
+ `- thread mode: ${state.botThreadMode}${state.botThreadModeAction ? ` reconcile=${state.botThreadModeAction}` : ""}`,
1077
+ ]
1078
+ : []),
1079
+ ...(state.busRole ? [`- bus role: ${state.busRole}`] : []),
1080
+ ...(state.busLifecyclePhase
1081
+ ? [`- bus lifecycle: ${state.busLifecyclePhase}`]
1082
+ : []),
1083
+ ...(state.instanceThreadName || state.instanceSlot
1084
+ ? [`- instance: ${state.instanceThreadName ?? state.instanceSlot}`]
1085
+ : []),
580
1086
  ...(state.lockState ? [`- owner: ${state.lockState}`] : []),
581
1087
  "",
582
1088
  "polling:",
@@ -593,6 +1099,10 @@ export function buildTelegramBridgeStatusLines(
593
1099
  "queue:",
594
1100
  `- queued turns: ${state.queuedItems.length}`,
595
1101
  `- lanes: control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
1102
+ ...buildTelegramBusFollowerLines(state),
1103
+ ...buildTelegramTopicTargetDiagnosticLines(state),
1104
+ ...buildTelegramThreadReconciliationLines(state),
1105
+ ...buildTelegramSyncSliceLines(state),
596
1106
  "",
597
1107
  ...buildTelegramRuntimeEventLines(state.recentRuntimeEvents),
598
1108
  ];
@@ -686,15 +1196,28 @@ function buildStatusSummary(ctx: TelegramStatusContext): string {
686
1196
  return "unknown";
687
1197
  }
688
1198
 
1199
+ function buildTelegramStatusRoleSuffix(
1200
+ state: TelegramBridgeStatusLineState | undefined,
1201
+ ): string {
1202
+ if (state?.botThreadMode !== "enabled" || !state.busRole) return "";
1203
+ return ` @${state.busRole}`;
1204
+ }
1205
+
689
1206
  export function buildStatusHtml(
690
1207
  ctx: TelegramStatusContext,
691
1208
  activeModel: TelegramStatusActiveModel | undefined,
1209
+ bridgeStatus?: TelegramBridgeStatusLineState,
692
1210
  ): string {
693
1211
  const stats = collectUsageStats(ctx);
694
1212
  const usesSubscription = activeModel
695
1213
  ? ctx.modelRegistry.isUsingOAuth(activeModel)
696
1214
  : false;
697
- const lines: string[] = [buildStatusRow("Status", buildStatusSummary(ctx))];
1215
+ const lines: string[] = [
1216
+ buildStatusRow(
1217
+ "Status",
1218
+ `${buildStatusSummary(ctx)}${buildTelegramStatusRoleSuffix(bridgeStatus)}`,
1219
+ ),
1220
+ ];
698
1221
  const usageSummary = buildUsageSummary(stats);
699
1222
  const costSummary = buildCostSummary(stats, usesSubscription);
700
1223
  if (usageSummary) {