@llblab/pi-kit 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -0
  4. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  5. package/node_modules/@llblab/pi-telegram/README.md +4 -2
  6. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
  7. package/node_modules/@llblab/pi-telegram/docs/architecture.md +9 -5
  8. package/node_modules/@llblab/pi-telegram/docs/compact-matrix-literal.md +39 -11
  9. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
  10. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
  11. package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -4
  12. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  13. package/node_modules/@llblab/pi-telegram/index.ts +12 -1
  14. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +12 -3
  15. package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +5 -3
  16. package/node_modules/@llblab/pi-telegram/lib/locks.ts +99 -16
  17. package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +72 -15
  18. package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +81 -9
  19. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +2 -0
  20. package/node_modules/@llblab/pi-telegram/lib/polling.ts +142 -30
  21. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +8 -6
  22. package/node_modules/@llblab/pi-telegram/lib/queue.ts +4 -0
  23. package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
  24. package/node_modules/@llblab/pi-telegram/lib/status.ts +4 -0
  25. package/node_modules/@llblab/pi-telegram/lib/updates.ts +6 -0
  26. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  27. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +4 -2
  28. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/references/layout-and-state.md +4 -2
  29. package/node_modules/@llblab/pi-telegram/skills/generative-apps/SKILL.md +4 -3
  30. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +19 -8
  31. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +2 -0
  32. package/package.json +2 -2
@@ -21,6 +21,7 @@ const TELEGRAM_LONG_POLL_LIMIT = 10;
21
21
  const TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 30;
22
22
  const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 2_500;
23
23
  const TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES = 2;
24
+ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
24
25
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
25
26
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
26
27
  const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
@@ -69,6 +70,15 @@ export function getLatestTelegramUpdateId(
69
70
  return updates.at(-1)?.update_id;
70
71
  }
71
72
 
73
+ export class TelegramPersistentGetUpdatesConflictError extends Error {
74
+ readonly count: number;
75
+ constructor(count: number) {
76
+ super(`Telegram polling stopped after ${count} consecutive getUpdates conflicts.`);
77
+ this.name = "TelegramPersistentGetUpdatesConflictError";
78
+ this.count = count;
79
+ }
80
+ }
81
+
72
82
  export class TelegramGetUpdatesTimeoutError extends Error {
73
83
  readonly timeoutMs: number;
74
84
 
@@ -126,7 +136,8 @@ export type TelegramPollingStopReason =
126
136
  | "not-started"
127
137
  | "requested"
128
138
  | "completed"
129
- | "failed";
139
+ | "failed"
140
+ | "persistent-conflict";
130
141
 
131
142
  export interface TelegramPollingStateSnapshot {
132
143
  phase: TelegramPollingPhase;
@@ -200,6 +211,7 @@ export interface TelegramPollingRuntimeDeps<
200
211
  createAbortController?: () => AbortController;
201
212
  getNowMs?: () => number;
202
213
  onPollingStateChange?: () => void;
214
+ onPersistentConflict?: (ctx: TContext, count: number) => MaybePromise<void>;
203
215
  onPollingStarted?: () => void;
204
216
  onPollingStopped?: (reason: TelegramPollingStopReason) => void;
205
217
  }
@@ -227,20 +239,31 @@ export interface TelegramPollingAdmissionRuntime<TContext> {
227
239
  export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
228
240
  polling: TelegramPollingController<TContext>;
229
241
  prepareStart?: () => MaybePromise<void>;
242
+ canStart?: (ctx: TContext) => boolean;
230
243
  validateStart?: () => void;
231
244
  worker: {
232
245
  onSessionStart: (ctx: TContext) => Promise<void>;
233
246
  };
234
247
  }): TelegramPollingAdmissionRuntime<TContext> {
248
+ let generation = 0;
235
249
  return {
236
250
  isActive: deps.polling.isActive,
237
251
  async start(ctx) {
252
+ if (!(deps.canStart?.(ctx) ?? true)) return;
253
+ const expectedGeneration = ++generation;
254
+ const isCurrent = () => expectedGeneration === generation && (deps.canStart?.(ctx) ?? true);
255
+ if (!isCurrent()) return;
238
256
  await deps.prepareStart?.();
257
+ if (!isCurrent()) return;
239
258
  deps.validateStart?.();
240
259
  await deps.worker.onSessionStart(ctx);
241
- await deps.polling.start(ctx);
260
+ if (!isCurrent()) return;
261
+ deps.polling.start(ctx);
262
+ },
263
+ async stop() {
264
+ generation += 1;
265
+ await deps.polling.stop();
242
266
  },
243
- stop: deps.polling.stop,
244
267
  };
245
268
  }
