@llblab/pi-kit 0.20.0 → 0.21.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 (66) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +6 -6
  4. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +9 -0
  5. package/node_modules/@llblab/pi-state-flow/README.md +4 -4
  6. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -2
  7. package/node_modules/@llblab/pi-state-flow/dist/index.js +2 -2
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -0
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -3
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +41 -11
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +6 -0
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +94 -1
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +2 -5
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +2 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +11 -0
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +4 -2
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +8 -2
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +6 -1
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +19 -6
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +5 -0
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +27 -3
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +2 -2
  23. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  24. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +10 -8
  25. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +2 -2
  26. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -3
  27. package/node_modules/@llblab/pi-state-flow/docs/usage.md +5 -5
  28. package/node_modules/@llblab/pi-state-flow/index.ts +2 -2
  29. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +8 -3
  30. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +41 -9
  31. package/node_modules/@llblab/pi-state-flow/lib/git.ts +83 -1
  32. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +2 -4
  33. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +11 -0
  34. package/node_modules/@llblab/pi-state-flow/lib/status.ts +10 -4
  35. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +23 -6
  36. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +29 -3
  37. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -2
  38. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  39. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  40. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
  41. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -0
  42. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +2 -0
  43. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +3 -1
  44. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +6 -1
  45. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +5 -0
  46. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +9 -0
  47. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +5 -1
  48. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +8 -0
  49. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +63 -11
  50. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +4 -0
  51. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +14 -0
  52. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +1 -0
  53. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +5 -0
  54. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  55. package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
  56. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
  57. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  58. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +7 -0
  59. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +13 -2
  60. package/node_modules/@llblab/pi-telegram/lib/commands.ts +19 -0
  61. package/node_modules/@llblab/pi-telegram/lib/extension.ts +9 -0
  62. package/node_modules/@llblab/pi-telegram/lib/queue.ts +75 -16
  63. package/node_modules/@llblab/pi-telegram/lib/replies.ts +18 -0
  64. package/node_modules/@llblab/pi-telegram/lib/routing.ts +7 -0
  65. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  66. package/package.json +3 -3
@@ -9,6 +9,7 @@ import { basename } from "node:path";
9
9
  import * as Sync from "./sync.js";
10
10
  import * as Threads from "./threads.js";
11
11
  import { parseTelegramUpdateJournalQueueOwner } from "./journal.js";
12
+ import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS, } from "./locks.js";
12
13
  import { isTelegramApiMethodRetrySafe, TelegramApiCommitUnknownError, TelegramApiStaleTargetError, } from "./telegram-api.js";
13
14
  import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusFollowerTargetController, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, } from "./bus.js";
14
15
  import { getTelegramBusTransportRetryPolicy, TELEGRAM_BUS_REGISTRATION_RETRY, } from "./bus-transport.js";
@@ -17,6 +18,7 @@ export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
17
18
  export const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
18
19
  export const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30_000;
19
20
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30_000;
21
+ export const TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS = TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS;
20
22
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS = TELEGRAM_BUS_REGISTRATION_RETRY.attempts;
21
23
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS = TELEGRAM_BUS_REGISTRATION_RETRY.delayMs;
22
24
  const TELEGRAM_FOLLOWER_SESSION_HANDOFF_KEY = "__piTelegramFollowerSessionHandoff";
@@ -920,6 +922,9 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
920
922
  const getNowMs = deps.getNowMs ?? Date.now;
921
923
  const getPid = deps.getPid ?? (() => process.pid);
922
924
  const heartbeatMs = deps.heartbeatMs ?? 1000;
925
+ const heartbeatTimeoutMs = deps.heartbeatTimeoutMs ??
926
+ deps.timeoutMs ??
927
+ TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS;
923
928
  const registrationTimeoutMs = deps.registrationTimeoutMs ?? deps.timeoutMs ?? 30000;
924
929
  const registrationRetryAttempts = deps.registrationRetryAttempts ??
925
930
  TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS;
