@llblab/pi-telegram 0.54.3 → 0.54.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/routing.ts CHANGED
@@ -462,7 +462,8 @@ function formatTelegramUnboundRerouteChooserText(command?: string): string {
462
462
  function formatTelegramUnboundRerouteRestoreChooserText(
463
463
  command?: string,
464
464
  ): string {
465
- return `<b>🔁 Restore into this tab & send ${formatTelegramRouteSubject(command)}:</b>`;
465
+ const subject = command ? ` for ${formatTelegramRouteSubject(command)}` : "";
466
+ return `<b>🔁 Restore a Pi into this tab${subject}:</b>`;
466
467
  }
467
468
 
468
469
  function formatTelegramUnboundTopicGuidance(): string {
@@ -585,7 +586,10 @@ function classifyLiveRebindCleanupFailure(
585
586
  : "unknown";
586
587
  }
587
588
 
588
- /** Default live-rebind cleanup pacing: quick early attempts, then minutely, within a 15-minute window. */
589
+ /**
590
+ * Default live-rebind cleanup pacing: quick early attempts, then minutely, within a 15-minute window. The first
591
+ * delay is not cosmetic: a leader's detached command reply still needs the released, cleanup-free row.
592
+ */
589
593
  export const TELEGRAM_LIVE_REBIND_CLEANUP_SCHEDULE = Object.freeze({
590
594
  delaysMs: Object.freeze([1_000, 2_000, 5_000, 10_000, 30_000]),
591
595
  intervalMs: 60_000,
@@ -643,6 +647,8 @@ export function createTelegramLiveRebindCoordinator(input: {
643
647
  /** Explicit staged singleton command branch; absence preserves ordinary live input behavior. */
644
648
  selectedCommand?: Bus.TelegramBusSelectedCommandInput;
645
649
  };
650
+ /** Restore moves the tab to its new owner: once applied, show that owner's title (best-effort, never blocking). */
651
+ retitle?: (isCurrent: () => boolean) => Promise<void>;
646
652
  recordRuntimeEvent?: (
647
653
  category: string,
648
654
  error: unknown,
@@ -773,6 +779,7 @@ export function createTelegramLiveRebindCoordinator(input: {
773
779
  let saved = false,
774
780
  commitIssued = false,
775
781
  applyIssued = false,
782
+ retitleIssued = false,
776
783
  peerReleaseIssued = false,
777
784
  donorSettlementAttempted = false,
778
785
  continuationIssued = false,
@@ -1030,6 +1037,17 @@ export function createTelegramLiveRebindCoordinator(input: {
1030
1037
  }
1031
1038
  if (!sourceSaved() || (preparation && !preparation.confirmSaved()))
1032
1039
  return "protected";
1040
+ if (input.retitle && !retitleIssued) {
1041
+ retitleIssued = true;
1042
+ try {
1043
+ await input.retitle(current);
1044
+ } catch (error) {
1045
+ input.recordRuntimeEvent?.("telegram", error, {
1046
+ phase: "live-rebind-title",
1047
+ });
1048
+ }
1049
+ if (!current() || !canonical(intent)) return "unknown";
1050
+ }
1033
1051
  if (intent.phase === "rebound") {
1034
1052
  const expected = intent;
1035
1053
  input.restoreStore.advanceLiveRebind(
@@ -1799,6 +1817,8 @@ export function createTelegramInboundRouteRuntime<
1799
1817
  TContext
1800
1818
  >
1801
1819
  >["canPrepareHeldCommand"];
1820
+ /** Agent-settled hint: wakes waiting leader cleanup retries early; every readiness gate is still sampled fresh. */
1821
+ onSessionSettled(ctx: TContext): void;
1802
1822
  /** Fresh body-free recipient observation only; never reconstructs an attempt or grants cleanup. */
1803
1823
  observeLiveRebindLeaderWork(
1804
1824
  intent: Threads.TelegramWorkspaceLiveRebindIntent,
@@ -1888,6 +1908,8 @@ export function createTelegramInboundRouteRuntime<
1888
1908
  temporaryThread?: Threads.TelegramTemporaryThreadEntry;
1889
1909
  /** Source copies left in All (a menu-picked command); deleted once, when the input is consumed. */
1890
1910
  allTabCopies?: number[];
1911
+ /** The tab holds a forwarded copy of the threadless original, which then left All at once. */
1912
+ allTabCopiesForwarded?: true;
1891
1913
  /** Acknowledged same-tab membership stays protective when its reader disappears; never a dispatch/cleanup grant. */
1892
1914
  temporaryMembership?: true;
1893
1915
  /** Re-entrancy lock for one command route in flight; orthogonal to `phase`. */
@@ -1918,7 +1940,8 @@ export function createTelegramInboundRouteRuntime<
1918
1940
  /** Some Forward outcome is unknown; process-local, so restart-safe issuance remains a release gate. */
1919
1941
  | { kind: "forward-unknown" }
1920
1942
  | { kind: "cleanup"; cleanup: PendingRerouteCleanup }
1921
- | { kind: "finalizing"; message: string };
1943
+ /** `retainRecord`: a live rebind's chooser-scoped command reference outlives its deleted menu. */
1944
+ | { kind: "finalizing"; message: string; retainRecord?: true };
1922
1945
  /** The command a chooser routes, read from its own source exactly as the root heading named it. */
1923
1946
  const getPendingRerouteCommandName = (
1924
1947
  pending: PendingUnboundReroute,
@@ -2781,6 +2804,52 @@ export function createTelegramInboundRouteRuntime<
2781
2804
  pending.sourceTarget.chatId,
2782
2805
  pending.allTabCopies?.splice(0) ?? [],
2783
2806
  );
2807
+ /**
2808
+ * Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
2809
+ * holds its forwarded copy. Without one it stays where the operator can still read it.
2810
+ */
2811
+ const releaseAllTabCopiesIntoSurvivingTab = (
2812
+ pending: PendingUnboundReroute,
2813
+ ): Promise<void> => {
2814
+ if (pending.allTabCopiesForwarded) return deleteAllTabCopies(pending);
2815
+ pending.allTabCopies?.splice(0);
2816
+ return Promise.resolve();
2817
+ };
2818
+ /**
2819
+ * The Bot API files a threadless All input in All even when a mobile client first draws it in its new tab. One silent
2820
+ * best-effort forward moves it into the routing tab it opened, and only a confirmed copy lets the original leave All;
2821
+ * any failure keeps the original where the operator can still read it.
2822
+ */
2823
+ const moveAllTabSourceIntoTab = async (
2824
+ pending: PendingUnboundReroute,
2825
+ target: { chatId: number; threadId: number },
2826
+ isCurrent: () => boolean,
2827
+ ): Promise<number | undefined> => {
2828
+ const [messageId] = pending.allTabCopies ?? [];
2829
+ if (messageId === undefined || !deps.callApi || !isCurrent()) return;
2830
+ try {
2831
+ const copy = await deps.callApi<{ message_id?: unknown }>(
2832
+ "forwardMessage",
2833
+ {
2834
+ chat_id: target.chatId,
2835
+ message_thread_id: target.threadId,
2836
+ from_chat_id: pending.sourceTarget.chatId,
2837
+ message_id: messageId,
2838
+ disable_notification: true,
2839
+ },
2840
+ );
2841
+ const copyId = copy?.message_id;
2842
+ if (typeof copyId !== "number" || !Number.isSafeInteger(copyId)) return;
2843
+ pending.allTabCopiesForwarded = true;
2844
+ await deleteAllTabCopies(pending);
2845
+ return copyId;
2846
+ } catch (error) {
2847
+ deps.recordRuntimeEvent?.("routing", error, {
2848
+ phase: "temporary-thread-source-forward",
2849
+ });
2850
+ return;
2851
+ }
2852
+ };
2784
2853
  const dismissRerouteChooserMessage = async (
2785
2854
  query: TCallbackQuery,
2786
2855
  assertExecutionCurrent?: () => void,
@@ -2810,6 +2879,36 @@ export function createTelegramInboundRouteRuntime<
2810
2879
  return false;
2811
2880
  }
2812
2881
  };
2882
+ /** Choosers are one-shot: a final outcome deletes the menu; only a failed delete leaves an inert, button-free notice. */
2883
+ const retireRerouteChooserMessage = async (
2884
+ chatId: number,
2885
+ messageId: number,
2886
+ fallbackText: string,
2887
+ assertCurrent?: () => void,
2888
+ ): Promise<void> => {
2889
+ if (deps.deleteMessage) {
2890
+ try {
2891
+ await deps.deleteMessage(chatId, messageId);
2892
+ return;
2893
+ } catch (error) {
2894
+ deps.recordRuntimeEvent?.("telegram", error, {
2895
+ phase: "reroute-chooser-delete",
2896
+ chatId,
2897
+ messageId,
2898
+ });
2899
+ }
2900
+ assertCurrent?.();
2901
+ }
2902
+ await deps.editInteractiveMessage?.(
2903
+ chatId,
2904
+ messageId,
2905
+ fallbackText,
2906
+ "html",
2907
+ {
2908
+ inline_keyboard: [],
2909
+ },
2910
+ );
2911
+ };
2813
2912
  const closeReroutedUnboundTopic = async (
2814
2913
  target: { chatId: number; threadId: number } | undefined,
2815
2914
  messageId: number | undefined,
@@ -2975,8 +3074,9 @@ export function createTelegramInboundRouteRuntime<
2975
3074
  continue;
2976
3075
  if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
2977
3076
  continue;
3077
+ // A sibling cancelled by Restore stays visible in the restored tab, like a desktop input typed there.
2978
3078
  if (await retireCancellationChooser(id, pending, current))
2979
- await deleteAllTabCopies(pending);
3079
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
2980
3080
  } catch (error) {
2981
3081
  deps.recordRuntimeEvent?.("routing", error, {
2982
3082
  phase: "temporary-thread-restore-sibling",
@@ -3706,6 +3806,25 @@ export function createTelegramInboundRouteRuntime<
3706
3806
  );
3707
3807
  if (!intent) continue;
3708
3808
  }
3809
+ // The chooser follows the success proof, never old-thread cleanup, which may stay protected indefinitely.
3810
+ const retireRestoreChoosers = async (): Promise<void> => {
3811
+ for (const [id, pending] of pendingUnboundReroutes) {
3812
+ if (pending.workspaceRestore?.operationId !== operationId)
3813
+ continue;
3814
+ pending.phase = { kind: "finalizing", message: "Restored" };
3815
+ if (pending.chooserMessageId === undefined) continue;
3816
+ assertSettlementCurrent();
3817
+ await retireRerouteChooserMessage(
3818
+ pending.sourceTarget.chatId,
3819
+ pending.chooserMessageId,
3820
+ "<b>✅ Message routed.</b>",
3821
+ assertSettlementCurrent,
3822
+ );
3823
+ assertSettlementCurrent();
3824
+ if (pendingUnboundReroutes.get(id) === pending)
3825
+ removePendingReroute(id);
3826
+ }
3827
+ };
3709
3828
  if (intent.routing?.cleanup === undefined) {
3710
3829
  if (
3711
3830
  !intent.request.source.updateIds.every((id) =>
@@ -3716,13 +3835,14 @@ export function createTelegramInboundRouteRuntime<
3716
3835
  )
3717
3836
  )
3718
3837
  continue;
3719
- // Positive settlement of every selected source is the success proof; old-thread cleanup may stay protected indefinitely.
3838
+ // Positive settlement of every selected source is the success proof.
3720
3839
  await disposeTemporaryThreadSiblingsAfterRestore(
3721
3840
  intent,
3722
3841
  ctx,
3723
3842
  settlementAuthority.isCurrent,
3724
3843
  );
3725
3844
  assertSettlementCurrent();
3845
+ await retireRestoreChoosers();
3726
3846
  if (
3727
3847
  isRerouteTargetProtected(
3728
3848
  intent.request.binding.target,
@@ -3758,27 +3878,7 @@ export function createTelegramInboundRouteRuntime<
3758
3878
  }
3759
3879
  if (!store.retire(intent, settlementAuthority)) continue;
3760
3880
  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
- }
3881
+ await retireRestoreChoosers();
3782
3882
  }
3783
3883
  },
3784
3884
  ),
@@ -5094,6 +5194,23 @@ export function createTelegramInboundRouteRuntime<
5094
5194
  messages: selection.messages,
5095
5195
  restoreStore: store,
5096
5196
  threadStore: threads,
5197
+ // Same title as Workspace Restore: the display-mode title of the restored binding on its new tab.
5198
+ retitle: deps.callApi
5199
+ ? async (isCurrent) => {
5200
+ if (!isCurrent()) return;
5201
+ const slot = binding.slot ?? "";
5202
+ await deps.callApi!("editForumTopic", {
5203
+ chat_id: target.chatId,
5204
+ message_thread_id: target.threadId,
5205
+ name:
5206
+ deps.getDisplayTitle?.(target) ??
5207
+ ThreadNaming.getTelegramTopicTitleForThreadName(
5208
+ getRestoredThreadName(request.owner, slot),
5209
+ slot,
5210
+ ),
5211
+ });
5212
+ }
5213
+ : undefined,
5097
5214
  authority: {
5098
5215
  executor: { instanceId, leaderEpoch: String(epoch) },
5099
5216
  operatorUserId,
@@ -5788,6 +5905,8 @@ export function createTelegramInboundRouteRuntime<
5788
5905
  string,
5789
5906
  ReturnType<typeof setTimeout> | "running"
5790
5907
  >();
5908
+ // A leader retry waiting out its delay; a settled session wakes it at once, since its own busy run is the usual wait.
5909
+ const liveRebindCleanupWakers = new Map<string, () => void>();
5791
5910
  /** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
5792
5911
  const terminalizeUnissuedLiveRebindCleanup = async (
5793
5912
  operationId: string,
@@ -5860,7 +5979,8 @@ export function createTelegramInboundRouteRuntime<
5860
5979
  liveRebindCleanupChains.delete(operationId);
5861
5980
  return;
5862
5981
  }
5863
- const timer = setTimeout(() => {
5982
+ const attemptNow = () => {
5983
+ liveRebindCleanupWakers.delete(operationId);
5864
5984
  if (liveRebindCleanupChains.get(operationId) !== timer) return;
5865
5985
  liveRebindCleanupChains.set(operationId, "running");
5866
5986
  let continued = false;
@@ -5906,9 +6026,25 @@ export function createTelegramInboundRouteRuntime<
5906
6026
  });
5907
6027
  restoreSettlementTasks.add(task);
5908
6028
  void task.finally(() => restoreSettlementTasks.delete(task));
5909
- }, pacing.delaysMs[attempt] ?? pacing.intervalMs);
6029
+ };
6030
+ const timer = setTimeout(
6031
+ attemptNow,
6032
+ pacing.delaysMs[attempt] ?? pacing.intervalMs,
6033
+ );
5910
6034
  timer.unref?.();
5911
6035
  liveRebindCleanupChains.set(operationId, timer);
6036
+ // The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
6037
+ if (role === "leader" && attempt > 0)
6038
+ liveRebindCleanupWakers.set(operationId, () => {
6039
+ if (liveRebindCleanupChains.get(operationId) !== timer) return;
6040
+ clearTimeout(timer);
6041
+ attemptNow();
6042
+ });
6043
+ };
6044
+ /** The agent settled: every gate is re-sampled now, so an old tab goes right after the answer, not at the next tick. */
6045
+ const onSessionSettled = (ctx: TContext): void => {
6046
+ if (deps.isContextActive?.(ctx) !== true) return;
6047
+ for (const wake of [...liveRebindCleanupWakers.values()]) wake();
5912
6048
  };
5913
6049
  const restoreWorkspace = deps.workspaceRestoreRecipient
5914
6050
  ? async (input: {
@@ -6446,6 +6582,7 @@ export function createTelegramInboundRouteRuntime<
6446
6582
  assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(
6447
6583
  query,
6448
6584
  ),
6585
+ retainRecord = false,
6449
6586
  ): Promise<void> => {
6450
6587
  assertExecutionCurrent();
6451
6588
  const dismissed = await dismissRerouteChooserMessage(
@@ -6454,12 +6591,18 @@ export function createTelegramInboundRouteRuntime<
6454
6591
  );
6455
6592
  assertExecutionCurrent();
6456
6593
  if (dismissed) {
6457
- removePendingReroute(rerouteId);
6594
+ // A retained record no longer matches any chooser, so a late duplicate tap reads as expired.
6595
+ if (retainRecord) pending.chooserMessageId = undefined;
6596
+ else removePendingReroute(rerouteId);
6458
6597
  await deps.answerCallbackQuery(query.id, successMessage);
6459
6598
  await deleteAllTabCopies(pending);
6460
6599
  return;
6461
6600
  }
6462
- pending.phase = { kind: "finalizing", message: successMessage };
6601
+ pending.phase = {
6602
+ kind: "finalizing",
6603
+ message: successMessage,
6604
+ ...(retainRecord ? { retainRecord: true as const } : {}),
6605
+ };
6463
6606
  await deps.answerCallbackQuery(
6464
6607
  query.id,
6465
6608
  `${successMessage}; tap again to clear the menu`,
@@ -7017,16 +7160,14 @@ export function createTelegramInboundRouteRuntime<
7017
7160
  if (
7018
7161
  !isPendingRerouteCancelled(pending) ||
7019
7162
  !isCurrent() ||
7020
- !deps.editInteractiveMessage ||
7163
+ (!deps.deleteMessage && !deps.editInteractiveMessage) ||
7021
7164
  pending.chooserMessageId === undefined
7022
7165
  )
7023
7166
  return false;
7024
- await deps.editInteractiveMessage(
7167
+ await retireRerouteChooserMessage(
7025
7168
  pending.sourceTarget.chatId,
7026
7169
  pending.chooserMessageId,
7027
7170
  "<b>⛔️ Routing cancelled.</b>",
7028
- "html",
7029
- { inline_keyboard: [] },
7030
7171
  );
7031
7172
  if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
7032
7173
  return false;
@@ -7523,6 +7664,7 @@ export function createTelegramInboundRouteRuntime<
7523
7664
  const result = source.expire();
7524
7665
  if (!result) return;
7525
7666
  // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
7667
+ let movedIntoTab = false;
7526
7668
  for (const [id, pending] of pendingUnboundReroutes) {
7527
7669
  if (
7528
7670
  !Updates.collectTelegramAdmissionSourceUpdateIds(
@@ -7530,6 +7672,7 @@ export function createTelegramInboundRouteRuntime<
7530
7672
  ).includes(source.original.updateId)
7531
7673
  )
7532
7674
  continue;
7675
+ movedIntoTab ||= pending.allTabCopiesForwarded === true;
7533
7676
  removePendingReroute(id);
7534
7677
  if (
7535
7678
  signal.aborted ||
@@ -7562,13 +7705,15 @@ export function createTelegramInboundRouteRuntime<
7562
7705
  // never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
7563
7706
  if (temporary?.target && runtimeCurrent()) {
7564
7707
  scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
7565
- await deleteAllTabMessages(
7566
- temporary.target.chatId,
7567
- getTelegramAllTabSourceMessageIds(
7568
- [message!],
7708
+ // A moved original already left All with its forward; only an unmoved one is deleted here.
7709
+ if (!movedIntoTab)
7710
+ await deleteAllTabMessages(
7569
7711
  temporary.target.chatId,
7570
- ),
7571
- );
7712
+ getTelegramAllTabSourceMessageIds(
7713
+ [message!],
7714
+ temporary.target.chatId,
7715
+ ),
7716
+ );
7572
7717
  }
7573
7718
  },
7574
7719
  );
@@ -8172,6 +8317,7 @@ export function createTelegramInboundRouteRuntime<
8172
8317
  query,
8173
8318
  pending.phase.message,
8174
8319
  assertExecutionCurrent,
8320
+ pending.phase.retainRecord === true,
8175
8321
  );
8176
8322
  return true;
8177
8323
  }
@@ -8566,7 +8712,8 @@ export function createTelegramInboundRouteRuntime<
8566
8712
  selection.record.owner?.kind === "leader" ? "leader" : "follower",
8567
8713
  ctx,
8568
8714
  );
8569
- if (outcome === "released") await deleteAllTabCopies(pending);
8715
+ if (outcome === "released")
8716
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
8570
8717
  } catch (error) {
8571
8718
  deps.recordRuntimeEvent?.("routing", error, {
8572
8719
  phase: "live-rebind-chooser",
@@ -8575,31 +8722,41 @@ export function createTelegramInboundRouteRuntime<
8575
8722
  selection.isCurrent = undefined;
8576
8723
  }
8577
8724
  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",
8725
+ if (outcome !== "released") {
8726
+ await deps.answerCallbackQuery(
8727
+ query.id,
8728
+ "Live rebind is unconfirmed; this choice never replays issued input",
8729
+ );
8730
+ return true;
8731
+ }
8732
+ // The tab now belongs to the rebound thread; only the one-shot chooser message goes.
8733
+ await finalizePendingReroute(
8734
+ parsed.rerouteId,
8735
+ pending,
8736
+ query,
8737
+ selection.extensionKind === "generated-prompt"
8738
+ ? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
8739
+ : selection.extensionCommand
8740
+ ? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
8741
+ : selection.nameCommand
8742
+ ? selection.nameCommand.args.trim()
8743
+ ? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
8744
+ : "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
8745
+ : selection.confirmationCommand
8746
+ ? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
8747
+ : selection.helpCommand
8748
+ ? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
8749
+ : selection.stopCommand
8750
+ ? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
8751
+ : selection.nextCommand
8752
+ ? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
8753
+ : selection.menuCommand || selection.abortCommand
8754
+ ? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
8755
+ : selection.coordinator?.selectedCommandReference()
8756
+ ? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
8757
+ : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed",
8758
+ assertExecutionCurrent,
8759
+ true,
8603
8760
  );
8604
8761
  return true;
8605
8762
  }
@@ -8611,6 +8768,8 @@ export function createTelegramInboundRouteRuntime<
8611
8768
  messages: [...pending.messages],
8612
8769
  });
8613
8770
  let active = true;
8771
+ // Delivery proof for the toast only; chooser retirement still waits for the positive settlement that follows.
8772
+ let delivered = false;
8614
8773
  const ownerUserId = deps.configStore.getAllowedUserId();
8615
8774
  const leaderEpoch = deps.getCurrentLeaderEpoch?.();
8616
8775
  const admissionScope = deps.getAdmissionScope?.();
@@ -8676,7 +8835,8 @@ export function createTelegramInboundRouteRuntime<
8676
8835
  : routed;
8677
8836
  await dispatchPendingRerouteMessages(pending, messages, ctx);
8678
8837
  assertRecipientCurrent();
8679
- await deleteAllTabCopies(pending);
8838
+ delivered = true;
8839
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
8680
8840
  return true;
8681
8841
  }
8682
8842
  const forwarded = await forwardPendingRerouteMessages(
@@ -8687,7 +8847,10 @@ export function createTelegramInboundRouteRuntime<
8687
8847
  assertRecipientCurrent,
8688
8848
  recordForwardAcceptance,
8689
8849
  );
8690
- if (forwarded) await deleteAllTabCopies(pending);
8850
+ if (forwarded) {
8851
+ delivered = true;
8852
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
8853
+ }
8691
8854
  return forwarded;
8692
8855
  },
8693
8856
  });
@@ -8698,7 +8861,7 @@ export function createTelegramInboundRouteRuntime<
8698
8861
  if (!selectionCurrent()) return true;
8699
8862
  await deps.answerCallbackQuery(
8700
8863
  query.id,
8701
- "Restore unconfirmed; not resending",
8864
+ delivered ? "Restored" : "Restore unconfirmed; not resending",
8702
8865
  );
8703
8866
  return true;
8704
8867
  }
@@ -10080,7 +10243,10 @@ export function createTelegramInboundRouteRuntime<
10080
10243
  }
10081
10244
  return undefined;
10082
10245
  };
10083
- /** A fresh implicit native tab whose name the All input starts with, observed just before it in the same exact scope. */
10246
+ /**
10247
+ * A fresh implicit native tab observed just before the All input in the same exact scope: mobile names it after
10248
+ * the input, while other clients may use a generic name, so the immediately preceding creation also qualifies.
10249
+ */
10084
10250
  const findImplicitCreationForAllInput = (
10085
10251
  message: TMessage,
10086
10252
  updateId: number,
@@ -10098,8 +10264,8 @@ export function createTelegramInboundRouteRuntime<
10098
10264
  | undefined;
10099
10265
  for (const [key, implicit] of implicitThreadCreations) {
10100
10266
  if (
10101
- implicit.name &&
10102
- text.startsWith(implicit.name) &&
10267
+ ((implicit.name && text.startsWith(implicit.name)) ||
10268
+ implicit.updateId === updateId - 1) &&
10103
10269
  implicit.updateId < updateId &&
10104
10270
  implicit.createdAtSec > 0 &&
10105
10271
  message.date !== undefined &&
@@ -10159,6 +10325,9 @@ export function createTelegramInboundRouteRuntime<
10159
10325
  entry.source.updateId === updateId,
10160
10326
  );
10161
10327
  let entry: Threads.TelegramTemporaryThreadEntry | undefined;
10328
+ // A tab opened by this call, by the bot or adopted from the client, gets the input's one forwarded copy; re-entry
10329
+ // after a restart finds its tab already prepared and never forwards again.
10330
+ let openedNow = false;
10162
10331
  await deps.runWorkspaceOperation(
10163
10332
  {
10164
10333
  operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
@@ -10175,7 +10344,12 @@ export function createTelegramInboundRouteRuntime<
10175
10344
  }
10176
10345
  // Mobile clients sending from All first create a native tab named after the input, then deliver
10177
10346
  // 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);
10347
+ const native = findImplicitCreationForAllInput(
10348
+ message,
10349
+ updateId,
10350
+ cap,
10351
+ ctx,
10352
+ );
10179
10353
  if (native) {
10180
10354
  const adopted = store.registerImplicitTemporaryThread(
10181
10355
  { journalBindingKey, updateIds: [updateId] },
@@ -10191,6 +10365,7 @@ export function createTelegramInboundRouteRuntime<
10191
10365
  if (adopted && current()) {
10192
10366
  implicitThreadCreations.delete(native.key);
10193
10367
  entry = adopted;
10368
+ openedNow = true;
10194
10369
  // Adopted native tabs share the routing name; a failed rename never blocks routing.
10195
10370
  try {
10196
10371
  await deps.callApi!("editForumTopic", {
@@ -10237,12 +10412,13 @@ export function createTelegramInboundRouteRuntime<
10237
10412
  threadId <= 0
10238
10413
  )
10239
10414
  return;
10240
- entry =
10241
- store.acknowledgeTemporaryThread(
10242
- reservation.entry,
10243
- { chatId: operatorUserId, threadId },
10244
- authority,
10245
- ) ?? entry;
10415
+ const acknowledged = store.acknowledgeTemporaryThread(
10416
+ reservation.entry,
10417
+ { chatId: operatorUserId, threadId },
10418
+ authority,
10419
+ );
10420
+ openedNow = !!acknowledged;
10421
+ entry = acknowledged ?? entry;
10246
10422
  },
10247
10423
  );
10248
10424
  if (!current() || !entry)
@@ -10298,6 +10474,9 @@ export function createTelegramInboundRouteRuntime<
10298
10474
  pending.rootChooserText = formatTelegramTemporaryThreadChooserText(
10299
10475
  command?.name,
10300
10476
  );
10477
+ const tabCopyId = openedNow
10478
+ ? await moveAllTabSourceIntoTab(pending, target, current)
10479
+ : undefined;
10301
10480
  try {
10302
10481
  if (!deps.sendInteractiveMessage)
10303
10482
  throw new Error("Temporary Thread chooser publication is unavailable.");
@@ -10309,7 +10488,9 @@ export function createTelegramInboundRouteRuntime<
10309
10488
  canCancel: !!pending.abandonment,
10310
10489
  getDisplayTitle: deps.getDisplayTitle,
10311
10490
  }),
10312
- { target },
10491
+ tabCopyId === undefined
10492
+ ? { target }
10493
+ : { target, replyToMessageId: tabCopyId },
10313
10494
  );
10314
10495
  rememberRerouteChooser(rerouteId, chooserId);
10315
10496
  } catch (error) {
@@ -10705,7 +10886,10 @@ export function createTelegramInboundRouteRuntime<
10705
10886
  chatId: lifecycle.target.chatId,
10706
10887
  threadId: lifecycle.target.threadId!,
10707
10888
  },
10708
- name: "name" in created && typeof created.name === "string" ? created.name : "",
10889
+ name:
10890
+ "name" in created && typeof created.name === "string"
10891
+ ? created.name
10892
+ : "",
10709
10893
  createdAtSec: message.date ?? 0,
10710
10894
  });
10711
10895
  } else implicitThreadCreations.delete(key);
@@ -11097,6 +11281,33 @@ export function createTelegramInboundRouteRuntime<
11097
11281
  threadId: target.threadId,
11098
11282
  };
11099
11283
  /** Rebinds the leader record to this unbound tab, publishes its identity and handles the message there. */
11284
+ // The leader's own Workspace binding follows a reclaimed tab, keeping its slot and name; without it, the tab
11285
+ // would gain a second slot while Restore, labels and titles still pointed at the deleted target.
11286
+ const leaderWorkspaceCwd =
11287
+ typeof (ctx as { cwd?: unknown }).cwd === "string"
11288
+ ? (ctx as { cwd: string }).cwd
11289
+ : undefined;
11290
+ const claimLeaderWorkspaceBinding = ():
11291
+ Threads.TelegramWorkspaceThreadBinding | undefined => {
11292
+ const sessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
11293
+ if (!leaderWorkspaceCwd || !sessionId || !instanceId)
11294
+ return undefined;
11295
+ const identity = deps.threadStore!.claimWorkspaceIdentity(
11296
+ leaderWorkspaceCwd,
11297
+ instanceId,
11298
+ undefined,
11299
+ { existingBindingOnly: true, sessionId },
11300
+ );
11301
+ const binding = identity
11302
+ ? deps.threadStore!.getWorkspaceBinding(
11303
+ identity.cwd,
11304
+ identity.instanceSlot,
11305
+ identity.sessionId,
11306
+ )
11307
+ : undefined;
11308
+ if (!binding) deps.threadStore!.releaseWorkspaceClaim(instanceId);
11309
+ return binding;
11310
+ };
11100
11311
  const reclaimUnboundTargetForLeader = async (
11101
11312
  base: Partial<Threads.TelegramTopicTargetRecord> &
11102
11313
  Pick<Threads.TelegramTopicTargetRecord, "createdAtMs">,
@@ -11105,7 +11316,25 @@ export function createTelegramInboundRouteRuntime<
11105
11316
  threadName: string,
11106
11317
  event: string,
11107
11318
  details: Record<string, unknown>,
11319
+ leaderBinding?: Threads.TelegramWorkspaceThreadBinding,
11108
11320
  ): Promise<void> => {
11321
+ if (leaderBinding) {
11322
+ const {
11323
+ inactiveSinceMs: _inactiveSinceMs,
11324
+ displayTitle: _displayTitle,
11325
+ ...moved
11326
+ } = leaderBinding;
11327
+ const committed = deps.threadStore!.upsertWorkspaceBinding(
11328
+ {
11329
+ ...moved,
11330
+ target: { ...unboundTarget },
11331
+ updatedAtMs: Date.now(),
11332
+ },
11333
+ instanceId,
11334
+ );
11335
+ deps.threadStore!.releaseWorkspaceClaim(instanceId!);
11336
+ if (committed) threadName = committed.threadName ?? threadName;
11337
+ }
11109
11338
  deps.threadStore!.upsert({
11110
11339
  ...base,
11111
11340
  profileKey,
@@ -11137,6 +11366,35 @@ export function createTelegramInboundRouteRuntime<
11137
11366
  slot,
11138
11367
  profileKey,
11139
11368
  });
11369
+ if (leaderBinding && deps.callApi) {
11370
+ const title = deps.getDisplayTitle?.(unboundTarget);
11371
+ if (title)
11372
+ try {
11373
+ await deps.callApi("editForumTopic", {
11374
+ chat_id: unboundTarget.chatId,
11375
+ message_thread_id: unboundTarget.threadId,
11376
+ name: title,
11377
+ });
11378
+ const current = deps
11379
+ .threadStore!.listWorkspaceBindings()
11380
+ .find(
11381
+ (value) =>
11382
+ value.bindingKey === leaderBinding.bindingKey &&
11383
+ isDeepStrictEqual(value.target, unboundTarget),
11384
+ );
11385
+ if (current) {
11386
+ deps.threadStore!.upsertWorkspaceBinding({
11387
+ ...current,
11388
+ displayTitle: title,
11389
+ });
11390
+ await deps.threadStore!.persist();
11391
+ }
11392
+ } catch (error) {
11393
+ deps.recordRuntimeEvent?.("bus", error, {
11394
+ phase: "leader-topic-reclaim-title",
11395
+ });
11396
+ }
11397
+ }
11140
11398
  await textDispatch.handleMessage(message as TMessage, ctx);
11141
11399
  };
11142
11400
  const records = deps.threadStore.list();
@@ -11277,7 +11535,14 @@ export function createTelegramInboundRouteRuntime<
11277
11535
  if (!currentLeaderIsStale) throw error;
11278
11536
  }
11279
11537
  if (currentLeaderIsStale) {
11280
- const slot = deps.threadStore.allocateSlot(leaderProfileKey);
11538
+ const leaderBinding = claimLeaderWorkspaceBinding();
11539
+ const slot = deps.threadStore.allocateSlot(
11540
+ leaderProfileKey,
11541
+ leaderBinding?.slot,
11542
+ leaderBinding?.bindingKey,
11543
+ );
11544
+ if (leaderBinding && leaderBinding.slot !== slot)
11545
+ deps.threadStore.releaseWorkspaceClaim(instanceId!);
11281
11546
  if (!slot) {
11282
11547
  deps.threadStore.markStaleByTarget(
11283
11548
  currentLeaderRecord.target,
@@ -11313,6 +11578,7 @@ export function createTelegramInboundRouteRuntime<
11313
11578
  phase: "leader-topic-unbound-stale-reclaim",
11314
11579
  staleThreadId: currentLeaderRecord.target.threadId,
11315
11580
  },
11581
+ leaderBinding?.slot === slot ? leaderBinding : undefined,
11316
11582
  );
11317
11583
  return;
11318
11584
  }
@@ -11327,10 +11593,16 @@ export function createTelegramInboundRouteRuntime<
11327
11593
  deps.threadStore.getByProfileKey(leaderProfileKey);
11328
11594
  const priorLeaderIdentity =
11329
11595
  deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
11596
+ const leaderBinding = claimLeaderWorkspaceBinding();
11330
11597
  const slot = deps.threadStore.allocateSlot(
11331
11598
  leaderProfileKey,
11332
- priorLeaderRecord?.slot ?? priorLeaderIdentity?.slot,
11599
+ leaderBinding?.slot ??
11600
+ priorLeaderRecord?.slot ??
11601
+ priorLeaderIdentity?.slot,
11602
+ leaderBinding?.bindingKey,
11333
11603
  );
11604
+ if (leaderBinding && leaderBinding.slot !== slot)
11605
+ deps.threadStore.releaseWorkspaceClaim(instanceId!);
11334
11606
  if (!slot) {
11335
11607
  await deps.sendTextReply(
11336
11608
  target.chatId,
@@ -11360,6 +11632,7 @@ export function createTelegramInboundRouteRuntime<
11360
11632
  threadName,
11361
11633
  "Bus leader reclaimed unbound thread",
11362
11634
  { phase: "leader-topic-reclaim" },
11635
+ leaderBinding?.slot === slot ? leaderBinding : undefined,
11363
11636
  );
11364
11637
  return;
11365
11638
  }
@@ -11389,6 +11662,7 @@ export function createTelegramInboundRouteRuntime<
11389
11662
  onUpdateCompleted,
11390
11663
  prepareHeldCommand: commandHandler.prepareHeldCommand,
11391
11664
  canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
11665
+ onSessionSettled,
11392
11666
  observeLiveRebindLeaderWork,
11393
11667
  prepareLiveRebindLeaderCleanup,
11394
11668
  prepareLiveRebindFollowerCleanup,