246
269
 
@@ -256,6 +279,7 @@ export type TelegramDurablePollingRuntimeAssemblyDeps<
256
279
  TelegramPollingControllerRuntimeDeps<TUpdate, TContext>,
257
280
  "appendUpdateBatch" | "getJournalEntryCount" | "signalUpdateWorker"
258
281
  > & {
282
+ canStart?: (ctx: TContext) => boolean;
259
283
  journal: {
260
284
  appendBatch: (
261
285
  updates: readonly TUpdate[],
@@ -287,6 +311,7 @@ export function createTelegramDurablePollingRuntimeAssembly<
287
311
  const admission = createTelegramPollingAdmissionRuntime({
288
312
  polling: controller,
289
313
  prepareStart: deps.journal.prepareCursorCutover,
314
+ canStart: deps.canStart,
290
315
  validateStart() {
291
316
  if (deps.journal.getAcceptedThroughUpdateId() !== undefined) return;
292
317
  if (deps.journal.getBootstrapEntryCount() === 0) return;
@@ -312,6 +337,7 @@ export type TelegramPollingControllerRuntimeDeps<
312
337
  createAbortController?: () => AbortController;
313
338
  getNowMs?: () => number;
314
339
  onPollingStateChange?: () => void;
340
+ onPersistentConflict?: (ctx: TContext, count: number) => MaybePromise<void>;
315
341
  };
316
342
 
317
343
  function notifyTelegramPollingStateChange(
@@ -390,6 +416,7 @@ export function createTelegramPollingControllerRuntime<
390
416
  createAbortController: deps.createAbortController,
391
417
  getNowMs,
392
418
  onPollingStateChange: deps.onPollingStateChange,
419
+ onPersistentConflict: deps.onPersistentConflict,
393
420
  recordRuntimeEvent: deps.recordRuntimeEvent,
394
421
  });
395
422
  }
@@ -503,6 +530,7 @@ export function startTelegramPollingRuntime<TContext>(
503
530
  deps.setPollingController(controller);
504
531
  deps.onPollingStarted?.();
505
532
  let failed = false;
533
+ let persistentConflict: TelegramPersistentGetUpdatesConflictError | undefined;
506
534
  let runPromise: Promise<void>;
507
535
  try {
508
536
  runPromise = deps.runPollLoop(ctx, controller.signal);
@@ -513,24 +541,40 @@ export function startTelegramPollingRuntime<TContext>(
513
541
  promise = runPromise
514
542
  .catch((error) => {
515
543
  if (shouldStopTelegramPolling(controller.signal.aborted, error)) return;
544
+ if (error instanceof TelegramPersistentGetUpdatesConflictError) {
545
+ persistentConflict = error;
546
+ return;
547
+ }
516
548
  failed = true;
517
549
  deps.recordRuntimeEvent?.("polling", error, {
518
550
  phase: "controller",
519
551
  });
520
552
  })
521
- .finally(() => {
553
+ .finally(async () => {
522
554
  const ownsPromise = deps.getPollingPromise() === promise;
523
555
  const ownsController = deps.getPollingController() === controller;
524
556
  if (ownsPromise) deps.setPollingPromise(undefined);
525
557
  if (ownsController) deps.setPollingController(undefined);
526
- if (ownsPromise || ownsController) {
527
- deps.onPollingStopped?.(
528
- failed
529
- ? "failed"
530
- : controller.signal.aborted
531
- ? "requested"
532
- : "completed",
533
- );
558
+ if (!ownsPromise && !ownsController) return;
559
+ deps.onPollingStopped?.(
560
+ controller.signal.aborted ? "requested" : persistentConflict
561
+ ? "persistent-conflict" : failed ? "failed" : "completed",
562
+ );
563
+ // Detach the inner promise before outer teardown calls polling.stop().
564
+ if (persistentConflict && !controller.signal.aborted) {
565
+ try {
566
+ if (deps.onPersistentConflict) {
567
+ await deps.onPersistentConflict(ctx, persistentConflict.count);
568
+ } else {
569
+ deps.stopTypingLoop();
570
+ deps.recordRuntimeEvent?.("polling", persistentConflict, {
571
+ phase: "persistent-conflict", count: persistentConflict.count,
572
+ });
573
+ }
574
+ } catch (error) {
575
+ deps.recordRuntimeEvent?.("polling", error, { phase: "conflict-stand-down" });
576
+ }
577
+ if (deps.getPollingController() || deps.getPollingPromise()) return;
534
578
  }
535
579
  updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
536
580
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -581,6 +625,11 @@ export interface TelegramThreadCapabilityReaderDeps {
581
625
  ) => Promise<TResponse>;
582
626
  }
583
627
 
628
+ interface TelegramThreadCapabilityLifecycle {
629
+ capture: () => () => boolean;
630
+ invalidate: () => void;
631
+ }
632
+
584
633
  export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThreadCapabilityReaderDeps {
585
634
  topicTargetStore: TelegramThreadCapabilityStore;
586
635
  recordEvent: (
@@ -595,6 +644,7 @@ export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThread
595
644
  export interface TelegramThreadCapabilityRuntimeDeps<
596
645
  TContext,
597
646
  > extends TelegramThreadCapabilityReaderDeps {
647
+ lifecycle?: TelegramThreadCapabilityLifecycle;
598
648
  topicTargetStore: TelegramThreadCapabilityStore;
599
649
  ownsLock: (ctx: TContext) => boolean;
600
650
  isFollowerRegistered?: () => boolean;
@@ -672,6 +722,7 @@ export interface TelegramThreadAwarePollingDeps<
672
722
  TContext,
673
723
  TOwner,
674
724
  > extends TelegramStartupThreadCapabilityProbeDeps {
725
+ lifecycle?: TelegramThreadCapabilityLifecycle;
675
726
  isBusRuntimeEnabled: () => boolean;
676
727
  isTopicModeUnavailableError: (error: unknown) => boolean;
677
728
  getPollingStartedWithTelegramBus: () => boolean;
@@ -749,7 +800,16 @@ export function createTelegramThreadCapabilityStateRuntime(): TelegramThreadCapa
749
800
  export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
750
801
  deps: TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner>,
751
802
  ): TelegramThreadCapabilityOrchestration<TContext, TOwner> {
803
+ let generation = 0;
804
+ const lifecycle: TelegramThreadCapabilityLifecycle = {
805
+ capture() {
806
+ const expected = generation;
807
+ return () => expected === generation;
808
+ },
809
+ invalidate() { generation++; },
810
+ };
752
811
  const capabilityDeps: TelegramThreadCapabilityRuntimeDeps<TContext> = {
812
+ lifecycle,
753
813
  getAllowedUserId: deps.getAllowedUserId,
754
814
  callApi: deps.callApi,
755
815
  topicTargetStore: deps.topicTargetStore,
@@ -773,6 +833,7 @@ export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
773
833
  monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
774
834
  observeTarget: createTelegramThreadTargetObservationHandler(capabilityDeps),
775
835
  pollingPorts: createTelegramThreadAwarePollingPorts({
836
+ lifecycle,
776
837
  getAllowedUserId: deps.getAllowedUserId,
777
838
  callApi: deps.callApi,
778
839
  topicTargetStore: deps.topicTargetStore,
@@ -807,8 +868,11 @@ export async function readTelegramThreadCapability(
807
868
 
808
869
  export async function probeTelegramStartupThreadCapability(
809
870
  deps: TelegramStartupThreadCapabilityProbeDeps,
871
+ isCurrent: () => boolean = () => true,
810
872
  ): Promise<boolean | undefined> {
873
+ if (!isCurrent()) return;
811
874
  const threadModeEnabled = await readTelegramThreadCapability(deps);
875
+ if (!isCurrent()) return;
812
876
  const nowMs = (deps.getNowMs ?? Date.now)();
813
877
  if (threadModeEnabled === false) {
814
878
  deps.topicTargetStore.setBotState({
@@ -817,6 +881,7 @@ export async function probeTelegramStartupThreadCapability(
817
881
  lastReconcileAction: "startup-bot-topics-disabled",
818
882
  });
819
883
  await deps.topicTargetStore.persist();
884
+ if (!isCurrent()) return;
820
885
  deps.recordEvent("bus", "Telegram Threaded Mode unavailable on startup", {
821
886
  phase: "startup-bot-topics-disabled",
822
887
  });
@@ -831,6 +896,7 @@ export async function probeTelegramStartupThreadCapability(
831
896
  lastReconcileAction: "startup-bot-topics-enabled",
832
897
  });
833
898
  await deps.topicTargetStore.persist();
899
+ if (!isCurrent()) return;
834
900
  deps.setTopicModeUnavailable(false);
835
901
  }
836
902
  return threadModeEnabled;
@@ -865,8 +931,11 @@ export async function applyTelegramThreadCapability<TContext>(
865
931
  threadModeEnabled: boolean,
866
932
  phase: string,
867
933
  deps: TelegramThreadCapabilityRuntimeDeps<TContext>,
934
+ isCurrent: () => boolean = deps.lifecycle?.capture() ?? (() => true),
868
935
  ): Promise<void> {
936
+ if (!isCurrent()) return;
869
937
  await deps.topicTargetStore.load();
938
+ if (!isCurrent()) return;
870
939
  const nowMs = (deps.getNowMs ?? Date.now)();
871
940
  const previousBotState = deps.topicTargetStore.getBotState();
872
941
  if (!threadModeEnabled) {
@@ -886,6 +955,7 @@ export async function applyTelegramThreadCapability<TContext>(
886
955
  lastReconcileAction: phase,
887
956
  });
888
957
  await deps.topicTargetStore.persist();
958
+ if (!isCurrent()) return;
889
959
  deps.setTopicModeUnavailable(true);
890
960
  deps.stopFollowerRegistration();
891
961
  if (
@@ -894,16 +964,20 @@ export async function applyTelegramThreadCapability<TContext>(
894
964
  ) {
895
965
  deps.stopLeaderHealth();
896
966
  await deps.stopBusPolling();
967
+ if (!isCurrent()) return;
897
968
  deps.setPollingStartedWithTelegramBus(false);
898
969
  try {
899
970
  await deps.startClassicPolling(ctx);
971
+ if (!isCurrent()) return;
900
972
  } catch (classicError) {
973
+ if (!isCurrent()) return;
901
974
  deps.topicTargetStore.setBotState({
902
975
  threadMode: "disabled",
903
976
  updatedAtMs: (deps.getNowMs ?? Date.now)(),
904
977
  lastReconcileAction: `${phase}-classic-restore-failed`,
905
978
  });
906
979
  await deps.topicTargetStore.persist();
980
+ if (!isCurrent()) return;
907
981
  deps.recordEvent("bus", classicError, {
908
982
  phase: `${phase}-classic-restore`,
909
983
  });
@@ -919,14 +993,18 @@ export async function applyTelegramThreadCapability<TContext>(
919
993
  lastReconcileAction: phase,
920
994
  });
921
995
  await deps.topicTargetStore.persist();
996
+ if (!isCurrent()) return;
922
997
  deps.setTopicModeUnavailable(false);
923
998
  if (!deps.getPollingStartedWithTelegramBus() && deps.ownsLock(ctx)) {
924
999
  await deps.stopClassicPolling();
1000
+ if (!isCurrent()) return;
925
1001
  deps.setPollingStartedWithTelegramBus(true);
926
1002
  try {
927
1003
  await deps.startBusPolling(ctx);
1004
+ if (!isCurrent()) return;
928
1005
  deps.startLeaderHealth();
929
1006
  } catch (error) {
1007
+ if (!isCurrent()) return;
930
1008
  deps.setPollingStartedWithTelegramBus(false);
931
1009
  const threadModeUnavailable =
932
1010
  deps.isTopicModeUnavailableError?.(error) === true;
@@ -937,17 +1015,21 @@ export async function applyTelegramThreadCapability<TContext>(
937
1015
  lastReconcileAction: `${phase}-unavailable`,
938
1016
  });
939
1017
  await deps.topicTargetStore.persist();
1018
+ if (!isCurrent()) return;
940
1019
  deps.setTopicModeUnavailable(true);
941
1020
  }
942
1021
  try {
943
1022
  await deps.startClassicPolling(ctx);
1023
+ if (!isCurrent()) return;
944
1024
  } catch (classicError) {
1025
+ if (!isCurrent()) return;
945
1026
  deps.topicTargetStore.setBotState({
946
1027
  threadMode: "disabled",
947
1028
  updatedAtMs: (deps.getNowMs ?? Date.now)(),
948
1029
  lastReconcileAction: `${phase}-classic-restore-failed`,
949
1030
  });
950
1031
  await deps.topicTargetStore.persist();
1032
+ if (!isCurrent()) return;
951
1033
  deps.recordEvent("bus", classicError, {
952
1034
  phase: `${phase}-classic-restore`,
953
1035
  });
@@ -963,17 +1045,25 @@ export async function applyTelegramThreadCapability<TContext>(
963
1045
  export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
964
1046
  deps: TelegramThreadAwarePollingDeps<TContext, TOwner>,
965
1047
  ): TelegramThreadAwarePollingPorts<TContext, TOwner> {
1048
+ let generation = 0;
966
1049
  const startPolling = async (
967
1050
  ctx: TContext,
968
1051
  options?: { forceFreshLeaderThread?: boolean },
969
1052
  ): Promise<void> => {
1053
+ const expectedGeneration = ++generation;
1054
+ deps.lifecycle?.invalidate();
1055
+ const isLifecycleCurrent = deps.lifecycle?.capture() ?? (() => true);
1056
+ const isCurrent = () => expectedGeneration === generation && isLifecycleCurrent();
970
1057
  await deps.topicTargetStore.load();
1058
+ if (!isCurrent()) return;
971
1059
  let startupThreadCapability: boolean | undefined;
972
1060
  try {
973
- startupThreadCapability = await probeTelegramStartupThreadCapability(deps);
1061
+ startupThreadCapability = await probeTelegramStartupThreadCapability(deps, isCurrent);
974
1062
  } catch (error) {
1063
+ if (!isCurrent()) return;
975
1064
  deps.recordEvent("bus", error, { phase: "startup-thread-mode-probe" });
976
1065
  }
1066
+ if (!isCurrent()) return;
977
1067
  deps.setTopicModeUnavailable(startupThreadCapability !== true);
978
1068
  if (deps.isBusRuntimeEnabled()) {
979
1069
  deps.setTopicModeUnavailable(false);
@@ -983,32 +1073,39 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
983
1073
  !!options?.forceFreshLeaderThread,
984
1074
  );
985
1075
  await deps.startBusLeaderPolling(ctx);
1076
+ if (!isCurrent()) return;
986
1077
  deps.startLeaderHealth();
987
1078
  return;
988
1079
  } catch (error) {
1080
+ if (!isCurrent()) return;
989
1081
  deps.setPollingStartedWithTelegramBus(false);
990
1082
  if (!deps.isTopicModeUnavailableError(error)) throw error;
991
1083
  deps.setTopicModeUnavailable(true);
992
1084
  await deps.topicTargetStore.load();
1085
+ if (!isCurrent()) return;
993
1086
  deps.topicTargetStore.setBotState({
994
1087
  threadMode: "disabled",
995
1088
  updatedAtMs: Date.now(),
996
1089
  lastReconcileAction: "thread-mode-unavailable",
997
1090
  });
998
1091
  await deps.topicTargetStore.persist();
1092
+ if (!isCurrent()) return;
999
1093
  deps.recordEvent("bus", error, { phase: "thread-mode-unavailable" });
1000
1094
  } finally {
1001
- deps.setForceFreshLeaderThreadOnNextStart(false);
1095
+ if (isCurrent()) deps.setForceFreshLeaderThreadOnNextStart(false);
1002
1096
  }
1003
1097
  }
1004
1098
  deps.setPollingStartedWithTelegramBus(false);
1005
1099
  await deps.startClassicPolling(ctx);
1006
1100
  };
1007
1101
  const stopPolling = async (): Promise<void> => {
1102
+ const expectedGeneration = ++generation;
1103
+ deps.lifecycle?.invalidate();
1104
+ deps.setForceFreshLeaderThreadOnNextStart(false);
1105
+ deps.stopLeaderHealth();
1008
1106
  if (deps.getPollingStartedWithTelegramBus()) {
1009
- deps.stopLeaderHealth();
1010
1107
  await deps.stopBusLeaderPolling();
1011
- deps.setPollingStartedWithTelegramBus(false);
1108
+ if (expectedGeneration === generation) deps.setPollingStartedWithTelegramBus(false);
1012
1109
  return;
1013
1110
  }
1014
1111
  await deps.stopClassicPolling();
@@ -1043,14 +1140,17 @@ export function createTelegramThreadTargetObservationHandler<TContext>(
1043
1140
  if (transitionPending) return;
1044
1141
  if (deps.topicTargetStore.getBotState().threadMode === "enabled") return;
1045
1142
  transitionPending = true;
1143
+ const isCurrent = deps.lifecycle?.capture() ?? (() => true);
1046
1144
  try {
1047
1145
  await applyTelegramThreadCapability(
1048
1146
  ctx,
1049
1147
  true,
1050
1148
  "thread-target-observed",
1051
1149
  deps,
1150
+ isCurrent,
1052
1151
  );
1053
1152
  } catch (error) {
1153
+ if (!isCurrent()) return;
1054
1154
  deps.recordEvent("bus", error, { phase: "thread-target-observed" });
1055
1155
  } finally {
1056
1156
  transitionPending = false;
@@ -1079,6 +1179,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1079
1179
  let consecutiveDisabledProbes = 0;
1080
1180
  const stop = (): void => {
1081
1181
  generation += 1;
1182
+ deps.lifecycle?.invalidate();
1082
1183
  if (interval) clearInterval(interval);
1083
1184
  interval = undefined;
1084
1185
  };
@@ -1087,7 +1188,8 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1087
1188
  return;
1088
1189
  }
1089
1190
  const expectedGeneration = generation;
1090
- const isCurrent = (): boolean => generation === expectedGeneration;
1191
+ const isLifecycleCurrent = deps.lifecycle?.capture() ?? (() => true);
1192
+ const isCurrent = (): boolean => generation === expectedGeneration && isLifecycleCurrent();
1091
1193
  let tracked: Promise<void>;
1092
1194
  tracked = readTelegramThreadCapability(deps)
1093
1195
  .then(async (threadModeEnabled) => {
@@ -1103,6 +1205,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1103
1205
  true,
1104
1206
  "capability-monitor-retry",
1105
1207
  deps,
1208
+ isCurrent,
1106
1209
  );
1107
1210
  }
1108
1211
  return;
@@ -1123,6 +1226,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1123
1226
  false,
1124
1227
  "capability-monitor-disabled-confirmed",
1125
1228
  deps,
1229
+ isCurrent,
1126
1230
  );
1127
1231
  return;
1128
1232
  }
@@ -1153,6 +1257,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1153
1257
  ? "capability-monitor-disabled-confirmed"
1154
1258
  : "capability-monitor-disabled",
1155
1259
  deps,
1260
+ isCurrent,
1156
1261
  );
1157
1262
  })
1158
1263
  .catch((error) => {
@@ -1526,6 +1631,19 @@ export async function runTelegramPollLoop<
1526
1631
  TContext = unknown,
1527
1632
  >(deps: TelegramPollLoopDeps<TUpdate, TContext>): Promise<void> {
1528
1633
  if (!deps.config.botToken) return;
1634
+ let consecutiveGetUpdatesConflicts = 0;
1635
+ const retryConflict = async () => {
1636
+ consecutiveGetUpdatesConflicts += 1;
1637
+ if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
1638
+ throw new TelegramPersistentGetUpdatesConflictError(consecutiveGetUpdatesConflicts);
1639
+ }
1640
+ await deps.sleep(
1641
+ consecutiveGetUpdatesConflicts < TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT
1642
+ ? TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS
1643
+ : TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS,
1644
+ deps.signal,
1645
+ );
1646
+ };
1529
1647
  try {
1530
1648
  await deps.deleteWebhook(deps.signal);
1531
1649
  } catch {
@@ -1564,7 +1682,9 @@ export async function runTelegramPollLoop<
1564
1682
  } catch (error) {
1565
1683
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
1566
1684
  reportTelegramPollingPhase(deps, "retrying");
1567
- deps.recordRuntimeEvent?.("polling", error, {
1685
+ if (isTelegramGetUpdatesConflictError(error)) {
1686
+ await retryConflict();
1687
+ } else deps.recordRuntimeEvent?.("polling", error, {
1568
1688
  phase: "initial-sync",
1569
1689
  ...(error instanceof TelegramGetUpdatesTimeoutError
1570
1690
  ? { timeoutMs: error.timeoutMs }
@@ -1572,7 +1692,6 @@ export async function runTelegramPollLoop<
1572
1692
  });
1573
1693
  }
1574
1694
  }
1575
- let consecutiveGetUpdatesConflicts = 0;
1576
1695
  let currentUpdateId: number | undefined;
1577
1696
  while (!deps.signal.aborted) {
1578
1697
  try {
@@ -1600,6 +1719,10 @@ export async function runTelegramPollLoop<
1600
1719
  } catch (error) {
1601
1720
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
1602
1721
  reportTelegramPollingPhase(deps, "retrying", currentUpdateId);
1722
+ if (isTelegramGetUpdatesConflictError(error)) {
1723
+ await retryConflict();
1724
+ continue;
1725
+ }
1603
1726
  deps.recordRuntimeEvent?.("polling", error, {
1604
1727
  phase:
1605
1728
  error instanceof TelegramGetUpdatesTimeoutError
@@ -1609,17 +1732,6 @@ export async function runTelegramPollLoop<
1609
1732
  ? { timeoutMs: error.timeoutMs }
1610
1733
  : {}),
1611
1734
  });
1612
- if (isTelegramGetUpdatesConflictError(error)) {
1613
- consecutiveGetUpdatesConflicts += 1;
1614
- await deps.sleep(
1615
- consecutiveGetUpdatesConflicts <
1616
- TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT
1617
- ? TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS
1618
- : TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS,
1619
- deps.signal,
1620
- );
1621
- continue;
1622
- }
1623
1735
  consecutiveGetUpdatesConflicts = 0;
1624
1736
  deps.onErrorStatus(getTelegramPollingErrorMessage(error));
1625
1737
  await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
@@ -152,7 +152,7 @@ type TelegramBeforeAgentStartEvent = Omit<
152
152
  BeforeAgentStartEvent,
153
153
  "systemPrompt"
154
154
  > & {
155
- systemPrompt: TelegramSystemPrompt;
155
+ systemPrompt?: TelegramSystemPrompt | null;
156
156
  };
157
157
 
158
158
  type TelegramBeforeAgentStartResult = {
@@ -165,11 +165,12 @@ type TelegramBeforeAgentStartHook = (
165
165
 
166
166
  export function buildTelegramBridgeSystemPrompt(options: {
167
167
  prompt: string;
168
- systemPrompt: TelegramSystemPrompt;
168
+ systemPrompt?: TelegramSystemPrompt | null;
169
169
  telegramPrefix?: string;
170
170
  localSystemPromptSuffix: string;
171
171
  telegramTurnSystemPromptSuffix: string;
172
172
  }): TelegramBeforeAgentStartResult {
173
+ const basePrompt = options.systemPrompt ?? "";
173
174
  const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
174
175
  const telegramHead = telegramPrefix.endsWith("]")
175
176
  ? telegramPrefix.slice(0, -1)
@@ -182,12 +183,12 @@ export function buildTelegramBridgeSystemPrompt(options: {
182
183
  ? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
183
184
  : "";
184
185
  return {
185
- systemPrompt: Array.isArray(options.systemPrompt)
186
+ systemPrompt: Array.isArray(basePrompt)
186
187
  ? [
187
- ...options.systemPrompt,
188
+ ...basePrompt,
188
189
  options.localSystemPromptSuffix + telegramSuffix,
189
190
  ]
190
- : options.systemPrompt +
191
+ : basePrompt +
191
192
  options.localSystemPromptSuffix +
192
193
  telegramSuffix,
193
194
  };
@@ -221,8 +222,9 @@ function stripTelegramToolMetadataFromString(systemPrompt: string): string {
221
222
  }
222
223
 
223
224
  function stripTelegramToolMetadataFromSystemPrompt(
224
- systemPrompt: TelegramSystemPrompt,
225
+ systemPrompt: TelegramSystemPrompt | null | undefined,
225
226
  ): TelegramSystemPrompt {
227
+ if (!systemPrompt) return "";
226
228
  return Array.isArray(systemPrompt)
227
229
  ? systemPrompt.map(stripTelegramToolMetadataFromString)
228
230
  : stripTelegramToolMetadataFromString(systemPrompt);
@@ -1908,6 +1908,8 @@ export async function handleTelegramAgentEndRuntime<
1908
1908
  if (!isDeliveryActive()) return;
1909
1909
  if (richAttachmentDelivered) {
1910
1910
  await deps.clearPreview(turn.chatId, { target: turn.target });
1911
+ if (!isDeliveryActive()) return;
1912
+ deps.setPreviewPendingText("");
1911
1913
  }
1912
1914
  } catch (error) {
1913
1915
  deps.recordRuntimeEvent?.("delivery", error, {
@@ -1938,6 +1940,8 @@ export async function handleTelegramAgentEndRuntime<
1938
1940
  { replyMarkup, target: turn.target },
1939
1941
  );
1940
1942
  }
1943
+ if (!isDeliveryActive()) return;
1944
+ deps.setPreviewPendingText("");
1941
1945
  } catch (error) {
1942
1946
  deps.recordRuntimeEvent?.("delivery", error, {
1943
1947
  phase: "final-text",
@@ -532,7 +532,7 @@ function splitTelegramNativeMarkdownCountedBlocks(block: string): string[] {
532
532
  function countTelegramNativeMarkdownBlocks(block: string): number {
533
533
  if (/^ {0,3}(`{3,}|~{3,})/.test(block)) return 1;
534
534
  const lines = block.split("\n").filter((line) => line.trim().length > 0);
535
- if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\|)/.test(line))) {
535
+ if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\||<tg-button-row>)/.test(line))) {
536
536
  return Math.max(1, lines.length);
537
537
  }
538
538
  return 1;
@@ -291,6 +291,7 @@ export interface TelegramStatusBarTheme {
291
291
  export interface TelegramStatusBarState {
292
292
  hasBotToken: boolean;
293
293
  pollingActive: boolean;
294
+ pollingStopReason?: string;
294
295
  paired: boolean;
295
296
  busRole?: TelegramBridgeBusRole;
296
297
  busLifecyclePhase?: TelegramBridgeBusLifecyclePhase;
@@ -699,6 +700,7 @@ export function createTelegramBridgeStatusRuntime<
699
700
  queuedItems: queuedItemCount,
700
701
  }),
701
702
  queuedStatus: deps.formatQueuedStatus(queuedItems),
703
+ pollingStopReason: deps.getPollingState?.().stopReason,
702
704
  error,
703
705
  };
704
706
  },
@@ -889,6 +891,8 @@ export function buildTelegramStatusBarText(
889
891
  : "";
890
892
  if (!state.hasBotToken)
891
893
  return `${label} ${theme.fg("muted", "not configured")}${queued}`;
894
+ if (state.pollingStopReason === "persistent-conflict" && state.busRole !== "follower")
895
+ return `${label} ${theme.fg("error", "error")}`;
892
896
  if (!state.paired)
893
897
  return `${label} ${theme.fg("warning", "awaiting pairing")}${queued}`;
894
898
  if (state.busLifecyclePhase === "electing")
@@ -2160,6 +2160,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
2160
2160
  failurePhase: string,
2161
2161
  error: unknown,
2162
2162
  currentUpdateId?: number,
2163
+ extraDetails?: Record<string, unknown>,
2163
2164
  ): "blocked" => {
2164
2165
  blocked = true;
2165
2166
  state.lastFailureAtMs = getNowMs();
@@ -2168,6 +2169,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
2168
2169
  phase: failurePhase,
2169
2170
  generation: owner?.generation,
2170
2171
  ...(currentUpdateId !== undefined ? { updateId: currentUpdateId } : {}),
2172
+ ...extraDetails,
2171
2173
  });
2172
2174
  transition("blocked", currentUpdateId, blockedReason);
2173
2175
  return "blocked";
@@ -2553,6 +2555,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
2553
2555
  `Telegram queue receipt ${normalized.receiptId} conflicts with committed authority.`,
2554
2556
  ),
2555
2557
  currentUpdateId,
2558
+ {
2559
+ receiptId: normalized.receiptId,
2560
+ sourceUpdateIds: normalized.sourceUpdateIds,
2561
+ },
2556
2562
  );
2557
2563
  }
2558
2564
  return "duplicate";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.42.4",
3
+ "version": "0.43.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -40,7 +40,9 @@ A surface is an ordered ragged sequence of rows. Each button carries:
40
40
 
41
41
  - A short, distinct label.
42
42
  - The smallest self-contained next-request prompt.
43
- - Optional presentation state supported by the transport.
43
+ - Optional presentation state supported by the transport, including disabled controls when their visible unavailability helps explain current state.
44
+
45
+ A disabled control is not an action: it needs no prompt or selected style and must not enqueue a prompt or invoke a bound method. Prefer a meaningful label; omit it only for an intentional blank cell in a spatial layout, never as decorative padding. Preserve its label and position when that makes a changing surface easier to understand; otherwise omit irrelevant controls. Explain non-obvious unavailability without relying on color alone. Retain at least one useful enabled action, such as refresh or navigation. Derive disabled state from the same evidence as the view; an old enabled control still requires current domain validation. Use the transport owner's disabled encoding rather than a dummy prompt or no-op callback.
44
46
 
45
47
  Prompts must name any target, operation, constraint, or freshness identity whose omission could change the action. Reuse visible context only when it remains unambiguous under delayed or reordered clicks. Never encode volatile output that should be freshly inspected.
46
48
 
@@ -57,7 +59,7 @@ Every generated human-readable action label must use `emoji + space + text`; emo
57
59
 
58
60
  For complex grids, navigation collections, or stateful repeated clicks, read [`references/layout-and-state.md`](./references/layout-and-state.md).
59
61
 
60
- Serialize the resulting rows with the active transport contract. This Skill owns admission and composition, not transport syntax.
62
+ Place a control group beside the section it governs when the transport supports in-body blocks; use a footer for whole-answer actions. Placement must not change the matrix or action semantics. Serialize the resulting rows with the active transport contract. This Skill owns admission and composition, not transport syntax.
61
63
 
62
64
  ## Safety
63
65