@llblab/pi-telegram 0.54.3 → 0.55.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 (80) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +5 -3
  5. package/dist/lib/activity.d.ts +5 -1
  6. package/dist/lib/activity.js +31 -2
  7. package/dist/lib/bindings.d.ts +5 -1
  8. package/dist/lib/bindings.js +9 -2
  9. package/dist/lib/bus-api.d.ts +2 -2
  10. package/dist/lib/bus-api.js +7 -2
  11. package/dist/lib/bus-follower.d.ts +49 -3
  12. package/dist/lib/bus-follower.js +313 -146
  13. package/dist/lib/bus-leader.d.ts +3 -0
  14. package/dist/lib/bus-leader.js +99 -18
  15. package/dist/lib/bus.d.ts +5 -3
  16. package/dist/lib/bus.js +13 -10
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +56 -6
  20. package/dist/lib/lifecycle.d.ts +68 -0
  21. package/dist/lib/lifecycle.js +178 -58
  22. package/dist/lib/machine.d.ts +23 -0
  23. package/dist/lib/machine.js +37 -0
  24. package/dist/lib/media.d.ts +114 -28
  25. package/dist/lib/media.js +292 -119
  26. package/dist/lib/model.d.ts +54 -15
  27. package/dist/lib/model.js +98 -53
  28. package/dist/lib/polling.js +2 -0
  29. package/dist/lib/preview.d.ts +128 -113
  30. package/dist/lib/preview.js +448 -366
  31. package/dist/lib/queue.d.ts +2 -2
  32. package/dist/lib/queue.js +3 -3
  33. package/dist/lib/routing.d.ts +14 -2
  34. package/dist/lib/routing.js +546 -207
  35. package/dist/lib/runtime.d.ts +77 -11
  36. package/dist/lib/runtime.js +174 -92
  37. package/dist/lib/sections.js +0 -1
  38. package/dist/lib/telegram-api.d.ts +11 -9
  39. package/dist/lib/telegram-api.js +53 -4
  40. package/dist/lib/text-groups.d.ts +0 -26
  41. package/dist/lib/text-groups.js +51 -98
  42. package/dist/lib/thread-display.js +14 -6
  43. package/dist/lib/threads.d.ts +20 -5
  44. package/dist/lib/threads.js +111 -32
  45. package/dist/lib/turns.d.ts +2 -0
  46. package/dist/lib/turns.js +6 -1
  47. package/dist/lib/updates.d.ts +11 -4
  48. package/dist/lib/updates.js +54 -19
  49. package/dist/package.json +1 -1
  50. package/docs/activity.md +1 -1
  51. package/docs/architecture.md +16 -15
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +18 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +8 -6
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +13 -1
  58. package/lib/bus-api.ts +11 -3
  59. package/lib/bus-follower.ts +417 -203
  60. package/lib/bus-leader.ts +88 -14
  61. package/lib/bus.ts +17 -15
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +59 -9
  64. package/lib/lifecycle.ts +229 -55
  65. package/lib/machine.ts +60 -0
  66. package/lib/media.ts +438 -172
  67. package/lib/model.ts +169 -74
  68. package/lib/polling.ts +2 -0
  69. package/lib/preview.ts +715 -566
  70. package/lib/queue.ts +5 -5
  71. package/lib/routing.ts +644 -283
  72. package/lib/runtime.ts +260 -104
  73. package/lib/sections.ts +0 -2
  74. package/lib/telegram-api.ts +61 -8
  75. package/lib/text-groups.ts +75 -136
  76. package/lib/thread-display.ts +15 -4
  77. package/lib/threads.ts +130 -38
  78. package/lib/turns.ts +8 -1
  79. package/lib/updates.ts +58 -21
  80. package/package.json +1 -1
package/lib/routing.ts CHANGED
@@ -335,6 +335,10 @@ function getTelegramAllTabSourceMessageIds(
335
335
  );
336
336
  }
337
337
 
338
+ /** Failure retains the source for recovery; Threaded Mode never emits a fallback answer into All. */
339
+ const TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR =
340
+ "All is input-only in Threaded Mode; a routing Thread could not be prepared.";
341
+
338
342
  /** What a route chooser moves: `<code>/name</code>` for a command, otherwise "this message". */
339
343
  function formatTelegramRouteSubject(command?: string): string {
340
344
  return command ? `<code>/${escapeHtml(command)}</code>` : "this message";
@@ -387,14 +391,6 @@ function formatTelegramRoutingInputPreview(message: object): string {
387
391
  );
388
392
  }
389
393
 