@@ -979,7 +984,7 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
979
984
  try {
980
985
  const response = await sendTelegramBusLocalEnvelope({
981
986
  socketPath: leaderSocketPath,
982
- timeoutMs: deps.timeoutMs,
987
+ timeoutMs: heartbeatTimeoutMs,
983
988
  retry: getTelegramBusTransportRetryPolicy({
984
989
  endpoint: leaderSocketPath,
985
990
  operation: "operation",
@@ -210,6 +210,7 @@ export interface TelegramCommandActionDeps<TMessage, TContext> {
210
210
  export interface TelegramStopCommandDeps {
211
211
  hasAbortHandler: () => boolean;
212
212
  clearPendingModelSwitch: () => void;
213
+ cancelNextTransitionAnnouncements?: () => void;
213
214
  clearQueuedTelegramItems: () => number;
214
215
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
215
216
  abortCurrentTurn: () => void;
@@ -392,6 +393,8 @@ export interface TelegramCommandRuntimeDeps<TMessage extends TelegramCommandRunt
392
393
  isContextActive?: (ctx: TContext) => boolean;
393
394
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
394
395
  requestNextDispatchAnnouncement?: () => void;
396
+ markActiveTurnNextAbortAnnouncement?: () => boolean;
397
+ cancelNextTransitionAnnouncements?: () => void;
395
398
  requestDeferredDispatchNextQueuedTelegramTurn?: (dispatch: (ctx: TContext) => void) => void;
396
399
  startTypingLoop?: (ctx: TContext, chatId?: number, options?: {
397
400
  target?: {
@@ -504,6 +507,7 @@ export declare function handleTelegramAbortCommand(deps: {
504
507
  hasAbortHandler: () => boolean;
505
508
  hasActiveTelegramTurn: () => boolean;
506
509
  clearPendingModelSwitch: () => void;
510
+ cancelNextTransitionAnnouncements?: () => void;
507
511
  abortCurrentTurn: () => void;
508
512
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
509
513
  updateStatus: () => void;
@@ -519,6 +523,7 @@ export declare function handleTelegramNextCommand(deps: {
519
523
  abortCurrentTurn: () => void;
520
524
  dispatchNextQueuedTurn: () => void;
521
525
  requestNextDispatchAnnouncement?: () => void;
526
+ markActiveTurnNextAbortAnnouncement?: () => boolean;
522
527
  clearFoldForDispatch: () => void;
523
528
  updateStatus: () => void;
524
529
  sendTextReply: (text: string, options?: {
@@ -631,6 +631,7 @@ function formatTelegramQueuedTurnCount(count) {
631
631
  }
632
632
  export async function handleTelegramStopCommand(deps) {
633
633
  deps.clearPendingModelSwitch();
634
+ deps.cancelNextTransitionAnnouncements?.();
634
635
  const clearedCount = deps.clearQueuedTelegramItems();
635
636
  deps.setFoldQueuedPromptsIntoHistory(false);
636
637
  if (!deps.hasAbortHandler()) {
@@ -651,6 +652,7 @@ export async function handleTelegramStopCommand(deps) {
651
652
  }
652
653
  export async function handleTelegramAbortCommand(deps) {
653
654
  deps.clearPendingModelSwitch();
655
+ deps.cancelNextTransitionAnnouncements?.();
654
656
  if (!deps.hasAbortHandler()) {
655
657
  await deps.sendTextReply(formatTelegramInformationHeading("💤", "No active turn."), { parseMode: "HTML" });
656
658
  return;
@@ -669,6 +671,7 @@ export async function handleTelegramNextCommand(deps) {
669
671
  if (!deps.isIdle() && deps.hasAbortHandler()) {
670
672
  deps.clearFoldForDispatch();
671
673
  deps.requestNextDispatchAnnouncement?.();
674
+ deps.markActiveTurnNextAbortAnnouncement?.();
672
675
  deps.abortCurrentTurn();
673
676
  deps.updateStatus();
674
677
  return;
@@ -934,6 +937,9 @@ export function createTelegramCommandHandlerTargetRuntime(deps) {
934
937
  updateStatus: deps.updateStatus,
935
938
  isContextActive: deps.isContextActive,
936
939
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
940
+ requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
941
+ markActiveTurnNextAbortAnnouncement: deps.markActiveTurnNextAbortAnnouncement,
942
+ cancelNextTransitionAnnouncements: deps.cancelNextTransitionAnnouncements,
937
943
  startTypingLoop: deps.startTypingLoop,
938
944
  stopTypingLoop: deps.stopTypingLoop,
939
945
  enqueueContinueTurn: deps.enqueueContinueTurn,
@@ -1039,6 +1045,7 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
1039
1045
  await handleTelegramStopCommand({
1040
1046
  hasAbortHandler: deps.hasAbortHandler,
1041
1047
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
1048
+ cancelNextTransitionAnnouncements: deps.cancelNextTransitionAnnouncements,
1042
1049
  clearQueuedTelegramItems: () => deps.clearQueuedTelegramItems(commandCtx),
1043
1050
  setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
1044
1051
  abortCurrentTurn: deps.abortCurrentTurn,
@@ -1085,6 +1092,7 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
1085
1092
  hasAbortHandler: deps.hasAbortHandler,
1086
1093
  hasActiveTelegramTurn: deps.hasActiveTelegramTurn,
1087
1094
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
1095
+ cancelNextTransitionAnnouncements: deps.cancelNextTransitionAnnouncements,
1088
1096
  abortCurrentTurn: deps.abortCurrentTurn,
1089
1097
  setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
1090
1098
  updateStatus: updateStatusFor(commandCtx),
@@ -1100,6 +1108,7 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
1100
1108
  abortCurrentTurn: deps.abortCurrentTurn,
1101
1109
  dispatchNextQueuedTurn: () => deps.dispatchNextQueuedTelegramTurn(commandCtx),
1102
1110
  requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
1111
+ markActiveTurnNextAbortAnnouncement: deps.markActiveTurnNextAbortAnnouncement,
1103
1112
  clearFoldForDispatch: () => deps.setFoldQueuedPromptsIntoHistory(false),
1104
1113
  updateStatus: updateStatusFor(commandCtx),
1105
1114
  sendTextReply: sendReplyFor(nextMessage),
@@ -642,7 +642,7 @@ export default function (pi) {
642
642
  editMessageText: editTelegramMessageText,
643
643
  },
644
644
  });
645
- const { mutation: queueMutationRuntime, dispatchNext: dispatchNextQueuedTelegramTurn, requestNextDispatchAnnouncement, watchdog: queueDispatchWatchdogRuntime, } = Bindings.createTelegramQueueBindingRuntime({
645
+ const { mutation: queueMutationRuntime, dispatchNext: dispatchNextQueuedTelegramTurn, requestNextDispatchAnnouncement, cancelNextDispatchAnnouncement, watchdog: queueDispatchWatchdogRuntime, } = Bindings.createTelegramQueueBindingRuntime({
646
646
  store: telegramQueueStore,
647
647
  queue,
648
648
  lifecycle,
@@ -656,6 +656,9 @@ export default function (pi) {
656
656
  updateStatus,
657
657
  sendTextReply,
658
658
  sendUserMessage,
659
+ reconcileNextDispatchAnnouncementReplyOwnership(item) {
660
+ Replies.preserveTransportReplyDedupOnNextReset(item.chatId, item.replyToMessageId, item.target);
661
+ },
659
662
  recordRuntimeEvent,
660
663
  });
661
664
  const { finalizeMarkdownPreview, preparePreviewDelivery } = Outbound.createTelegramOutboundTextPreviewRuntime({
@@ -887,6 +890,7 @@ export default function (pi) {
887
890
  isContextActive: telegramSessionContextStore.isCurrent,
888
891
  dispatchNextQueuedTelegramTurn,
889
892
  requestNextDispatchAnnouncement,
893
+ cancelNextDispatchAnnouncement,
890
894
  requestDeferredDispatchNextQueuedTelegramTurn: deferredQueueDispatchRuntime.request,
891
895
  hasDeferredDispatchContext: deferredQueueDispatchRuntime.isBound,
892
896
  startTypingLoop: promptDispatchRuntime.startTypingLoop,
@@ -84,6 +84,8 @@ export interface PendingTelegramTurn extends TelegramQueueItemBase {
84
84
  historyText: string;
85
85
  priorityEmoji?: string;
86
86
  reactionSuppressionEmoji?: string;
87
+ /** Emit the explicit aborted-turn notice when /next settles this active turn. */
88
+ announceNextAbortOnEnd?: boolean;
87
89
  /** Turn should preferably be delivered as voice (mirror mode + user sent voice) */
88
90
  voiceReplyPreferred?: boolean;
89
91
  /** Turn must be delivered as voice (voice mode) */
@@ -165,6 +167,8 @@ export interface TelegramActiveTurnStore<TTurn extends PendingTelegramTurn = Pen
165
167
  has: () => boolean;
166
168
  set: (turn: TTurn) => void;
167
169
  clear: () => void;
170
+ markNextAbortAnnouncement: () => boolean;
171
+ clearNextAbortAnnouncement: () => boolean;
168
172
  getChatId: () => number | undefined;
169
173
  getTarget: () => TelegramQueueTarget | undefined;
170
174
  getReplyToMessageId: () => number | undefined;
@@ -391,6 +395,7 @@ export interface TelegramAgentEndPlan {
391
395
  kind: "no-turn" | "aborted" | "error" | "text" | "attachments-only" | "empty";
392
396
  shouldClearPreview: boolean;
393
397
  shouldDispatchNext: boolean;
398
+ shouldSendAbortMessage: boolean;
394
399
  shouldSendErrorMessage: boolean;
395
400
  shouldSendAttachmentNotice: boolean;
396
401
  }
@@ -517,6 +522,7 @@ export declare function buildTelegramAgentEndPlan(options: {
517
522
  hasFinalText: boolean;
518
523
  hasQueuedAttachments: boolean;
519
524
  foldQueuedPromptsIntoHistory: boolean;
525
+ announceNextAbortOnEnd?: boolean;
520
526
  }): TelegramAgentEndPlan;
521
527
  export declare function createTelegramAgentEndHook<TTurn extends PendingTelegramTurn, TContext, TMessage, TReplyMarkup = unknown>(deps: TelegramAgentEndHookRuntimeDeps<TTurn, TContext, TMessage, TReplyMarkup>): (event: TelegramAgentEndHookEvent<TMessage>, ctx: TContext, assistantOverride?: TelegramAgentEndAssistantResult) => Promise<void>;
522
528
  export declare function handleTelegramAgentEndRuntime<TTurn extends PendingTelegramTurn, TReplyMarkup = unknown>(deps: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>): Promise<void>;
@@ -759,6 +765,7 @@ export interface TelegramQueueDispatchControllerDeps<TContext = unknown> extends
759
765
  }>["item"], ctx: TContext) => boolean;
760
766
  sendUserMessage: TelegramDispatchRuntimeDeps<TContext>["sendUserMessage"];
761
767
  onPromptDispatchFailure: (ctx: TContext, message: string) => void;
768
+ reconcileNextDispatchAnnouncementReplyOwnership?: (item: PendingTelegramTurn) => void;
762
769
  isQueueItemTransportActive?: (item: TelegramQueueItem<TContext>) => boolean;
763
770
  hasPendingInboundQueueMutationForItem?: (item: TelegramQueueItem<TContext>) => boolean;
764
771
  isQueueItemAdmissionReady?: (item: TelegramQueueItem<TContext>) => boolean;
@@ -768,6 +775,7 @@ export interface TelegramQueueDispatchControllerDeps<TContext = unknown> extends
768
775
  export interface TelegramQueueDispatchController<TContext = unknown> {
769
776
  dispatchNext: (ctx: TContext) => void;
770
777
  requestNextDispatchAnnouncement: () => void;
778
+ cancelNextDispatchAnnouncement: () => void;
771
779
  }
772
780
  export declare function executeTelegramQueueDispatchPlan<TContext = unknown>(plan: TelegramQueueDispatchAction<TContext>, deps: TelegramDispatchRuntimeDeps<TContext>): void;
773
781
  export type TelegramQueueDispatchRuntimeDeps<TContext = unknown> = Omit<TelegramQueueDispatchControllerDeps<TContext>, "canDispatch"> & TelegramDispatchReadinessDeps<TContext>;
@@ -168,6 +168,18 @@ export function createTelegramActiveTurnStore() {
168
168
  clear: () => {
169
169
  activeTurn = undefined;
170
170
  },
171
+ markNextAbortAnnouncement: () => {
172
+ if (!activeTurn)
173
+ return false;
174
+ activeTurn.announceNextAbortOnEnd = true;
175
+ return true;
176
+ },
177
+ clearNextAbortAnnouncement: () => {
178
+ if (!activeTurn?.announceNextAbortOnEnd)
179
+ return false;
180
+ delete activeTurn.announceNextAbortOnEnd;
181
+ return true;
182
+ },
171
183
  getChatId: () => activeTurn?.chatId,
172
184
  getTarget: () => activeTurn?.target ? { ...activeTurn.target } : undefined,
173
185
  getReplyToMessageId: () => activeTurn?.replyToMessageId,
@@ -815,6 +827,7 @@ export function buildTelegramAgentEndPlan(options) {
815
827
  kind: "no-turn",
816
828
  shouldClearPreview: false,
817
829
  shouldDispatchNext,
830
+ shouldSendAbortMessage: false,
818
831
  shouldSendErrorMessage: false,
819
832
  shouldSendAttachmentNotice: false,
820
833
  };
@@ -824,6 +837,7 @@ export function buildTelegramAgentEndPlan(options) {
824
837
  kind: "aborted",
825
838
  shouldClearPreview: true,
826
839
  shouldDispatchNext,
840
+ shouldSendAbortMessage: options.announceNextAbortOnEnd === true,
827
841
  shouldSendErrorMessage: false,
828
842
  shouldSendAttachmentNotice: false,
829
843
  };
@@ -833,6 +847,7 @@ export function buildTelegramAgentEndPlan(options) {
833
847
  kind: "error",
834
848
  shouldClearPreview: true,
835
849
  shouldDispatchNext,
850
+ shouldSendAbortMessage: false,
836
851
  shouldSendErrorMessage: true,
837
852
  shouldSendAttachmentNotice: false,
838
853
  };
@@ -842,6 +857,7 @@ export function buildTelegramAgentEndPlan(options) {
842
857
  kind: "text",
843
858
  shouldClearPreview: false,
844
859
  shouldDispatchNext,
860
+ shouldSendAbortMessage: false,
845
861
  shouldSendErrorMessage: false,
846
862
  shouldSendAttachmentNotice: false,
847
863
  };
@@ -851,6 +867,7 @@ export function buildTelegramAgentEndPlan(options) {
851
867
  kind: "attachments-only",
852
868
  shouldClearPreview: true,
853
869
  shouldDispatchNext,
870
+ shouldSendAbortMessage: false,
854
871
  shouldSendErrorMessage: false,
855
872
  shouldSendAttachmentNotice: true,
856
873
  };
@@ -859,6 +876,7 @@ export function buildTelegramAgentEndPlan(options) {
859
876
  kind: "empty",
860
877
  shouldClearPreview: true,
861
878
  shouldDispatchNext,
879
+ shouldSendAbortMessage: false,
862
880
  shouldSendErrorMessage: false,
863
881
  shouldSendAttachmentNotice: false,
864
882
  };
@@ -991,6 +1009,7 @@ export async function handleTelegramAgentEndRuntime(deps) {
991
1009
  hasFinalText: !!finalText || hasOutboundArtifacts,
992
1010
  hasQueuedAttachments: (turn?.queuedAttachments.length ?? 0) > 0,
993
1011
  foldQueuedPromptsIntoHistory: deps.foldQueuedPromptsIntoHistory,
1012
+ announceNextAbortOnEnd: turn?.announceNextAbortOnEnd === true,
994
1013
  });
995
1014
  if (!turn) {
996
1015
  if (endPlan.shouldDispatchNext)
@@ -1116,17 +1135,26 @@ export async function handleTelegramAgentEndRuntime(deps) {
1116
1135
  if (!isDeliveryActive())
1117
1136
  return;
1118
1137
  }
1119
- if (endPlan.shouldSendErrorMessage) {
1138
+ if (endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage) {
1120
1139
  const errorMessage = assistant.errorMessage ||
1121
1140
  "Telegram bridge: Pi failed while processing the request.";
1122
- const isOperationAborted = errorMessage.trim().replace(/\.$/, "") ===
1123
- "This operation was aborted";
1124
- await deps.sendTextReply(turn.chatId, turn.replyToMessageId, isOperationAborted
1125
- ? "<b>⏹️ This operation was aborted.</b>"
1126
- : errorMessage, {
1127
- target: turn.target,
1128
- ...(isOperationAborted ? { parseMode: "HTML" } : {}),
1129
- });
1141
+ const isOperationAborted = endPlan.shouldSendAbortMessage ||
1142
+ errorMessage.trim().replace(/\.$/, "") === "This operation was aborted";
1143
+ try {
1144
+ await deps.sendTextReply(turn.chatId, turn.replyToMessageId, isOperationAborted
1145
+ ? "<b>⏹️ This operation was aborted.</b>"
1146
+ : errorMessage, {
1147
+ target: turn.target,
1148
+ ...(isOperationAborted ? { parseMode: "HTML" } : {}),
1149
+ });
1150
+ }
1151
+ catch (error) {
1152
+ if (!isOperationAborted)
1153
+ throw error;
1154
+ deps.recordRuntimeEvent?.("dispatch", error, {
1155
+ phase: "next-abort-announcement",
1156
+ });
1157
+ }
1130
1158
  if (!isDeliveryActive())
1131
1159
  return;
1132
1160
  if (endPlan.shouldDispatchNext)
@@ -1267,7 +1295,7 @@ export async function handleTelegramAgentEndRuntime(deps) {
1267
1295
  deps.dispatchNextQueuedTelegramTurn();
1268
1296
  };
1269
1297
  if (deps.scheduleActiveTurnDelivery &&
1270
- (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)) {
1298
+ (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)) {
1271
1299
  deps.scheduleActiveTurnDelivery(deliverActiveTurn);
1272
1300
  return;
1273
1301
  }
@@ -1765,6 +1793,7 @@ export function createTelegramQueueDispatchRuntime(deps) {
1765
1793
  commitPromptDispatch: deps.commitPromptDispatch,
1766
1794
  sendUserMessage: deps.sendUserMessage,
1767
1795
  onPromptDispatchFailure: deps.onPromptDispatchFailure,
1796
+ reconcileNextDispatchAnnouncementReplyOwnership: deps.reconcileNextDispatchAnnouncementReplyOwnership,
1768
1797
  isQueueItemTransportActive: deps.isQueueItemTransportActive,
1769
1798
  hasPendingInboundQueueMutationForItem: deps.hasPendingInboundQueueMutationForItem,
1770
1799
  isQueueItemAdmissionReady: deps.isQueueItemAdmissionReady,
@@ -1776,12 +1805,19 @@ export function createTelegramQueueDispatchRuntime(deps) {
1776
1805
  export function createTelegramQueueDispatchController(deps) {
1777
1806
  let controlDispatchPending = false;
1778
1807
  let nextDispatchAnnouncementRequested = false;
1808
+ let nextDispatchAnnouncementGeneration = 0;
1779
1809
  let nextDispatchAnnouncementAnchor;
1780
1810
  const controller = {
1781
1811
  requestNextDispatchAnnouncement: () => {
1812
+ nextDispatchAnnouncementGeneration += 1;
1782
1813
  nextDispatchAnnouncementRequested = true;
1783
1814
  nextDispatchAnnouncementAnchor = undefined;
1784
1815
  },
1816
+ cancelNextDispatchAnnouncement: () => {
1817
+ nextDispatchAnnouncementGeneration += 1;
1818
+ nextDispatchAnnouncementRequested = false;
1819
+ nextDispatchAnnouncementAnchor = undefined;
1820
+ },
1785
1821
  dispatchNext: (ctx) => {
1786
1822
  if (deps.hasDispatchContext && !deps.hasDispatchContext())
1787
1823
  return;
@@ -1972,11 +2008,22 @@ export function createTelegramQueueDispatchController(deps) {
1972
2008
  if (dispatchPlan.kind === "prompt" && nextDispatchAnnouncementRequested) {
1973
2009
  nextDispatchAnnouncementRequested = false;
1974
2010
  controlDispatchPending = true;
2011
+ const announcementGeneration = nextDispatchAnnouncementGeneration;
1975
2012
  const dispatchGeneration = deps.getDispatchGeneration?.();
1976
2013
  deps.updateStatus(ctx);
1977
2014
  void deps.sendTextReply(dispatchPlan.item.chatId, dispatchPlan.item.replyToMessageId, "<b>⏩ Dispatching next queued turn.</b>", { target: dispatchPlan.item.target }).catch((error) => {
1978
- deps.recordRuntimeEvent?.("dispatch", error, { phase: "next-announcement" });
2015
+ deps.recordRuntimeEvent?.("dispatch", error, {
2016
+ phase: "next-announcement",
2017
+ });
1979
2018
  }).finally(() => {
2019
+ try {
2020
+ deps.reconcileNextDispatchAnnouncementReplyOwnership?.(dispatchPlan.item);
2021
+ }
2022
+ catch (error) {
2023
+ deps.recordRuntimeEvent?.("dispatch", error, {
2024
+ phase: "next-announcement-reply-ownership",
2025
+ });
2026
+ }
1980
2027
  controlDispatchPending = false;
1981
2028
  if (deps.hasDispatchContext && !deps.hasDispatchContext())
1982
2029
  return;
@@ -1984,6 +2031,11 @@ export function createTelegramQueueDispatchController(deps) {
1984
2031
  deps.isDispatchGenerationActive &&
1985
2032
  !deps.isDispatchGenerationActive(dispatchGeneration))
1986
2033
  return;
2034
+ if (announcementGeneration !== nextDispatchAnnouncementGeneration) {
2035
+ if (nextDispatchAnnouncementRequested)
2036
+ controller.dispatchNext(ctx);
2037
+ return;
2038
+ }
1987
2039
  executePlan();
1988
2040
  });
1989
2041
  return;
@@ -23,6 +23,10 @@ export interface ReplyDedupRuntime {
23
23
  }
24
24
  export declare function createReplyDedupRuntime(): ReplyDedupRuntime;
25
25
  export declare function resetTransportReplyDedup(): void;
26
+ /** Keeps a successfully published transition notice as the first reply of the
27
+ * next agent turn. The following agent-start reset consumes this one-shot
28
+ * preservation, so later messages in that turn do not repeat the reply header. */
29
+ export declare function preserveTransportReplyDedupOnNextReset(chatId: number, messageId: number, target?: TelegramTarget): void;
26
30
  export declare function buildTelegramReplyParameters(chatId: number, messageId: number | undefined, target?: TelegramTarget): TelegramReplyParameters | undefined;
27
31
  export declare function withTelegramReplyParameters<T>(chatId: number, messageId: number | undefined, target: TelegramTarget | undefined, send: (parameters: TelegramReplyParameters | undefined) => Promise<T>): Promise<T>;
28
32
  export declare function isAssistantAgentMessage(message: unknown): boolean;
@@ -31,6 +31,7 @@ export function createReplyDedupRuntime() {
31
31
  }
32
32
  // --- Transport-level dedup ---
33
33
  const lastRepliedToMessageIdByTarget = new Map();
34
+ const replyDedupPreservedOnNextReset = new Map();
34
35
  let replyDedupGeneration = 0;
35
36
  function getReplyDedupTargetKey(chatId, target) {
36
37
  const threadId = target?.threadId;
@@ -41,6 +42,19 @@ function getReplyDedupTargetKey(chatId, target) {
41
42
  export function resetTransportReplyDedup() {
42
43
  replyDedupGeneration += 1;
43
44
  lastRepliedToMessageIdByTarget.clear();
45
+ for (const [key, messageId] of replyDedupPreservedOnNextReset) {
46
+ lastRepliedToMessageIdByTarget.set(key, messageId);
47
+ }
48
+ replyDedupPreservedOnNextReset.clear();
49
+ }
50
+ /** Keeps a successfully published transition notice as the first reply of the
51
+ * next agent turn. The following agent-start reset consumes this one-shot
52
+ * preservation, so later messages in that turn do not repeat the reply header. */
53
+ export function preserveTransportReplyDedupOnNextReset(chatId, messageId, target) {
54
+ const key = getReplyDedupTargetKey(chatId, target);
55
+ if (lastRepliedToMessageIdByTarget.get(key) !== messageId)
56
+ return;
57
+ replyDedupPreservedOnNextReset.set(key, messageId);
44
58
  }
45
59
  export function buildTelegramReplyParameters(chatId, messageId, target) {
46
60
  if (messageId === undefined || messageId <= 0)
@@ -122,6 +122,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
122
122
  isContextActive?: (ctx: TContext) => boolean;
123
123
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
124
124
  requestNextDispatchAnnouncement?: () => void;
125
+ cancelNextDispatchAnnouncement?: () => void;
125
126
  requestDeferredDispatchNextQueuedTelegramTurn?: (dispatch: (ctx: TContext) => void) => void;
126
127
  hasDeferredDispatchContext?: () => boolean;
127
128
  startTypingLoop?: (ctx: TContext, chatId?: number, options?: {
@@ -1418,6 +1418,10 @@ export function createTelegramInboundRouteRuntime(deps) {
1418
1418
  isContextActive: deps.isContextActive,
1419
1419
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
1420
1420
  requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
1421
+ cancelNextTransitionAnnouncements: () => {
1422
+ deps.activeTurnRuntime.clearNextAbortAnnouncement();
1423
+ deps.cancelNextDispatchAnnouncement?.();
1424
+ },
1421
1425
  requestDeferredDispatchNextQueuedTelegramTurn: deps.requestDeferredDispatchNextQueuedTelegramTurn,
1422
1426
  startTypingLoop: deps.startTypingLoop,
1423
1427
  stopTypingLoop: deps.stopTypingLoop,
@@ -1476,6 +1480,7 @@ export function createTelegramInboundRouteRuntime(deps) {
1476
1480
  },
1477
1481
  getPromptTemplateCommands,
1478
1482
  sendTextReply: deps.sendTextReply,
1483
+ markActiveTurnNextAbortAnnouncement: deps.activeTurnRuntime.markNextAbortAnnouncement,
1479
1484
  getActiveTurnReply: () => {
1480
1485
  const activeTurn = deps.activeTurnRuntime.get();
1481
1486
  if (!activeTurn)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.51.0",
3
+ "version": "0.51.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -518,7 +518,7 @@ Immediate controls:
518
518
  - `/start` opens the main inline application menu.
519
519
  - `/model`, `/thinking`, `/queue`, and `/settings` are hidden shortcuts to menu sections.
520
520
  - `/compact` opens an inline confirmation dialog and then runs compaction when the bridge is idle.
521
- - `/next` dispatches the next queued turn, aborting Pi first when needed. Active-turn settlement delivers its abort notice to the interrupted prompt before queue dispatch continues. The queue owner then emits `Dispatching next queued turn.` against the exact selected prompt's chat/thread/reply id before its one model dispatch; skipped, inactive, pending-mutation, and admission-blocked candidates never receive it. The `/next` command itself is never a lifecycle-notice reply target. Aborted pending assistant text is not projected as a second reply, while already completed intermediate output remains visible.
521
+ - `/next` dispatches the next queued turn, aborting Pi first when needed. Target-bound command composition must forward the announcement request, exact-turn marker, superseding cancellation, and deferred-dispatch ports rather than silently dropping them. Active-turn settlement attempts its abort notice against the interrupted prompt before queue dispatch continues; definite or ambiguous notice failure is recorded but cannot block the selected turn. The queue owner then emits `Dispatching next queued turn.` against the exact selected prompt's chat/thread/reply id before its one model dispatch; skipped, inactive, pending-mutation, and admission-blocked candidates never receive it. A successful dispatch notice carries its reply-dedup anchor through the following agent-start reset, so later messages in that turn do not repeat the queued-prompt reply header. A later `/abort` or `/stop` cancels both pending `/next` notices before taking ownership, preventing duplicate abort results and stale future dispatch announcements. The `/next` command itself is never a lifecycle-notice reply target. Aborted pending assistant text is not projected as a second reply, while already completed intermediate output remains visible.
522
522
  - `/abort` aborts active work while preserving queued items. Abort-history preservation is enabled only for Telegram-owned active turns; later local/non-Telegram agent starts clear stale abort-history mode so the next Telegram prompt appends instead of absorbing old queued turns as history.
523
523
  - `/stop` aborts and clears waiting Telegram queue items.
524
524
 
@@ -485,7 +485,7 @@ All files containing routing, chat ids, thread ids, or process details use priva
485
485
 
486
486
  ### Follower heartbeat is missed
487
487
 
488
- - Leader tolerates up to 15 seconds without a follower heartbeat before pruning it from the live registry, so short local event-loop or IPC stalls do not create false routing gaps. A follower response deadline defers its final timeout through one socket poll phase: an acknowledgement already buffered while the Pi/TUI event loop was blocked wins, while a genuinely silent peer still fails in the same event-loop turn. Heartbeat pruning remains liveness bookkeeping. One leader generation owns at most one prune operation; stop makes late endpoint, policy, and cleanup settlement inert, while durable-profile mutation serialization prevents replacement registration from crossing confirmed-dead cleanup.
488
+ - Leader tolerates up to 15 seconds without a follower heartbeat before pruning it from the live registry, so short local event-loop or IPC stalls do not create false routing gaps. Follower heartbeat requests use a dedicated eight-second response deadline aligned with leader stale-liveness policy rather than the generic one-second local-RPC default. The deadline also defers its final timeout through one socket poll phase: an acknowledgement already buffered while the Pi/TUI event loop was blocked wins, while a genuinely silent peer still fails in the same event-loop turn. Heartbeat pruning remains liveness bookkeeping. One leader generation owns at most one prune operation; stop makes late endpoint, policy, and cleanup settlement inert, while durable-profile mutation serialization prevents replacement registration from crossing confirmed-dead cleanup.
489
489
  - A missed heartbeat does not delete, close, mark offline, or send a disconnected notice for the follower's Telegram thread binding because the common cause may be leader reload, IPC handoff, or transient reconnect rather than a dead follower.
490
490
  - After removal from live routing, the current leader retains at most 26 volatile preservation observations with exact registered PID, generation, target, profile and leader epoch. The existing prune loop rechecks alive/unverifiable PIDs and retries non-destructive publication only after fresh explicit absence and a known disabled-cleanup policy. Missing identity or overflow stays protected. Observations never answer heartbeat/API/forwarding requests, launch processes, or authorize deletion. Enabling cleanup cancels deferred preservation rather than promoting it into destructive work; the existing first-prune cleanup path is not retried by this mechanism.
491
491
  - Registry registration invalidates overlapping instance/profile-owner/target observations, even if that replacement is removed before the next prune. Admitted same-instance provisioning cancels the old observation before its store writes can precede live registration. Profile/epoch drift, registry clear and leader stop invalidate it; late old completion cannot erase a replacement runtime's observation. No observation survives leader restart, reconstructs a PID from an opaque instance ID, or invents legacy inactivity.
@@ -53,7 +53,7 @@ Stable commands inside the paired Telegram DM:
53
53
  - `/start` — pair when needed and open the main application menu.
54
54
  - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-internal` Pi gateway using `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Manual invocation reports that the gateway cannot be run manually; the armed handler receives a real `ExtensionCommandContext` and calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. Busy, identity-mismatch, and unavailable-host paths fail closed.
55
55
  - `/compact` — open confirmation and compact when idle.
56
- - `/next` — abort active work first when needed, let the interrupted prompt receive its abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. The command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
56
+ - `/next` — abort active work first when needed, attempt the interrupted prompt's abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. That dispatch notice owns the turn's one reply header; later answer messages do not reply to the same prompt again. Abort-notice failure is diagnostic and cannot block dispatch. A later `/abort` or `/stop` cancels both pending transition notices before taking ownership. The `/next` command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
57
57
  - `/continue` — enqueue a priority `continue` prompt.
58
58
  - `/abort` — abort active work and keep the queue; abort-history is scoped to Telegram-owned active turns.
59
59
  - `/stop` — abort active Telegram-owned work and clear waiting Telegram queue items.
@@ -52,6 +52,7 @@ export interface TelegramQueueBindingRuntime<TContext> {
52
52
  mutation: Queue.TelegramQueueMutationController<TContext>;
53
53
  dispatchNext: (ctx: TContext) => void;
54
54
  requestNextDispatchAnnouncement: () => void;
55
+ cancelNextDispatchAnnouncement: () => void;
55
56
  watchdog: Queue.TelegramQueueDispatchWatchdogRuntime<TContext>;
56
57
  }
57
58
 
@@ -96,6 +97,9 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
96
97
  updateStatus: (ctx: TContext, error?: string) => void;
97
98
  sendTextReply: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendTextReply"];
98
99
  sendUserMessage: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendUserMessage"];
100
+ reconcileNextDispatchAnnouncementReplyOwnership?: (
101
+ item: Queue.PendingTelegramTurn,
102
+ ) => void;
99
103
  recordRuntimeEvent?: TelegramRuntimeEventRecorder;
100
104
  }): TelegramQueueBindingRuntime<TContext> {
101
105
  const settleDiscardedItems = (
@@ -159,6 +163,8 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
159
163
  },
160
164
  updateStatus: deps.updateStatus,
161
165
  sendTextReply: deps.sendTextReply,
166
+ reconcileNextDispatchAnnouncementReplyOwnership:
167
+ deps.reconcileNextDispatchAnnouncementReplyOwnership,
162
168
  recordRuntimeEvent: deps.recordRuntimeEvent,
163
169
  ...deps.promptDispatch,
164
170
  sendUserMessage: deps.sendUserMessage,
@@ -167,6 +173,7 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
167
173
  mutation,
168
174
  dispatchNext: dispatch.dispatchNext,
169
175
  requestNextDispatchAnnouncement: dispatch.requestNextDispatchAnnouncement,
176
+ cancelNextDispatchAnnouncement: dispatch.cancelNextDispatchAnnouncement,
170
177
  watchdog: Queue.createTelegramQueueDispatchWatchdogRuntime({
171
178
  hasQueuedItems: deps.store.hasQueuedItems,
172
179
  dispatchNextQueuedTelegramTurn: dispatch.dispatchNext,
@@ -11,7 +11,11 @@ import { basename } from "node:path";
11
11
  import * as Sync from "./sync.ts";
12
12
  import * as Threads from "./threads.ts";
13
13
  import { parseTelegramUpdateJournalQueueOwner, type TelegramUpdateJournalStoreOptions } from "./journal.ts";
14
- import type { TelegramLockEntry, TelegramLockState } from "./locks.ts";
14
+ import {
15
+ TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS,
16
+ type TelegramLockEntry,
17
+ type TelegramLockState,
18
+ } from "./locks.ts";
15
19
  import type {
16
20
  TelegramQueueHandoffPayload,
17
21
  TelegramQueueHandoffStageResult,
@@ -60,6 +64,8 @@ export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
60
64
  export const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
61
65
  export const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30_000;
62
66
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30_000;
67
+ export const TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS =
68
+ TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS;
63
69
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS =
64
70
  TELEGRAM_BUS_REGISTRATION_RETRY.attempts;
65
71
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS =
@@ -301,6 +307,7 @@ export interface TelegramBusFollowerRegistrationRuntimeDeps<
301
307
  registrationRetryAttempts?: number;
302
308
  registrationRetryDelayMs?: number;
303
309
  heartbeatMs?: number;
310
+ heartbeatTimeoutMs?: number;
304
311
  recordRuntimeEvent?: (
305
312
  category: string,
306
313
  error: unknown,
@@ -1619,6 +1626,10 @@ export function createTelegramBusFollowerRegistrationRuntime<
1619
1626
  const getNowMs = deps.getNowMs ?? Date.now;
1620
1627
  const getPid = deps.getPid ?? (() => process.pid);
1621
1628
  const heartbeatMs = deps.heartbeatMs ?? 1000;
1629
+ const heartbeatTimeoutMs =
1630
+ deps.heartbeatTimeoutMs ??
1631
+ deps.timeoutMs ??
1632
+ TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS;
1622
1633
  const registrationTimeoutMs =
1623
1634
  deps.registrationTimeoutMs ?? deps.timeoutMs ?? 30000;
1624
1635
  const registrationRetryAttempts =
@@ -1679,7 +1690,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
1679
1690
  try {
1680
1691
  const response = await sendTelegramBusLocalEnvelope({
1681
1692
  socketPath: leaderSocketPath,
1682
- timeoutMs: deps.timeoutMs,
1693
+ timeoutMs: heartbeatTimeoutMs,
1683
1694
  retry: getTelegramBusTransportRetryPolicy({
1684
1695
  endpoint: leaderSocketPath,
1685
1696
  operation: "operation",