390
- function formatTelegramAllTabMenuChooserText(command: string): string {
391
- return [
392
- `<b>🚦 Route ${formatTelegramRouteSubject(command)}:</b>`,
393
- "",
394
- "<i>To restore a Pi instead, send a message in a new tab.</i>",
395
- ].join("\n");
396
- }
397
-
398
394
  function buildTelegramUnboundRerouteChooserMarkup(
399
395
  rerouteId: string,
400
396
  records: readonly Threads.TelegramTopicTargetRecord[],
@@ -462,7 +458,8 @@ function formatTelegramUnboundRerouteChooserText(command?: string): string {
462
458
  function formatTelegramUnboundRerouteRestoreChooserText(
463
459
  command?: string,
464
460
  ): string {
465
- return `<b>🔁 Restore into this tab & send ${formatTelegramRouteSubject(command)}:</b>`;
461
+ const subject = command ? ` for ${formatTelegramRouteSubject(command)}` : "";
462
+ return `<b>🔁 Restore a Pi into this tab${subject}:</b>`;
466
463
  }
467
464
 
468
465
  function formatTelegramUnboundTopicGuidance(): string {
@@ -585,7 +582,10 @@ function classifyLiveRebindCleanupFailure(
585
582
  : "unknown";
586
583
  }
587
584
 
588
- /** Default live-rebind cleanup pacing: quick early attempts, then minutely, within a 15-minute window. */
585
+ /**
586
+ * Default live-rebind cleanup pacing: quick early attempts, then minutely, within a 15-minute window. The first
587
+ * delay is not cosmetic: a leader's detached command reply still needs the released, cleanup-free row.
588
+ */
589
589
  export const TELEGRAM_LIVE_REBIND_CLEANUP_SCHEDULE = Object.freeze({
590
590
  delaysMs: Object.freeze([1_000, 2_000, 5_000, 10_000, 30_000]),
591
591
  intervalMs: 60_000,
@@ -599,7 +599,7 @@ type TelegramLiveRebindSelectedCommandReference = {
599
599
  selectedCommand: Bus.TelegramBusSelectedCommandInput;
600
600
  };
601
601
 
602
- /** One warm live attempt with exact post-release peer donor settlement; no startup recovery, Restore ACK or cleanup. */
602
+ /** One warm live attempt with independent leader input/cleanup grants and exact peer donor settlement; no startup recovery or legacy Restore ACK. */
603
603
  export function createTelegramLiveRebindCoordinator(input: {
604
604
  request: Threads.TelegramWorkspaceRestoreRequest;
605
605
  /** Captures caller source/reference, admission, profile, transport and Pi lifetime. */
@@ -643,6 +643,10 @@ export function createTelegramLiveRebindCoordinator(input: {
643
643
  /** Explicit staged singleton command branch; absence preserves ordinary live input behavior. */
644
644
  selectedCommand?: Bus.TelegramBusSelectedCommandInput;
645
645
  };
646
+ /** Existing profile admission spans the applied recipient, fresh cleanup decision and one native input grant. */
647
+ cleanupBeforeRelease?(intent: Threads.TelegramWorkspaceLiveRebindIntent, isCurrent: () => boolean): Promise<boolean>;
648
+ /** Restore moves the tab to its new owner: once applied, show that owner's title (best-effort, never blocking). */
649
+ retitle?: (isCurrent: () => boolean) => Promise<void>;
646
650
  recordRuntimeEvent?: (
647
651
  category: string,
648
652
  error: unknown,
@@ -656,6 +660,7 @@ export function createTelegramLiveRebindCoordinator(input: {
656
660
  const leader = input.leader && { ...input.leader },
657
661
  follower = input.follower,
658
662
  followerRun = follower?.run;
663
+ const beforeCleanup = input.cleanupBeforeRelease;
659
664
  const selectedCommand =
660
665
  follower?.selectedCommand && structuredClone(follower.selectedCommand),
661
666
  held = selectedCommand;
@@ -773,9 +778,12 @@ export function createTelegramLiveRebindCoordinator(input: {
773
778
  let saved = false,
774
779
  commitIssued = false,
775
780
  applyIssued = false,
781
+ retitleIssued = false,
776
782
  peerReleaseIssued = false,
777
783
  donorSettlementAttempted = false,
778
784
  continuationIssued = false,
785
+ cleanupBeforeReleaseIssued = false,
786
+ cleanupBeforeReleaseDecided = false,
779
787
  released = false,
780
788
  advancing = false;
781
789
  let preparedSource: Bus.TelegramBusPreparedCommandSource | undefined;
@@ -829,6 +837,13 @@ export function createTelegramLiveRebindCoordinator(input: {
829
837
  generation: recipient!.generation,
830
838
  bindingKey: recipient!.bindingKey,
831
839
  });
840
+ const acceptInput = (expected: Threads.TelegramWorkspaceLiveRebindIntent) => {
841
+ if (!expected.inputRelease || expected.inputRelease === "accepted") return expected;
842
+ let accepted: Threads.TelegramWorkspaceLiveRebindIntent | undefined;
843
+ try { accepted = input.restoreStore.advanceLiveRebind(expected, "accept-input", authority); }
844
+ catch { accepted = retained(); }
845
+ return accepted?.inputRelease === "accepted" && canonical(accepted) ? accepted : undefined;
846
+ };
832
847
  const releaseFollower = async (
833
848
  intent: Threads.TelegramWorkspaceLiveRebindIntent,
834
849
  ): Promise<"unknown" | "released"> => {
@@ -857,6 +872,9 @@ export function createTelegramLiveRebindCoordinator(input: {
857
872
  )
858
873
  return "unknown";
859
874
  }
875
+ const accepted = acceptInput(intent);
876
+ if (!accepted) return "unknown";
877
+ intent = accepted;
860
878
  donorSettlementAttempted = true;
861
879
  if (preparation?.settleTransferred(() => canonical(intent)) !== "settled")
862
880
  return "unknown";
@@ -867,8 +885,7 @@ export function createTelegramLiveRebindCoordinator(input: {
867
885
  const intent = retained();
868
886
  if (
869
887
  !intent ||
870
- intent.phase !== "released" ||
871
- intent.cleanup ||
888
+ !Threads.isTelegramLiveRebindInputAuthorized(intent) ||
872
889
  !canonical(intent) ||
873
890
  !leader?.isApplied(intent)
874
891
  )
@@ -876,16 +893,14 @@ export function createTelegramLiveRebindCoordinator(input: {
876
893
  const completions = messages.map(
877
894
  Updates.inspectTelegramDeferredSourceCompletion,
878
895
  );
879
- return completions.every(
896
+ const completed = completions.every(
880
897
  (completion, index) =>
881
898
  completion && isDeepStrictEqual(completion, sources[index]),
882
899
  ) &&
883
900
  current() &&
884
901
  canonical(intent) &&
885
- leader.isApplied(intent) &&
886
- current()
887
- ? "released"
888
- : "unknown";
902
+ leader.isApplied(intent) && current();
903
+ return completed && acceptInput(intent) ? "released" : "unknown";
889
904
  };
890
905
  return {
891
906
  /** Correlation only; a later cleanup attempt rereads and revalidates the canonical row itself. */
@@ -919,8 +934,7 @@ export function createTelegramLiveRebindCoordinator(input: {
919
934
  const intent = retained();
920
935
  if (
921
936
  !intent ||
922
- intent.phase !== "released" ||
923
- intent.cleanup ||
937
+ !Threads.isTelegramLiveRebindInputAuthorized(intent) ||
924
938
  !canonical(intent) ||
925
939
  !leader?.isApplied(intent) ||
926
940
  !leader.observeAdmission?.() ||
@@ -929,6 +943,7 @@ export function createTelegramLiveRebindCoordinator(input: {
929
943
  !leader.isApplied(intent)
930
944
  )
931
945
  return "unknown";
946
+ if (!acceptInput(intent)) return "unknown";
932
947
  released = true;
933
948
  return "released";
934
949
  }
@@ -994,23 +1009,24 @@ export function createTelegramLiveRebindCoordinator(input: {
994
1009
  generation: recipient!.generation,
995
1010
  },
996
1011
  authority,
1012
+ beforeCleanup ? { holdInput: true } : undefined,
997
1013
  );
998
1014
  } else if (!commitIssued) return "protected";
999
1015
  if (
1000
1016
  !intent ||
1001
1017
  !sourceSaved() ||
1002
1018
  !canonical(intent) ||
1003
- intent.phase === "finished" ||
1004
- intent.cleanup
1019
+ (intent.phase === "finished" && !cleanupBeforeReleaseIssued) ||
1020
+ (intent.cleanup && intent.inputRelease === undefined)
1005
1021
  )
1006
1022
  return "protected";
1007
1023
  // A released peer may already have completed its sources; only its retained settlement can reobserve that outcome.
1008
1024
  if (recipient!.kind === "follower" && peerReleaseIssued)
1009
- return intent.phase === "released"
1025
+ return Threads.isTelegramLiveRebindInputAuthorized(intent)
1010
1026
  ? await releaseFollower(intent)
1011
1027
  : "protected";
1012
1028
  const mode =
1013
- applyIssued || intent.phase === "released" ? "inspect" : "apply";
1029
+ applyIssued || intent.phase === "released" || intent.phase === "finished" ? "inspect" : "apply";
1014
1030
  applyIssued = true;
1015
1031
  if (recipient!.kind === "leader") {
1016
1032
  await leader!.apply(structuredClone(intent), mode, current);
@@ -1030,6 +1046,17 @@ export function createTelegramLiveRebindCoordinator(input: {
1030
1046
  }
1031
1047
  if (!sourceSaved() || (preparation && !preparation.confirmSaved()))
1032
1048
  return "protected";
1049
+ if (input.retitle && !retitleIssued) {
1050
+ retitleIssued = true;
1051
+ try {
1052
+ await input.retitle(current);
1053
+ } catch (error) {
1054
+ input.recordRuntimeEvent?.("telegram", error, {
1055
+ phase: "live-rebind-title",
1056
+ });
1057
+ }
1058
+ if (!current() || !canonical(intent)) return "unknown";
1059
+ }
1033
1060
  if (intent.phase === "rebound") {
1034
1061
  const expected = intent;
1035
1062
  input.restoreStore.advanceLiveRebind(
@@ -1046,11 +1073,27 @@ export function createTelegramLiveRebindCoordinator(input: {
1046
1073
  }
1047
1074
  if (
1048
1075
  !intent ||
1049
- intent.phase !== "released" ||
1050
- intent.cleanup ||
1076
+ (intent.phase !== "released" && !(intent.phase === "finished" && intent.inputRelease === "held")) ||
1077
+ (intent.cleanup && intent.inputRelease === undefined) ||
1051
1078
  !canonical(intent)
1052
1079
  )
1053
1080
  return "protected";
1081
+ if (beforeCleanup) {
1082
+ if (!cleanupBeforeReleaseIssued) {
1083
+ cleanupBeforeReleaseIssued = true;
1084
+ cleanupBeforeReleaseDecided = await beforeCleanup(intent, current);
1085
+ }
1086
+ intent = retained();
1087
+ if (!cleanupBeforeReleaseDecided && intent?.phase !== "finished") return "unknown";
1088
+ if (!intent || !canonical(intent) ||
1089
+ (recipient!.kind === "leader" && !leader!.isApplied(intent)) ||
1090
+ intent.inputRelease !== "held" || intent.cleanup === "issued") return "unknown";
1091
+ let issued: Threads.TelegramWorkspaceLiveRebindIntent | undefined;
1092
+ try { issued = input.restoreStore.advanceLiveRebind(intent, "issue-input", authority); }
1093
+ catch { issued = retained(); }
1094
+ if (!issued || issued.inputRelease !== "issued" || !canonical(issued)) return "unknown";
1095
+ intent = issued;
1096
+ }
1054
1097
  if (recipient!.kind === "leader") {
1055
1098
  const expected = intent;
1056
1099
  if (
@@ -1060,14 +1103,21 @@ export function createTelegramLiveRebindCoordinator(input: {
1060
1103
  )
1061
1104
  return "protected";
1062
1105
  continuationIssued = true;
1063
- const canContinue = () =>
1064
- current() && canonical(expected) && leader!.isApplied(expected);
1106
+ const canContinue = () => {
1107
+ const observed = expected.inputRelease ? retained() : expected;
1108
+ return current() && !!observed &&
1109
+ (!expected.inputRelease || (observed.inputRelease === "issued" || observed.inputRelease === "accepted")) &&
1110
+ canonical(observed) && leader!.isApplied(observed);
1111
+ };
1065
1112
  if (leader!.complete) {
1066
1113
  leader!.complete([...messages], canContinue);
1114
+ // The detached native producer has taken this one input grant; its source ACK remains a separate observation.
1115
+ if (!acceptInput(intent)) return "unknown";
1067
1116
  return observeCommandCompletion();
1068
1117
  }
1069
1118
  if (!(await leader!.continue!(messages, canContinue)))
1070
1119
  return "unknown";
1120
+ if (!acceptInput(intent)) return "unknown";
1071
1121
  } else return await releaseFollower(intent);
1072
1122
  if (!current()) return "unknown";
1073
1123
  released = true;
@@ -1501,6 +1551,7 @@ export interface TelegramInboundRouteRuntimeDeps<
1501
1551
  hasWorkspaceRestoreAuthority?: () => boolean;
1502
1552
  /** Exact live root authority; capability advertisement alone cannot replace owned profile/session/recipient fences. */
1503
1553
  hasWorkspaceLiveRebindAuthority?: () => boolean;
1554
+ canUsePreDispatchRebind?: (recipient?: Threads.TelegramWorkspaceRestoreRecipient) => boolean;
1504
1555
  /** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
1505
1556
  inspectTemporaryThreadSources?: (
1506
1557
  target: Queue.TelegramQueueTarget,
@@ -1799,6 +1850,10 @@ export function createTelegramInboundRouteRuntime<
1799
1850
  TContext
1800
1851
  >
1801
1852
  >["canPrepareHeldCommand"];
1853
+ /** Agent-settled hint: wakes waiting leader cleanup retries early; every readiness gate is still sampled fresh. */
1854
+ onSessionSettled(ctx: TContext): void;
1855
+ /** Lifetime-fenced hint from drained publication/activity/delivery/API owners; never a cleanup grant. */
1856
+ onDeliverySettled(ctx: TContext): void;
1802
1857
  /** Fresh body-free recipient observation only; never reconstructs an attempt or grants cleanup. */
1803
1858
  observeLiveRebindLeaderWork(
1804
1859
  intent: Threads.TelegramWorkspaceLiveRebindIntent,
@@ -1888,6 +1943,8 @@ export function createTelegramInboundRouteRuntime<
1888
1943
  temporaryThread?: Threads.TelegramTemporaryThreadEntry;
1889
1944
  /** Source copies left in All (a menu-picked command); deleted once, when the input is consumed. */
1890
1945
  allTabCopies?: number[];
1946
+ /** The tab holds a forwarded copy of the threadless original, which then left All at once. */
1947
+ allTabCopiesForwarded?: true;
1891
1948
  /** Acknowledged same-tab membership stays protective when its reader disappears; never a dispatch/cleanup grant. */
1892
1949
  temporaryMembership?: true;
1893
1950
  /** Re-entrancy lock for one command route in flight; orthogonal to `phase`. */
@@ -1918,7 +1975,8 @@ export function createTelegramInboundRouteRuntime<
1918
1975
  /** Some Forward outcome is unknown; process-local, so restart-safe issuance remains a release gate. */
1919
1976
  | { kind: "forward-unknown" }
1920
1977
  | { kind: "cleanup"; cleanup: PendingRerouteCleanup }
1921
- | { kind: "finalizing"; message: string };
1978
+ /** `retainRecord`: a live rebind's chooser-scoped command reference outlives its deleted menu. */
1979
+ | { kind: "finalizing"; message: string; retainRecord?: true };
1922
1980
  /** The command a chooser routes, read from its own source exactly as the root heading named it. */
1923
1981
  const getPendingRerouteCommandName = (
1924
1982
  pending: PendingUnboundReroute,
@@ -2781,6 +2839,70 @@ export function createTelegramInboundRouteRuntime<
2781
2839
  pending.sourceTarget.chatId,
2782
2840
  pending.allTabCopies?.splice(0) ?? [],
2783
2841
  );
2842
+ /**
2843
+ * Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
2844
+ * holds its forwarded copy. Without one it stays where the operator can still read it.
2845
+ */
2846
+ const releaseAllTabCopiesIntoSurvivingTab = (
2847
+ pending: PendingUnboundReroute,
2848
+ ): Promise<void> => {
2849
+ if (pending.allTabCopiesForwarded) return deleteAllTabCopies(pending);
2850
+ pending.allTabCopies?.splice(0);
2851
+ return Promise.resolve();
2852
+ };
2853
+ /**
2854
+ * The Bot API files a threadless All input in All even when a mobile client first draws it in its new tab. One silent
2855
+ * best-effort forward moves it into the routing tab it opened, and only a confirmed copy lets the original leave All;
2856
+ * any failure keeps the original where the operator can still read it.
2857
+ */
2858
+ const moveAllTabSourceIntoTab = async (
2859
+ pending: PendingUnboundReroute,
2860
+ target: { chatId: number; threadId: number },
2861
+ isCurrent: () => boolean,
2862
+ ): Promise<number | undefined> => {
2863
+ const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
2864
+ if (messageIds.length === 0 || !deps.callApi || !isCurrent()) return;
2865
+ const route = {
2866
+ chat_id: target.chatId,
2867
+ message_thread_id: target.threadId,
2868
+ from_chat_id: pending.sourceTarget.chatId,
2869
+ disable_notification: true,
2870
+ };
2871
+ try {
2872
+ // An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
2873
+ const copies =
2874
+ messageIds.length === 1
2875
+ ? [
2876
+ await deps.callApi<{ message_id?: unknown }>("forwardMessage", {
2877
+ ...route,
2878
+ message_id: messageIds[0],
2879
+ }),
2880
+ ]
2881
+ : await deps.callApi<Array<{ message_id?: unknown }>>(
2882
+ "forwardMessages",
2883
+ { ...route, message_ids: messageIds },
2884
+ );
2885
+ const copyIds = Array.isArray(copies)
2886
+ ? copies.map((copy) => copy?.message_id)
2887
+ : [];
2888
+ if (
2889
+ copyIds.length !== messageIds.length ||
2890
+ !copyIds.every(
2891
+ (id): id is number =>
2892
+ typeof id === "number" && Number.isSafeInteger(id),
2893
+ )
2894
+ )
2895
+ return;
2896
+ pending.allTabCopiesForwarded = true;
2897
+ await deleteAllTabCopies(pending);
2898
+ return copyIds[0];
2899
+ } catch (error) {
2900
+ deps.recordRuntimeEvent?.("routing", error, {
2901
+ phase: "temporary-thread-source-forward",
2902
+ });
2903
+ return;
2904
+ }
2905
+ };
2784
2906
  const dismissRerouteChooserMessage = async (
2785
2907
  query: TCallbackQuery,
2786
2908
  assertExecutionCurrent?: () => void,
@@ -2810,6 +2932,36 @@ export function createTelegramInboundRouteRuntime<
2810
2932
  return false;
2811
2933
  }
2812
2934
  };
2935
+ /** Choosers are one-shot: a final outcome deletes the menu; only a failed delete leaves an inert, button-free notice. */
2936
+ const retireRerouteChooserMessage = async (
2937
+ chatId: number,
2938
+ messageId: number,
2939
+ fallbackText: string,
2940
+ assertCurrent?: () => void,
2941
+ ): Promise<void> => {
2942
+ if (deps.deleteMessage) {
2943
+ try {
2944
+ await deps.deleteMessage(chatId, messageId);
2945
+ return;
2946
+ } catch (error) {
2947
+ deps.recordRuntimeEvent?.("telegram", error, {
2948
+ phase: "reroute-chooser-delete",
2949
+ chatId,
2950
+ messageId,
2951
+ });
2952
+ }
2953
+ assertCurrent?.();
2954
+ }
2955
+ await deps.editInteractiveMessage?.(
2956
+ chatId,
2957
+ messageId,
2958
+ fallbackText,
2959
+ "html",
2960
+ {
2961
+ inline_keyboard: [],
2962
+ },
2963
+ );
2964
+ };
2813
2965
  const closeReroutedUnboundTopic = async (
2814
2966
  target: { chatId: number; threadId: number } | undefined,
2815
2967
  messageId: number | undefined,
@@ -2975,8 +3127,9 @@ export function createTelegramInboundRouteRuntime<
2975
3127
  continue;
2976
3128
  if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
2977
3129
  continue;
3130
+ // A sibling cancelled by Restore stays visible in the restored tab, like a desktop input typed there.
2978
3131
  if (await retireCancellationChooser(id, pending, current))
2979
- await deleteAllTabCopies(pending);
3132
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
2980
3133
  } catch (error) {
2981
3134
  deps.recordRuntimeEvent?.("routing", error, {
2982
3135
  phase: "temporary-thread-restore-sibling",
@@ -3706,6 +3859,25 @@ export function createTelegramInboundRouteRuntime<
3706
3859
  );
3707
3860
  if (!intent) continue;
3708
3861
  }
3862
+ // The chooser follows the success proof, never old-thread cleanup, which may stay protected indefinitely.
3863
+ const retireRestoreChoosers = async (): Promise<void> => {
3864
+ for (const [id, pending] of pendingUnboundReroutes) {
3865
+ if (pending.workspaceRestore?.operationId !== operationId)
3866
+ continue;
3867
+ pending.phase = { kind: "finalizing", message: "Restored" };
3868
+ if (pending.chooserMessageId === undefined) continue;
3869
+ assertSettlementCurrent();
3870
+ await retireRerouteChooserMessage(
3871
+ pending.sourceTarget.chatId,
3872
+ pending.chooserMessageId,
3873
+ "<b>✅ Message routed.</b>",
3874
+ assertSettlementCurrent,
3875
+ );
3876
+ assertSettlementCurrent();
3877
+ if (pendingUnboundReroutes.get(id) === pending)
3878
+ removePendingReroute(id);
3879
+ }
3880
+ };
3709
3881
  if (intent.routing?.cleanup === undefined) {
3710
3882
  if (
3711
3883
  !intent.request.source.updateIds.every((id) =>
@@ -3716,13 +3888,14 @@ export function createTelegramInboundRouteRuntime<
3716
3888
  )
3717
3889
  )
3718
3890
  continue;
3719
- // Positive settlement of every selected source is the success proof; old-thread cleanup may stay protected indefinitely.
3891
+ // Positive settlement of every selected source is the success proof.
3720
3892
  await disposeTemporaryThreadSiblingsAfterRestore(
3721
3893
  intent,
3722
3894
  ctx,
3723
3895
  settlementAuthority.isCurrent,
3724
3896
  );
3725
3897
  assertSettlementCurrent();
3898
+ await retireRestoreChoosers();
3726
3899
  if (
3727
3900
  isRerouteTargetProtected(
3728
3901
  intent.request.binding.target,
@@ -3758,27 +3931,7 @@ export function createTelegramInboundRouteRuntime<
3758
3931
  }
3759
3932
  if (!store.retire(intent, settlementAuthority)) continue;
3760
3933
  retired = true;
3761
- for (const [id, pending] of pendingUnboundReroutes) {
3762
- if (pending.workspaceRestore?.operationId !== operationId)
3763
- continue;
3764
- pending.phase = { kind: "finalizing", message: "Restored" };
3765
- if (
3766
- !deps.editInteractiveMessage ||
3767
- pending.chooserMessageId === undefined
3768
- )
3769
- continue;
3770
- assertSettlementCurrent();
3771
- await deps.editInteractiveMessage(
3772
- pending.sourceTarget.chatId,
3773
- pending.chooserMessageId,
3774
- "<b>✅ Message routed.</b>",
3775
- "html",
3776
- { inline_keyboard: [] },
3777
- );
3778
- assertSettlementCurrent();
3779
- if (pendingUnboundReroutes.get(id) === pending)
3780
- removePendingReroute(id);
3781
- }
3934
+ await retireRestoreChoosers();
3782
3935
  }
3783
3936
  },
3784
3937
  ),
@@ -4674,8 +4827,7 @@ export function createTelegramInboundRouteRuntime<
4674
4827
  .listLiveRebindings()
4675
4828
  .find((value) => value.request.operationId === request.operationId);
4676
4829
  if (
4677
- intent?.phase === "released" &&
4678
- !intent.cleanup &&
4830
+ intent && Threads.isTelegramLiveRebindInputAuthorized(intent) &&
4679
4831
  isDeepStrictEqual(intent.request, request) &&
4680
4832
  intent.operatorUserId === operatorUserId &&
4681
4833
  isDeepStrictEqual(intent.executor, {
@@ -5088,18 +5240,67 @@ export function createTelegramInboundRouteRuntime<
5088
5240
  name ||
5089
5241
  confirmation ||
5090
5242
  extension;
5243
+ const preDispatchCleanup =
5244
+ deps.runWorkspaceOperation !== undefined && deps.canUsePreDispatchRebind?.(recipient) === true;
5091
5245
  const createCoordinator = () =>
5092
5246
  createTelegramLiveRebindCoordinator({
5093
5247
  request,
5094
5248
  messages: selection.messages,
5095
5249
  restoreStore: store,
5096
5250
  threadStore: threads,
5251
+ // Same title as Workspace Restore: the display-mode title of the restored binding on its new tab.
5252
+ retitle: deps.callApi
5253
+ ? async (isCurrent) => {
5254
+ if (!isCurrent()) return;
5255
+ const expected = threads.getWorkspaceBindingByTarget(target, binding.sessionId);
5256
+ if (!expected || expected.bindingKey !== binding.bindingKey || expected.slot !== binding.slot)
5257
+ throw new Error("Live-rebind title binding is unavailable.");
5258
+ const callApi = deps.callApi!;
5259
+ const slot = binding.slot ?? "";
5260
+ const resolveTitle = () => deps.getDisplayTitle?.(expected.target) ??
5261
+ ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot);
5262
+ const title = resolveTitle();
5263
+ const bindingCurrent = (frame: Threads.TelegramWorkspaceThreadBinding) =>
5264
+ isCurrent() && deps.callApi === callApi &&
5265
+ isDeepStrictEqual(threads.getWorkspaceBindingByTarget(frame.target, frame.sessionId), frame);
5266
+ const assertTitle = () => {
5267
+ if (!bindingCurrent(expected) || resolveTitle() !== title)
5268
+ throw new Error("Live-rebind title authority changed.");
5269
+ };
5270
+ assertTitle();
5271
+ const renamed = await callApi.call(deps, "editForumTopic", {
5272
+ chat_id: expected.target.chatId,
5273
+ message_thread_id: expected.target.threadId,
5274
+ name: title,
5275
+ }, { maxAttempts: 1, assertAuthority: assertTitle });
5276
+ assertTitle();
5277
+ if (renamed !== true || !threads.setWorkspaceDisplayTitle(expected, title))
5278
+ throw new Error("Live-rebind title acknowledgement is unconfirmed.");
5279
+ const acknowledged = { ...expected, displayTitle: title };
5280
+ await threads.persist(() => bindingCurrent(acknowledged));
5281
+ if (!bindingCurrent(acknowledged))
5282
+ throw new Error("Live-rebind title publication authority changed.");
5283
+ }
5284
+ : undefined,
5097
5285
  authority: {
5098
5286
  executor: { instanceId, leaderEpoch: String(epoch) },
5099
5287
  operatorUserId,
5100
- isCurrent: current,
5288
+ isCurrent: () => current() && (!preDispatchCleanup || deps.canUsePreDispatchRebind?.(recipient) === true),
5101
5289
  },
5102
5290
  getRecipient,
5291
+ ...(preDispatchCleanup ? {
5292
+ async cleanupBeforeRelease(intent: Threads.TelegramWorkspaceLiveRebindIntent, isCurrent: () => boolean) {
5293
+ const delay = deps.liveRebindCleanupSchedule?.delaysMs[0] ?? 1_000;
5294
+ await new Promise<void>(resolve => { const timer = setTimeout(resolve, delay); timer.unref?.(); });
5295
+ if (!isCurrent()) return false;
5296
+ const result = await inspectLiveRebindRecipient(intent, ctx, isLeader ? "issue" : "follower-issue", isCurrent);
5297
+ if (!isCurrent()) return false;
5298
+ // A session-replacing producer cannot retain an old-session cleanup grant across its gateway.
5299
+ if (result?.status === "not-ready" && selection.confirmationCommand?.name === "new")
5300
+ return !!store.advanceLiveRebind(intent, "protect-old-target", { executor: intent.executor, operatorUserId, isCurrent });
5301
+ return result?.status === "not-ready" || result?.status === "finished" && result.recorded;
5302
+ },
5303
+ } : {}),
5103
5304
  follower: isLeader
5104
5305
  ? undefined
5105
5306
  : {
@@ -5141,9 +5342,9 @@ export function createTelegramInboundRouteRuntime<
5141
5342
  instanceId,
5142
5343
  leaderEpoch: String(epoch),
5143
5344
  }) &&
5144
- !intent.cleanup &&
5145
- (intent.phase === "rebound" ||
5146
- intent.phase === "released") &&
5345
+ (!intent.cleanup || intent.inputRelease !== undefined && intent.cleanup !== "issued") &&
5346
+ (intent.phase === "rebound" || intent.phase === "released" ||
5347
+ intent.phase === "finished" && intent.inputRelease !== undefined) &&
5147
5348
  isDeepStrictEqual(intent.recipient, {
5148
5349
  kind: recipient.kind,
5149
5350
  instanceId: recipient.instanceId,
@@ -5203,6 +5404,7 @@ export function createTelegramInboundRouteRuntime<
5203
5404
  });
5204
5405
  },
5205
5406
  isApplied,
5407
+
5206
5408
  ...(completeCommand
5207
5409
  ? {
5208
5410
  complete(
@@ -5395,11 +5597,13 @@ export function createTelegramInboundRouteRuntime<
5395
5597
  intent: Threads.TelegramWorkspaceLiveRebindIntent,
5396
5598
  ctx: TContext,
5397
5599
  mode: "issue" | "follower-issue",
5600
+ admittedOrigin?: () => boolean,
5398
5601
  ): Promise<TelegramLiveRebindCleanupIssue | undefined>;
5399
5602
  async function inspectLiveRebindRecipient(
5400
5603
  intent: Threads.TelegramWorkspaceLiveRebindIntent,
5401
5604
  ctx: TContext,
5402
5605
  mode: "work" | "cleanup" | "follower-cleanup" | "issue" | "follower-issue",
5606
+ admittedOrigin?: () => boolean,
5403
5607
  ): Promise<
5404
5608
  | Bus.TelegramBusLiveRebindWorkObservation
5405
5609
  | ThreadReconciler.TelegramLiveRebindCleanupPreparation
@@ -5427,8 +5631,9 @@ export function createTelegramInboundRouteRuntime<
5427
5631
  !run ||
5428
5632
  (issueMode && !callApi) ||
5429
5633
  deps.isContextActive?.(ctx) !== true
5430
- )
5634
+ ) {
5431
5635
  return undefined;
5636
+ }
5432
5637
  try {
5433
5638
  const expected = structuredClone(intent),
5434
5639
  { request, recipient, executor } = expected;
@@ -5534,6 +5739,7 @@ export function createTelegramInboundRouteRuntime<
5534
5739
  );
5535
5740
  };
5536
5741
  const current = () =>
5742
+ (!admittedOrigin || admittedOrigin()) &&
5537
5743
  deps.hasWorkspaceLiveRebindAuthority?.() === true &&
5538
5744
  deps.isContextActive?.(ctx) === true &&
5539
5745
  deps.workspaceRestoreRecipient === ports &&
@@ -5584,8 +5790,10 @@ export function createTelegramInboundRouteRuntime<
5584
5790
  };
5585
5791
  const sample = async () => {
5586
5792
  assertCanonical();
5587
- if (cleanupMode && protection.call(threads, expected) !== false)
5588
- return undefined;
5793
+ if (cleanupMode) {
5794
+ const protectedTarget = protection.call(threads, expected);
5795
+ if (protectedTarget !== false) return undefined;
5796
+ }
5589
5797
  const target = { ...request.binding.target };
5590
5798
  let value: Bus.TelegramBusLiveRebindWorkState | undefined;
5591
5799
  if (followerMode) {
@@ -5594,7 +5802,7 @@ export function createTelegramInboundRouteRuntime<
5594
5802
  instanceId: recipient.instanceId,
5595
5803
  sessionId: recipient.sessionId,
5596
5804
  recipientBindingKey: recipientJournalKey!,
5597
- mode: "observe",
5805
+ mode: expected.inputRelease === "held" ? "observe-held" : "observe",
5598
5806
  sourceUpdateIds: [...request.source.updateIds],
5599
5807
  slot: request.binding.slot!,
5600
5808
  target: { ...request.target },
@@ -5649,12 +5857,13 @@ export function createTelegramInboundRouteRuntime<
5649
5857
  );
5650
5858
  assertCanonical();
5651
5859
  if (cleanupMode) {
5860
+ const targetProtected = protection.call(threads, expected);
5652
5861
  const prepared = ThreadReconciler.prepareLiveRebindThreadCleanup({
5653
5862
  operationId: request.operationId,
5654
5863
  oldTarget: request.binding.target,
5655
5864
  recipientTarget: request.target,
5656
5865
  leaderEpoch: executor.leaderEpoch,
5657
- targetProtected: protection.call(threads, expected),
5866
+ targetProtected,
5658
5867
  work,
5659
5868
  });
5660
5869
  assertCanonical();
@@ -5677,21 +5886,16 @@ export function createTelegramInboundRouteRuntime<
5677
5886
  if (!current()) return undefined;
5678
5887
  if (issueMode) {
5679
5888
  // One admission spans the fresh sample, durable issue marker, single deletion and terminal record.
5680
- return await run(
5681
- {
5682
- operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
5683
- operationKind: "workspace.live-rebind-cleanup",
5684
- scopes: [{ kind: "profile" }],
5685
- },
5686
- async (): Promise<TelegramLiveRebindCleanupIssue | undefined> => {
5889
+ const execute = async (): Promise<TelegramLiveRebindCleanupIssue | undefined> => {
5687
5890
  const prepared = await sample();
5688
5891
  if (
5689
5892
  !prepared ||
5690
5893
  prepared.status !== "prepared" ||
5691
5894
  !current() ||
5692
5895
  protection.call(threads, expected) !== false
5693
- )
5896
+ ) {
5694
5897
  return { status: "not-ready" };
5898
+ }
5695
5899
  const authority = {
5696
5900
  executor: structuredClone(executor),
5697
5901
  operatorUserId: expected.operatorUserId,
@@ -5702,21 +5906,19 @@ export function createTelegramInboundRouteRuntime<
5702
5906
  "issue-cleanup",
5703
5907
  authority,
5704
5908
  );
5705
- if (!grant) return undefined;
5909
+ if (!grant) {
5910
+ return undefined;
5911
+ }
5706
5912
  const assertGranted = () => assertCanonical(grant);
5707
5913
  const cleanup = await ThreadReconciler.issueLiveRebindThreadCleanup(
5708
5914
  prepared,
5709
5915
  {
5710
5916
  assertCurrent: assertGranted,
5711
- deleteTopic: (target) =>
5712
- callApi!(
5713
- "deleteForumTopic",
5714
- {
5715
- chat_id: target.chatId,
5716
- message_thread_id: target.threadId,
5717
- },
5718
- { maxAttempts: 1, assertAuthority: assertGranted },
5719
- ),
5917
+ deleteTopic: (target) => callApi!(
5918
+ "deleteForumTopic",
5919
+ { chat_id: target.chatId, message_thread_id: target.threadId },
5920
+ { maxAttempts: 1, assertAuthority: assertGranted },
5921
+ ),
5720
5922
  classifyFailure: classifyLiveRebindCleanupFailure,
5721
5923
  },
5722
5924
  );
@@ -5734,8 +5936,12 @@ export function createTelegramInboundRouteRuntime<
5734
5936
  cleanup,
5735
5937
  recorded,
5736
5938
  };
5737
- },
5738
- );
5939
+ };
5940
+ // Pre-dispatch calls are private to the chooser callback already holding the same profile admission/gate.
5941
+ return admittedOrigin ? await execute() : await run({
5942
+ operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
5943
+ operationKind: "workspace.live-rebind-cleanup", scopes: [{ kind: "profile" }],
5944
+ }, execute);
5739
5945
  }
5740
5946
  const observation = await run(
5741
5947
  {
@@ -5788,6 +5994,22 @@ export function createTelegramInboundRouteRuntime<
5788
5994
  string,
5789
5995
  ReturnType<typeof setTimeout> | "running"
5790
5996
  >();
5997
+ // One hint slot per existing leader chain; a running attempt retains at most one coalesced recheck.
5998
+ const liveRebindCleanupWakers = new Map<string, (reason: "session-settled" | "delivery-drained") => void>();
5999
+ const captureLiveRebindCleanupScope = (ctx: TContext): (() => boolean) => {
6000
+ try {
6001
+ const generation = deps.getSessionGeneration?.(), instanceId = deps.getCurrentInstanceId?.(),
6002
+ epoch = deps.getCurrentLeaderEpoch?.(), store = deps.getWorkspaceRestoreStore?.(),
6003
+ operator = deps.configStore.getAllowedUserId();
6004
+ return () => {
6005
+ try {
6006
+ return deps.isContextActive?.(ctx) === true && deps.getSessionGeneration?.() === generation &&
6007
+ deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
6008
+ deps.getWorkspaceRestoreStore?.() === store && deps.configStore.getAllowedUserId() === operator;
6009
+ } catch { return false; }
6010
+ };
6011
+ } catch { return () => false; }
6012
+ };
5791
6013
  /** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
5792
6014
  const terminalizeUnissuedLiveRebindCleanup = async (
5793
6015
  operationId: string,
@@ -5848,24 +6070,48 @@ export function createTelegramInboundRouteRuntime<
5848
6070
  ctx: TContext,
5849
6071
  startedAtMs = Date.now(),
5850
6072
  attempt = 0,
6073
+ current = captureLiveRebindCleanupScope(ctx),
5851
6074
  ): void => {
5852
6075
  const pacing = deps.liveRebindCleanupSchedule;
5853
6076
  const chain = liveRebindCleanupChains.get(operationId);
5854
6077
  if (
5855
6078
  !pacing ||
5856
6079
  (attempt === 0 ? chain !== undefined : chain !== "running") ||
5857
- deps.isContextActive?.(ctx) !== true
6080
+ !current()
5858
6081
  ) {
5859
- if (attempt > 0 && chain === "running")
6082
+ if (attempt > 0 && chain === "running") {
5860
6083
  liveRebindCleanupChains.delete(operationId);
6084
+ liveRebindCleanupWakers.delete(operationId);
6085
+ }
5861
6086
  return;
5862
6087
  }
5863
- const timer = setTimeout(() => {
6088
+ let pendingWake: "session-settled" | "delivery-drained" | undefined;
6089
+ const wake = (reason: "session-settled" | "delivery-drained") => {
6090
+ if (!current()) return;
6091
+ if (liveRebindCleanupChains.get(operationId) === "running") {
6092
+ pendingWake = reason; return;
6093
+ }
6094
+ if (liveRebindCleanupChains.get(operationId) !== timer) return;
6095
+ if (reason === "delivery-drained") {
6096
+ let idle: unknown;
6097
+ try { idle = deps.isIdle?.(ctx); } catch { /* A missing idle observation is not a reason to hurry cleanup. */ }
6098
+ if (idle !== true) {
6099
+ // A streamed technical task can drain between deltas while Pi still runs. Keep one hint, not a polling loop.
6100
+ pendingWake = reason;
6101
+ return;
6102
+ }
6103
+ }
6104
+ clearTimeout(timer);
6105
+ attemptNow();
6106
+ };
6107
+ const attemptNow = () => {
5864
6108
  if (liveRebindCleanupChains.get(operationId) !== timer) return;
6109
+ pendingWake = undefined; // This fresh timer/settlement attempt consumes any previously deferred hint.
5865
6110
  liveRebindCleanupChains.set(operationId, "running");
6111
+ if (role === "leader") liveRebindCleanupWakers.set(operationId, wake);
5866
6112
  let continued = false;
5867
6113
  const task = (async () => {
5868
- if (deps.isContextActive?.(ctx) !== true) return;
6114
+ if (!current()) return;
5869
6115
  const intent = deps
5870
6116
  .getWorkspaceRestoreStore?.()
5871
6117
  ?.listLiveRebindings()
@@ -5878,11 +6124,12 @@ export function createTelegramInboundRouteRuntime<
5878
6124
  : await issueLiveRebindFollowerCleanup(intent, ctx);
5879
6125
  if (
5880
6126
  result?.status === "finished" ||
5881
- deps.isContextActive?.(ctx) !== true
6127
+ !current()
5882
6128
  )
5883
6129
  return;
5884
- if (Date.now() - startedAtMs >= pacing.windowMs)
6130
+ if (Date.now() - startedAtMs >= pacing.windowMs) {
5885
6131
  return terminalizeUnissuedLiveRebindCleanup(operationId, ctx);
6132
+ }
5886
6133
  continued = true;
5887
6134
  scheduleLiveRebindCleanup(
5888
6135
  operationId,
@@ -5890,7 +6137,9 @@ export function createTelegramInboundRouteRuntime<
5890
6137
  ctx,
5891
6138
  startedAtMs,
5892
6139
  attempt + 1,
6140
+ current,
5893
6141
  );
6142
+ if (pendingWake) liveRebindCleanupWakers.get(operationId)?.(pendingWake);
5894
6143
  })()
5895
6144
  .catch((error) =>
5896
6145
  deps.recordRuntimeEvent?.("routing", error, {
@@ -5901,15 +6150,29 @@ export function createTelegramInboundRouteRuntime<
5901
6150
  if (
5902
6151
  !continued &&
5903
6152
  liveRebindCleanupChains.get(operationId) === "running"
5904
- )
6153
+ ) {
5905
6154
  liveRebindCleanupChains.delete(operationId);
6155
+ liveRebindCleanupWakers.delete(operationId);
6156
+ }
5906
6157
  });
5907
6158
  restoreSettlementTasks.add(task);
5908
6159
  void task.finally(() => restoreSettlementTasks.delete(task));
5909
- }, pacing.delaysMs[attempt] ?? pacing.intervalMs);
6160
+ };
6161
+ const timer = setTimeout(
6162
+ attemptNow,
6163
+ pacing.delaysMs[attempt] ?? pacing.intervalMs,
6164
+ );
5910
6165
  timer.unref?.();
5911
6166
  liveRebindCleanupChains.set(operationId, timer);
6167
+ // The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
6168
+ if (role === "leader" && attempt > 0) liveRebindCleanupWakers.set(operationId, wake);
5912
6169
  };
6170
+ const wakeLiveRebindCleanup = (ctx: TContext, reason: "session-settled" | "delivery-drained"): void => {
6171
+ if (deps.isContextActive?.(ctx) !== true) return;
6172
+ for (const wake of [...liveRebindCleanupWakers.values()]) wake(reason);
6173
+ };
6174
+ const onSessionSettled = (ctx: TContext): void => wakeLiveRebindCleanup(ctx, "session-settled");
6175
+ const onDeliverySettled = (ctx: TContext): void => wakeLiveRebindCleanup(ctx, "delivery-drained");
5913
6176
  const restoreWorkspace = deps.workspaceRestoreRecipient
5914
6177
  ? async (input: {
5915
6178
  operationId: string;
@@ -6446,6 +6709,7 @@ export function createTelegramInboundRouteRuntime<
6446
6709
  assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(
6447
6710
  query,
6448
6711
  ),
6712
+ retainRecord = false,
6449
6713
  ): Promise<void> => {
6450
6714
  assertExecutionCurrent();
6451
6715
  const dismissed = await dismissRerouteChooserMessage(
@@ -6454,12 +6718,18 @@ export function createTelegramInboundRouteRuntime<
6454
6718
  );
6455
6719
  assertExecutionCurrent();
6456
6720
  if (dismissed) {
6457
- removePendingReroute(rerouteId);
6721
+ // A retained record no longer matches any chooser, so a late duplicate tap reads as expired.
6722
+ if (retainRecord) pending.chooserMessageId = undefined;
6723
+ else removePendingReroute(rerouteId);
6458
6724
  await deps.answerCallbackQuery(query.id, successMessage);
6459
6725
  await deleteAllTabCopies(pending);
6460
6726
  return;
6461
6727
  }
6462
- pending.phase = { kind: "finalizing", message: successMessage };
6728
+ pending.phase = {
6729
+ kind: "finalizing",
6730
+ message: successMessage,
6731
+ ...(retainRecord ? { retainRecord: true as const } : {}),
6732
+ };
6463
6733
  await deps.answerCallbackQuery(
6464
6734
  query.id,
6465
6735
  `${successMessage}; tap again to clear the menu`,
@@ -7017,16 +7287,14 @@ export function createTelegramInboundRouteRuntime<
7017
7287
  if (
7018
7288
  !isPendingRerouteCancelled(pending) ||
7019
7289
  !isCurrent() ||
7020
- !deps.editInteractiveMessage ||
7290
+ (!deps.deleteMessage && !deps.editInteractiveMessage) ||
7021
7291
  pending.chooserMessageId === undefined
7022
7292
  )
7023
7293
  return false;
7024
- await deps.editInteractiveMessage(
7294
+ await retireRerouteChooserMessage(
7025
7295
  pending.sourceTarget.chatId,
7026
7296
  pending.chooserMessageId,
7027
7297
  "<b>⛔️ Routing cancelled.</b>",
7028
- "html",
7029
- { inline_keyboard: [] },
7030
7298
  );
7031
7299
  if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
7032
7300
  return false;
@@ -7523,6 +7791,7 @@ export function createTelegramInboundRouteRuntime<
7523
7791
  const result = source.expire();
7524
7792
  if (!result) return;
7525
7793
  // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
7794
+ let movedIntoTab = false;
7526
7795
  for (const [id, pending] of pendingUnboundReroutes) {
7527
7796
  if (
7528
7797
  !Updates.collectTelegramAdmissionSourceUpdateIds(
@@ -7530,6 +7799,7 @@ export function createTelegramInboundRouteRuntime<
7530
7799
  ).includes(source.original.updateId)
7531
7800
  )
7532
7801
  continue;
7802
+ movedIntoTab ||= pending.allTabCopiesForwarded === true;
7533
7803
  removePendingReroute(id);
7534
7804
  if (
7535
7805
  signal.aborted ||
@@ -7562,13 +7832,15 @@ export function createTelegramInboundRouteRuntime<
7562
7832
  // never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
7563
7833
  if (temporary?.target && runtimeCurrent()) {
7564
7834
  scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
7565
- await deleteAllTabMessages(
7566
- temporary.target.chatId,
7567
- getTelegramAllTabSourceMessageIds(
7568
- [message!],
7835
+ // A moved original already left All with its forward; only an unmoved one is deleted here.
7836
+ if (!movedIntoTab)
7837
+ await deleteAllTabMessages(
7569
7838
  temporary.target.chatId,
7570
- ),
7571
- );
7839
+ getTelegramAllTabSourceMessageIds(
7840
+ [message!],
7841
+ temporary.target.chatId,
7842
+ ),
7843
+ );
7572
7844
  }
7573
7845
  },
7574
7846
  );
@@ -8172,6 +8444,7 @@ export function createTelegramInboundRouteRuntime<
8172
8444
  query,
8173
8445
  pending.phase.message,
8174
8446
  assertExecutionCurrent,
8447
+ pending.phase.retainRecord === true,
8175
8448
  );
8176
8449
  return true;
8177
8450
  }
@@ -8549,10 +8822,10 @@ export function createTelegramInboundRouteRuntime<
8549
8822
  // Keep the captured plan even if the source owner's issued selection result is lost.
8550
8823
  pending.liveRebind ??= selection;
8551
8824
  if (!(await selectPendingDestination())) return true;
8825
+ const operationId = selection.coordinator.operationId;
8552
8826
  if (current()) outcome = await selection.coordinator.advance();
8553
8827
  // Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
8554
8828
  // still be pending while every attempt samples current work afresh.
8555
- const operationId = selection.coordinator.operationId;
8556
8829
  const row =
8557
8830
  outcome === "protected"
8558
8831
  ? undefined
@@ -8560,13 +8833,16 @@ export function createTelegramInboundRouteRuntime<
8560
8833
  .getWorkspaceRestoreStore?.()
8561
8834
  ?.listLiveRebindings()
8562
8835
  .find((value) => value.request.operationId === operationId);
8563
- if (row?.phase === "released" && row.cleanup === undefined)
8836
+ if (row?.phase === "released" && row.cleanup === undefined &&
8837
+ (row.inputRelease === undefined || row.inputRelease === "accepted")) {
8564
8838
  scheduleLiveRebindCleanup(
8565
8839
  operationId,
8566
8840
  selection.record.owner?.kind === "leader" ? "leader" : "follower",
8567
8841
  ctx,
8568
8842
  );
8569
- if (outcome === "released") await deleteAllTabCopies(pending);
8843
+ }
8844
+ if (outcome === "released")
8845
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
8570
8846
  } catch (error) {
8571
8847
  deps.recordRuntimeEvent?.("routing", error, {
8572
8848
  phase: "live-rebind-chooser",
@@ -8575,31 +8851,53 @@ export function createTelegramInboundRouteRuntime<
8575
8851
  selection.isCurrent = undefined;
8576
8852
  }
8577
8853
  assertExecutionCurrent();
8578
- await deps.answerCallbackQuery(
8579
- query.id,
8580
- outcome === "released"
8581
- ? selection.extensionKind === "generated-prompt"
8582
- ? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
8583
- : selection.extensionCommand
8584
- ? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
8585
- : selection.nameCommand
8586
- ? selection.nameCommand.args.trim()
8587
- ? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
8588
- : "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
8589
- : selection.confirmationCommand
8590
- ? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
8591
- : selection.helpCommand
8592
- ? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
8593
- : selection.stopCommand
8594
- ? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
8595
- : selection.nextCommand
8596
- ? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
8597
- : selection.menuCommand || selection.abortCommand
8598
- ? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
8599
- : selection.coordinator?.selectedCommandReference()
8600
- ? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
8601
- : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
8602
- : "Live rebind is unconfirmed; this choice never replays issued input",
8854
+ if (outcome !== "released") {
8855
+ await deps.answerCallbackQuery(
8856
+ query.id,
8857
+ "Live rebind is unconfirmed; this choice never replays issued input",
8858
+ );
8859
+ return true;
8860
+ }
8861
+ const cleanupRow = deps.getWorkspaceRestoreStore?.()?.listLiveRebindings().find(value => value.request.operationId === selection.coordinator?.operationId);
8862
+ const cleanupResult = cleanupRow?.inputRelease && cleanupRow.cleanup !== undefined
8863
+ ? cleanupRow.cleanup === "confirmed" ? "old-Thread removal confirmed"
8864
+ : cleanupRow.cleanup === "unknown" || cleanupRow.cleanup === "issued" ? "old-Thread removal unconfirmed; deletion will not repeat"
8865
+ : cleanupRow.cleanup === "failed" ? "old-Thread removal failed" : "old-Thread removal was not issued"
8866
+ : undefined;
8867
+ const commandDisposed = !!(selection.extensionCommand && selection.extensionKind !== "generated-prompt" || selection.nameCommand ||
8868
+ selection.confirmationCommand || selection.helpCommand || selection.stopCommand || selection.nextCommand ||
8869
+ selection.menuCommand || selection.abortCommand || selection.coordinator?.selectedCommandReference());
8870
+ // The tab now belongs to the rebound thread; only the one-shot chooser message goes.
8871
+ await finalizePendingReroute(
8872
+ parsed.rerouteId,
8873
+ pending,
8874
+ query,
8875
+ cleanupResult
8876
+ ? commandDisposed ? `Command source disposal confirmed; ${cleanupResult}; delivery and follow-on effects remain independent`
8877
+ : `Input queued in the rebound Thread; ${cleanupResult}`
8878
+ : selection.extensionKind === "generated-prompt"
8879
+ ? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
8880
+ : selection.extensionCommand
8881
+ ? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
8882
+ : selection.nameCommand
8883
+ ? selection.nameCommand.args.trim()
8884
+ ? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
8885
+ : "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
8886
+ : selection.confirmationCommand
8887
+ ? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
8888
+ : selection.helpCommand
8889
+ ? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
8890
+ : selection.stopCommand
8891
+ ? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
8892
+ : selection.nextCommand
8893
+ ? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
8894
+ : selection.menuCommand || selection.abortCommand
8895
+ ? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
8896
+ : selection.coordinator?.selectedCommandReference()
8897
+ ? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
8898
+ : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed",
8899
+ assertExecutionCurrent,
8900
+ true,
8603
8901
  );
8604
8902
  return true;
8605
8903
  }
@@ -8611,6 +8909,8 @@ export function createTelegramInboundRouteRuntime<
8611
8909
  messages: [...pending.messages],
8612
8910
  });
8613
8911
  let active = true;
8912
+ // Delivery proof for the toast only; chooser retirement still waits for the positive settlement that follows.
8913
+ let delivered = false;
8614
8914
  const ownerUserId = deps.configStore.getAllowedUserId();
8615
8915
  const leaderEpoch = deps.getCurrentLeaderEpoch?.();
8616
8916
  const admissionScope = deps.getAdmissionScope?.();
@@ -8676,7 +8976,8 @@ export function createTelegramInboundRouteRuntime<
8676
8976
  : routed;
8677
8977
  await dispatchPendingRerouteMessages(pending, messages, ctx);
8678
8978
  assertRecipientCurrent();
8679
- await deleteAllTabCopies(pending);
8979
+ delivered = true;
8980
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
8680
8981
  return true;
8681
8982
  }
8682
8983
  const forwarded = await forwardPendingRerouteMessages(
@@ -8687,7 +8988,10 @@ export function createTelegramInboundRouteRuntime<
8687
8988
  assertRecipientCurrent,
8688
8989
  recordForwardAcceptance,
8689
8990
  );
8690
- if (forwarded) await deleteAllTabCopies(pending);
8991
+ if (forwarded) {
8992
+ delivered = true;
8993
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
8994
+ }
8691
8995
  return forwarded;
8692
8996
  },
8693
8997
  });
@@ -8698,7 +9002,7 @@ export function createTelegramInboundRouteRuntime<
8698
9002
  if (!selectionCurrent()) return true;
8699
9003
  await deps.answerCallbackQuery(
8700
9004
  query.id,
8701
- "Restore unconfirmed; not resending",
9005
+ delivered ? "Restored" : "Restore unconfirmed; not resending",
8702
9006
  );
8703
9007
  return true;
8704
9008
  }
@@ -10080,7 +10384,10 @@ export function createTelegramInboundRouteRuntime<
10080
10384
  }
10081
10385
  return undefined;
10082
10386
  };
10083
- /** A fresh implicit native tab whose name the All input starts with, observed just before it in the same exact scope. */
10387
+ /**
10388
+ * A fresh implicit native tab observed just before the All input in the same exact scope: mobile names it after
10389
+ * the input, while other clients may use a generic name, so the immediately preceding creation also qualifies.
10390
+ */
10084
10391
  const findImplicitCreationForAllInput = (
10085
10392
  message: TMessage,
10086
10393
  updateId: number,
@@ -10098,8 +10405,8 @@ export function createTelegramInboundRouteRuntime<
10098
10405
  | undefined;
10099
10406
  for (const [key, implicit] of implicitThreadCreations) {
10100
10407
  if (
10101
- implicit.name &&
10102
- text.startsWith(implicit.name) &&
10408
+ ((implicit.name && text.startsWith(implicit.name)) ||
10409
+ implicit.updateId === updateId - 1) &&
10103
10410
  implicit.updateId < updateId &&
10104
10411
  implicit.createdAtSec > 0 &&
10105
10412
  message.date !== undefined &&
@@ -10122,15 +10429,23 @@ export function createTelegramInboundRouteRuntime<
10122
10429
  * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
10123
10430
  * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
10124
10431
  */
10432
+ /**
10433
+ * Opens (or adopts) the routing tab for input sent from All. One text message may carry a command; anything else,
10434
+ * a voice note, a file or a coalesced album, is held untouched as one prompt.
10435
+ */
10125
10436
  const sendAllTabTemporaryInputChooser = async (
10126
10437
  command: Commands.ParsedTelegramCommand | undefined,
10127
10438
  commandText: string,
10128
- message: TMessage,
10439
+ messages: readonly TMessage[],
10129
10440
  ctx: TContext,
10130
10441
  ): Promise<boolean> => {
10131
- const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([
10132
- message,
10133
- ]);
10442
+ const message = messages[0];
10443
+ if (!message) return false;
10444
+ const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds([
10445
+ ...messages,
10446
+ ]).sort((a, b) => a - b);
10447
+ const updateId = updateIds[0];
10448
+ const group = { journalBindingKey: "", updateIds };
10134
10449
  const execution = Updates.getTelegramUpdateExecutionFence(message);
10135
10450
  const cap = captureTemporaryThreadAuthority(
10136
10451
  ctx,
@@ -10141,15 +10456,20 @@ export function createTelegramInboundRouteRuntime<
10141
10456
  !deps.runWorkspaceOperation ||
10142
10457
  !deps.callApi ||
10143
10458
  updateId === undefined ||
10144
- message.chat.type !== "private" ||
10145
- message.chat.id !== cap.operatorUserId ||
10146
- message.from?.id !== cap.operatorUserId ||
10459
+ updateIds.length !== messages.length ||
10460
+ messages.some(
10461
+ (held) =>
10462
+ held.chat.type !== "private" ||
10463
+ held.chat.id !== cap.operatorUserId ||
10464
+ held.from?.id !== cap.operatorUserId,
10465
+ ) ||
10147
10466
  !cap.isCurrent()
10148
10467
  )
10149
10468
  return false;
10150
10469
  const { store, operatorUserId, journalBindingKey, epoch, authority } = cap,
10151
10470
  current = cap.isCurrent;
10152
10471
  const source = { journalBindingKey, updateId };
10472
+ group.journalBindingKey = journalBindingKey;
10153
10473
  const find = () =>
10154
10474
  store
10155
10475
  .listTemporaryThreads()
@@ -10159,6 +10479,9 @@ export function createTelegramInboundRouteRuntime<
10159
10479
  entry.source.updateId === updateId,
10160
10480
  );
10161
10481
  let entry: Threads.TelegramTemporaryThreadEntry | undefined;
10482
+ // A tab opened by this call, by the bot or adopted from the client, gets the input's one forwarded copy; re-entry
10483
+ // after a restart finds its tab already prepared and never forwards again.
10484
+ let openedNow = false;
10162
10485
  await deps.runWorkspaceOperation(
10163
10486
  {
10164
10487
  operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
@@ -10175,10 +10498,15 @@ export function createTelegramInboundRouteRuntime<
10175
10498
  }
10176
10499
  // Mobile clients sending from All first create a native tab named after the input, then deliver
10177
10500
  // the input itself to All: adopt that tab rather than leaving it behind next to a second one.
10178
- const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
10501
+ const native = findImplicitCreationForAllInput(
10502
+ message,
10503
+ updateId,
10504
+ cap,
10505
+ ctx,
10506
+ );
10179
10507
  if (native) {
10180
10508
  const adopted = store.registerImplicitTemporaryThread(
10181
- { journalBindingKey, updateIds: [updateId] },
10509
+ group,
10182
10510
  native.implicit.target,
10183
10511
  randomBytes(16).toString("hex"),
10184
10512
  {
@@ -10191,6 +10519,7 @@ export function createTelegramInboundRouteRuntime<
10191
10519
  if (adopted && current()) {
10192
10520
  implicitThreadCreations.delete(native.key);
10193
10521
  entry = adopted;
10522
+ openedNow = true;
10194
10523
  // Adopted native tabs share the routing name; a failed rename never blocks routing.
10195
10524
  try {
10196
10525
  await deps.callApi!("editForumTopic", {
@@ -10210,6 +10539,7 @@ export function createTelegramInboundRouteRuntime<
10210
10539
  source,
10211
10540
  randomBytes(16).toString("hex"),
10212
10541
  authority,
10542
+ group,
10213
10543
  );
10214
10544
  entry = reservation?.entry;
10215
10545
  if (!reservation?.reserved || !current()) return;
@@ -10237,31 +10567,33 @@ export function createTelegramInboundRouteRuntime<
10237
10567
  threadId <= 0
10238
10568
  )
10239
10569
  return;
10240
- entry =
10241
- store.acknowledgeTemporaryThread(
10242
- reservation.entry,
10243
- { chatId: operatorUserId, threadId },
10244
- authority,
10245
- ) ?? entry;
10570
+ const acknowledged = store.acknowledgeTemporaryThread(
10571
+ reservation.entry,
10572
+ { chatId: operatorUserId, threadId },
10573
+ authority,
10574
+ );
10575
+ openedNow = !!acknowledged;
10576
+ entry = acknowledged ?? entry;
10246
10577
  },
10247
10578
  );
10248
10579
  if (!current() || !entry)
10249
10580
  throw new Error(
10250
10581
  "Temporary Thread authority changed; the input remains retryable.",
10251
10582
  );
10252
- const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
10253
- ...message,
10254
- text: commandText,
10255
- caption: undefined,
10256
- } as TMessage);
10583
+ // Only a lone text message is rewritten for command detection; media keeps its caption and payload.
10584
+ const held: TMessage[] =
10585
+ messages.length === 1 && typeof message.text === "string"
10586
+ ? [
10587
+ Updates.carryTelegramUpdateExecutionFence(message, {
10588
+ ...message,
10589
+ text: commandText,
10590
+ caption: undefined,
10591
+ } as TMessage),
10592
+ ]
10593
+ : [...messages];
10257
10594
  if (entry.phase !== "created" || !entry.target) {
10258
- Updates.reportTelegramUpdateDeferred(commandMessage);
10259
- await deps.sendTextReply(
10260
- message.chat.id,
10261
- message.message_id,
10262
- "<b>⚠️ The routing tab for this command could not be confirmed.</b> It stays held and was not sent to Pi.",
10263
- { parseMode: "HTML" },
10264
- );
10595
+ for (const input of held) Updates.reportTelegramUpdateDeferred(input);
10596
+ deps.recordRuntimeEvent?.("routing", new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR), { phase: "temporary-thread-unconfirmed" });
10265
10597
  return true;
10266
10598
  }
10267
10599
  const target = entry.target;
@@ -10270,23 +10602,22 @@ export function createTelegramInboundRouteRuntime<
10270
10602
  deps.getLiveThreadTargets?.(),
10271
10603
  );
10272
10604
  const rerouteId = storePendingUnboundReroute(
10273
- [commandMessage],
10605
+ held,
10274
10606
  command ? "command" : "prompt",
10275
10607
  target,
10276
10608
  );
10277
10609
  const pending = pendingUnboundReroutes.get(rerouteId)!;
10278
10610
  pending.temporaryThread = entry;
10279
10611
  pending.allTabCopies = getTelegramAllTabSourceMessageIds(
10280
- [message],
10612
+ messages,
10281
10613
  target.chatId,
10282
10614
  );
10283
- Updates.reportTelegramUpdateDeferred(commandMessage);
10615
+ for (const input of held) Updates.reportTelegramUpdateDeferred(input);
10284
10616
  // Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
10285
10617
  if (
10286
10618
  deps.editInteractiveMessage &&
10287
- Updates.supportsTelegramDeferredAbandonment(
10288
- commandMessage,
10289
- journalBindingKey,
10619
+ held.every((input) =>
10620
+ Updates.supportsTelegramDeferredAbandonment(input, journalBindingKey),
10290
10621
  )
10291
10622
  ) {
10292
10623
  pending.abandonment = {
@@ -10298,6 +10629,9 @@ export function createTelegramInboundRouteRuntime<
10298
10629
  pending.rootChooserText = formatTelegramTemporaryThreadChooserText(
10299
10630
  command?.name,
10300
10631
  );
10632
+ const tabCopyId = openedNow
10633
+ ? await moveAllTabSourceIntoTab(pending, target, current)
10634
+ : undefined;
10301
10635
  try {
10302
10636
  if (!deps.sendInteractiveMessage)
10303
10637
  throw new Error("Temporary Thread chooser publication is unavailable.");
@@ -10309,7 +10643,9 @@ export function createTelegramInboundRouteRuntime<
10309
10643
  canCancel: !!pending.abandonment,
10310
10644
  getDisplayTitle: deps.getDisplayTitle,
10311
10645
  }),
10312
- { target },
10646
+ tabCopyId === undefined
10647
+ ? { target }
10648
+ : { target, replyToMessageId: tabCopyId },
10313
10649
  );
10314
10650
  rememberRerouteChooser(rerouteId, chooserId);
10315
10651
  } catch (error) {
@@ -10318,96 +10654,14 @@ export function createTelegramInboundRouteRuntime<
10318
10654
  }
10319
10655
  return true;
10320
10656
  };
10321
- const sendAllTabCommandChooser = async (
10322
- command: Commands.ParsedTelegramCommand,
10323
- commandText: string,
10324
- message: TMessage,
10325
- options: {
10326
- replyToSource?: boolean;
10327
- target?: Queue.TelegramQueueTarget;
10328
- } = {},
10329
- ): Promise<boolean> => {
10330
- if (!deps.threadStore) return false;
10331
- const records = deps.threadStore.list();
10332
- const activeRecords = getTelegramRoutableThreadRecords(
10333
- records,
10334
- deps.getLiveThreadTargets?.(),
10335
- );
10336
- if (activeRecords.length === 0) return false;
10337
- const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
10338
- ...message,
10339
- text: commandText,
10340
- caption: undefined,
10341
- } as TMessage);
10342
- const rerouteId = storePendingUnboundReroute([commandMessage], "command");
10343
- Updates.reportTelegramUpdateDeferred(commandMessage);
10344
- const text = formatTelegramAllTabMenuChooserText(command.name);
10345
- pendingUnboundReroutes.get(rerouteId)!.rootChooserText = text;
10346
- const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
10347
- rerouteId,
10348
- activeRecords,
10349
- {
10350
- canRestore: typeof message.message_thread_id === "number",
10351
- getDisplayTitle: deps.getDisplayTitle,
10352
- },
10353
- );
10354
- let chooserId: number | undefined;
10355
- try {
10356
- if (deps.sendInteractiveMessage) {
10357
- chooserId = await deps.sendInteractiveMessage(
10358
- message.chat.id,
10359
- text,
10360
- "html",
10361
- replyMarkup,
10362
- options.target || options.replyToSource
10363
- ? {
10364
- ...(options.target ? { target: options.target } : {}),
10365
- ...(options.replyToSource
10366
- ? { replyToMessageId: message.message_id }
10367
- : {}),
10368
- }
10369
- : undefined,
10370
- );
10371
- } else if (deps.callApi) {
10372
- const chooser = await deps.callApi<{ message_id?: number }>(
10373
- "sendMessage",
10374
- {
10375
- chat_id: message.chat.id,
10376
- text,
10377
- parse_mode: "HTML",
10378
- reply_markup: replyMarkup,
10379
- ...(typeof options.target?.threadId === "number"
10380
- ? { message_thread_id: options.target.threadId }
10381
- : {}),
10382
- ...(options.replyToSource
10383
- ? {
10384
- reply_parameters: {
10385
- message_id: message.message_id,
10386
- allow_sending_without_reply: true,
10387
- },
10388
- }
10389
- : {}),
10390
- },
10391
- );
10392
- chooserId = chooser?.message_id;
10393
- } else {
10394
- chooserId = await deps.sendTextReply(
10395
- message.chat.id,
10396
- message.message_id,
10397
- text,
10398
- {
10399
- parseMode: "HTML",
10400
- target: options.target,
10401
- },
10402
- );
10403
- }
10404
- } catch (error) {
10405
- removePendingReroute(rerouteId);
10406
- throw error;
10407
- }
10408
- rememberRerouteChooser(rerouteId, chooserId);
10409
- Updates.reportTelegramUpdateCompleted(commandMessage);
10410
- return true;
10657
+ /** A coalesced album from All requires one routing tab; failure cannot answer in All or discard its source. */
10658
+ const routeAllTabAlbum = async (
10659
+ messages: TMessage[],
10660
+ ctx: TContext,
10661
+ ): Promise<void> => {
10662
+ if (await sendAllTabTemporaryInputChooser(undefined, "", messages, ctx))
10663
+ return;
10664
+ throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
10411
10665
  };
10412
10666
  const dispatchCommandOrPrompt =
10413
10667
  Commands.createTelegramCommandOrPromptDispatcher<TMessage, TContext>({
@@ -10705,7 +10959,10 @@ export function createTelegramInboundRouteRuntime<
10705
10959
  chatId: lifecycle.target.chatId,
10706
10960
  threadId: lifecycle.target.threadId!,
10707
10961
  },
10708
- name: "name" in created && typeof created.name === "string" ? created.name : "",
10962
+ name:
10963
+ "name" in created && typeof created.name === "string"
10964
+ ? created.name
10965
+ : "",
10709
10966
  createdAtSec: message.date ?? 0,
10710
10967
  });
10711
10968
  } else implicitThreadCreations.delete(key);
@@ -10980,6 +11237,7 @@ export function createTelegramInboundRouteRuntime<
10980
11237
  entry.source.journalBindingKey ===
10981
11238
  deps.getAdmissionJournalBinding?.(),
10982
11239
  ) === true;
11240
+ const needsThread = deps.threadStore.getBotState().threadMode === "enabled" || bindings.length > 0 || presented;
10983
11241
  // Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
10984
11242
  if (
10985
11243
  command &&
@@ -10989,30 +11247,48 @@ export function createTelegramInboundRouteRuntime<
10989
11247
  )
10990
11248
  return;
10991
11249
  if (
10992
- command?.name !== "thread" &&
10993
- (bindings.length > 0 || presented) &&
11250
+ needsThread &&
10994
11251
  typeof message.text === "string" &&
10995
11252
  message.text.trim() &&
10996
11253
  (await sendAllTabTemporaryInputChooser(
10997
11254
  command,
10998
11255
  text,
10999
- message as TMessage,
11256
+ [message as TMessage],
11000
11257
  ctx,
11001
11258
  ))
11002
11259
  )
11003
11260
  return;
11004
- if (bindings.length > 0 && command && command.name !== "thread") {
11005
- if (
11006
- await sendAllTabCommandChooser(command, text, message as TMessage, {
11007
- replyToSource: true,
11008
- })
11009
- ) {
11261
+ // Voice notes, files, stickers and albums from All get the same routing tab as text, whether or not the
11262
+ // client opened a tab for them; an album first gathers into one input so it opens one tab.
11263
+ if (
11264
+ !command &&
11265
+ needsThread &&
11266
+ typeof message.text !== "string" &&
11267
+ Media.hasTelegramMessagesPromptContent([message as TMessage])
11268
+ ) {
11269
+ if (message.media_group_id) {
11270
+ deps.mediaGroupRuntime.queueMessage({
11271
+ message: message as TMessage,
11272
+ context: ctx,
11273
+ dispatchMessages: (messages, queuedCtx) =>
11274
+ routeAllTabAlbum(messages, queuedCtx ?? ctx),
11275
+ });
11276
+ Updates.reportTelegramUpdateDeferred(message as TMessage);
11010
11277
  return;
11011
11278
  }
11279
+ if (
11280
+ await sendAllTabTemporaryInputChooser(
11281
+ undefined,
11282
+ text,
11283
+ [message as TMessage],
11284
+ ctx,
11285
+ )
11286
+ )
11287
+ return;
11012
11288
  }
11013
- if (bindings.length > 0 && !text.startsWith("/")) {
11289
+ if (needsThread) {
11014
11290
  const probeTarget = bindings[0]?.target;
11015
- if (probeTarget?.threadId && deps.callApi) {
11291
+ if (!command && !text.startsWith("/") && probeTarget?.threadId && deps.callApi) {
11016
11292
  try {
11017
11293
  await deps.callApi("sendChatAction", {
11018
11294
  chat_id: probeTarget.chatId,
@@ -11021,8 +11297,7 @@ export function createTelegramInboundRouteRuntime<
11021
11297
  });
11022
11298
  } catch (error) {
11023
11299
  if (
11024
- Threads.isTelegramTopicModeUnavailableError(error) ||
11025
- Threads.isTelegramTopicTargetStaleError(error)
11300
+ Threads.isTelegramTopicModeUnavailableError(error)
11026
11301
  ) {
11027
11302
  deps.threadStore.setBotState({
11028
11303
  threadMode: "disabled",
@@ -11042,12 +11317,7 @@ export function createTelegramInboundRouteRuntime<
11042
11317
  });
11043
11318
  }
11044
11319
  }
11045
- await deps.sendTextReply(
11046
- message.chat.id,
11047
- message.message_id,
11048
- "This bot is in threaded multi-instance mode. Send prompts in a bound Pi thread tab so they route to the right instance.",
11049
- );
11050
- return;
11320
+ throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
11051
11321
  }
11052
11322
  }
11053
11323
  await textDispatch.handleMessage(message as TMessage, ctx);
@@ -11097,6 +11367,33 @@ export function createTelegramInboundRouteRuntime<
11097
11367
  threadId: target.threadId,
11098
11368
  };
11099
11369
  /** Rebinds the leader record to this unbound tab, publishes its identity and handles the message there. */
11370
+ // The leader's own Workspace binding follows a reclaimed tab, keeping its slot and name; without it, the tab
11371
+ // would gain a second slot while Restore, labels and titles still pointed at the deleted target.
11372
+ const leaderWorkspaceCwd =
11373
+ typeof (ctx as { cwd?: unknown }).cwd === "string"
11374
+ ? (ctx as { cwd: string }).cwd
11375
+ : undefined;
11376
+ const claimLeaderWorkspaceBinding = ():
11377
+ Threads.TelegramWorkspaceThreadBinding | undefined => {
11378
+ const sessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
11379
+ if (!leaderWorkspaceCwd || !sessionId || !instanceId)
11380
+ return undefined;
11381
+ const identity = deps.threadStore!.claimWorkspaceIdentity(
11382
+ leaderWorkspaceCwd,
11383
+ instanceId,
11384
+ undefined,
11385
+ { existingBindingOnly: true, sessionId },
11386
+ );
11387
+ const binding = identity
11388
+ ? deps.threadStore!.getWorkspaceBinding(
11389
+ identity.cwd,
11390
+ identity.instanceSlot,
11391
+ identity.sessionId,
11392
+ )
11393
+ : undefined;
11394
+ if (!binding) deps.threadStore!.releaseWorkspaceClaim(instanceId);
11395
+ return binding;
11396
+ };
11100
11397
  const reclaimUnboundTargetForLeader = async (
11101
11398
  base: Partial<Threads.TelegramTopicTargetRecord> &
11102
11399
  Pick<Threads.TelegramTopicTargetRecord, "createdAtMs">,
@@ -11105,7 +11402,25 @@ export function createTelegramInboundRouteRuntime<
11105
11402
  threadName: string,
11106
11403
  event: string,
11107
11404
  details: Record<string, unknown>,
11405
+ leaderBinding?: Threads.TelegramWorkspaceThreadBinding,
11108
11406
  ): Promise<void> => {
11407
+ if (leaderBinding) {
11408
+ const {
11409
+ inactiveSinceMs: _inactiveSinceMs,
11410
+ displayTitle: _displayTitle,
11411
+ ...moved
11412
+ } = leaderBinding;
11413
+ const committed = deps.threadStore!.upsertWorkspaceBinding(
11414
+ {
11415
+ ...moved,
11416
+ target: { ...unboundTarget },
11417
+ updatedAtMs: Date.now(),
11418
+ },
11419
+ instanceId,
11420
+ );
11421
+ deps.threadStore!.releaseWorkspaceClaim(instanceId!);
11422
+ if (committed) threadName = committed.threadName ?? threadName;
11423
+ }
11109
11424
  deps.threadStore!.upsert({
11110
11425
  ...base,
11111
11426
  profileKey,
@@ -11137,6 +11452,35 @@ export function createTelegramInboundRouteRuntime<
11137
11452
  slot,
11138
11453
  profileKey,
11139
11454
  });
11455
+ if (leaderBinding && deps.callApi) {
11456
+ const title = deps.getDisplayTitle?.(unboundTarget);
11457
+ if (title)
11458
+ try {
11459
+ await deps.callApi("editForumTopic", {
11460
+ chat_id: unboundTarget.chatId,
11461
+ message_thread_id: unboundTarget.threadId,
11462
+ name: title,
11463
+ });
11464
+ const current = deps
11465
+ .threadStore!.listWorkspaceBindings()
11466
+ .find(
11467
+ (value) =>
11468
+ value.bindingKey === leaderBinding.bindingKey &&
11469
+ isDeepStrictEqual(value.target, unboundTarget),
11470
+ );
11471
+ if (current) {
11472
+ deps.threadStore!.upsertWorkspaceBinding({
11473
+ ...current,
11474
+ displayTitle: title,
11475
+ });
11476
+ await deps.threadStore!.persist();
11477
+ }
11478
+ } catch (error) {
11479
+ deps.recordRuntimeEvent?.("bus", error, {
11480
+ phase: "leader-topic-reclaim-title",
11481
+ });
11482
+ }
11483
+ }
11140
11484
  await textDispatch.handleMessage(message as TMessage, ctx);
11141
11485
  };
11142
11486
  const records = deps.threadStore.list();
@@ -11277,7 +11621,14 @@ export function createTelegramInboundRouteRuntime<
11277
11621
  if (!currentLeaderIsStale) throw error;
11278
11622
  }
11279
11623
  if (currentLeaderIsStale) {
11280
- const slot = deps.threadStore.allocateSlot(leaderProfileKey);
11624
+ const leaderBinding = claimLeaderWorkspaceBinding();
11625
+ const slot = deps.threadStore.allocateSlot(
11626
+ leaderProfileKey,
11627
+ leaderBinding?.slot,
11628
+ leaderBinding?.bindingKey,
11629
+ );
11630
+ if (leaderBinding && leaderBinding.slot !== slot)
11631
+ deps.threadStore.releaseWorkspaceClaim(instanceId!);
11281
11632
  if (!slot) {
11282
11633
  deps.threadStore.markStaleByTarget(
11283
11634
  currentLeaderRecord.target,
@@ -11313,6 +11664,7 @@ export function createTelegramInboundRouteRuntime<
11313
11664
  phase: "leader-topic-unbound-stale-reclaim",
11314
11665
  staleThreadId: currentLeaderRecord.target.threadId,
11315
11666
  },
11667
+ leaderBinding?.slot === slot ? leaderBinding : undefined,
11316
11668
  );
11317
11669
  return;
11318
11670
  }
@@ -11327,10 +11679,16 @@ export function createTelegramInboundRouteRuntime<
11327
11679
  deps.threadStore.getByProfileKey(leaderProfileKey);
11328
11680
  const priorLeaderIdentity =
11329
11681
  deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
11682
+ const leaderBinding = claimLeaderWorkspaceBinding();
11330
11683
  const slot = deps.threadStore.allocateSlot(
11331
11684
  leaderProfileKey,
11332
- priorLeaderRecord?.slot ?? priorLeaderIdentity?.slot,
11685
+ leaderBinding?.slot ??
11686
+ priorLeaderRecord?.slot ??
11687
+ priorLeaderIdentity?.slot,
11688
+ leaderBinding?.bindingKey,
11333
11689
  );
11690
+ if (leaderBinding && leaderBinding.slot !== slot)
11691
+ deps.threadStore.releaseWorkspaceClaim(instanceId!);
11334
11692
  if (!slot) {
11335
11693
  await deps.sendTextReply(
11336
11694
  target.chatId,
@@ -11360,6 +11718,7 @@ export function createTelegramInboundRouteRuntime<
11360
11718
  threadName,
11361
11719
  "Bus leader reclaimed unbound thread",
11362
11720
  { phase: "leader-topic-reclaim" },
11721
+ leaderBinding?.slot === slot ? leaderBinding : undefined,
11363
11722
  );
11364
11723
  return;
11365
11724
  }
@@ -11389,6 +11748,8 @@ export function createTelegramInboundRouteRuntime<
11389
11748
  onUpdateCompleted,
11390
11749
  prepareHeldCommand: commandHandler.prepareHeldCommand,
11391
11750
  canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
11751
+ onSessionSettled,
11752
+ onDeliverySettled,
11392
11753
  observeLiveRebindLeaderWork,
11393
11754
  prepareLiveRebindLeaderCleanup,
11394
11755
  prepareLiveRebindFollowerCleanup,