@llblab/pi-telegram 0.54.2 → 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(
@@ -1330,6 +1348,8 @@ export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
1330
1348
  export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "Routing choice expired";
1331
1349
  /** Every temporary routing tab carries this name, whether the bot created it or adopted Telegram's native tab. */
1332
1350
  const TELEGRAM_TEMPORARY_THREAD_NAME = "🚦 Routing";
1351
+ /** A mobile client's native tab creation precedes the All input it is named after by about a second. */
1352
+ const TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC = 10;
1333
1353
 
1334
1354
  export function isTelegramAllTabCommandExpired(
1335
1355
  message: { date?: number; message_thread_id?: number },
@@ -1797,6 +1817,8 @@ export function createTelegramInboundRouteRuntime<
1797
1817
  TContext
1798
1818
  >
1799
1819
  >["canPrepareHeldCommand"];
1820
+ /** Agent-settled hint: wakes waiting leader cleanup retries early; every readiness gate is still sampled fresh. */
1821
+ onSessionSettled(ctx: TContext): void;
1800
1822
  /** Fresh body-free recipient observation only; never reconstructs an attempt or grants cleanup. */
1801
1823
  observeLiveRebindLeaderWork(
1802
1824
  intent: Threads.TelegramWorkspaceLiveRebindIntent,
@@ -1886,6 +1908,8 @@ export function createTelegramInboundRouteRuntime<
1886
1908
  temporaryThread?: Threads.TelegramTemporaryThreadEntry;
1887
1909
  /** Source copies left in All (a menu-picked command); deleted once, when the input is consumed. */
1888
1910
  allTabCopies?: number[];
1911
+ /** The tab holds a forwarded copy of the threadless original, which then left All at once. */
1912
+ allTabCopiesForwarded?: true;
1889
1913
  /** Acknowledged same-tab membership stays protective when its reader disappears; never a dispatch/cleanup grant. */
1890
1914
  temporaryMembership?: true;
1891
1915
  /** Re-entrancy lock for one command route in flight; orthogonal to `phase`. */
@@ -1916,7 +1940,8 @@ export function createTelegramInboundRouteRuntime<
1916
1940
  /** Some Forward outcome is unknown; process-local, so restart-safe issuance remains a release gate. */
1917
1941
  | { kind: "forward-unknown" }
1918
1942
  | { kind: "cleanup"; cleanup: PendingRerouteCleanup }
1919
- | { 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 };
1920
1945
  /** The command a chooser routes, read from its own source exactly as the root heading named it. */
1921
1946
  const getPendingRerouteCommandName = (
1922
1947
  pending: PendingUnboundReroute,
@@ -1945,6 +1970,9 @@ export function createTelegramInboundRouteRuntime<
1945
1970
  executor: Threads.TelegramWorkspaceRestoreExecutor;
1946
1971
  generation: number | undefined;
1947
1972
  scope: string | undefined;
1973
+ target: { chatId: number; threadId: number };
1974
+ name: string;
1975
+ createdAtSec: number;
1948
1976
  }
1949
1977
  >();
1950
1978
  const guidedUnboundTopicKeys = new Set<string>();
@@ -2776,6 +2804,52 @@ export function createTelegramInboundRouteRuntime<
2776
2804
  pending.sourceTarget.chatId,
2777
2805
  pending.allTabCopies?.splice(0) ?? [],
2778
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
+ };
2779
2853
  const dismissRerouteChooserMessage = async (
2780
2854
  query: TCallbackQuery,
2781
2855
  assertExecutionCurrent?: () => void,
@@ -2805,6 +2879,36 @@ export function createTelegramInboundRouteRuntime<
2805
2879
  return false;
2806
2880
  }
2807
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
+ };
2808
2912
  const closeReroutedUnboundTopic = async (
2809
2913
  target: { chatId: number; threadId: number } | undefined,
2810
2914
  messageId: number | undefined,
@@ -2970,8 +3074,9 @@ export function createTelegramInboundRouteRuntime<
2970
3074
  continue;
2971
3075
  if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
2972
3076
  continue;
3077
+ // A sibling cancelled by Restore stays visible in the restored tab, like a desktop input typed there.
2973
3078
  if (await retireCancellationChooser(id, pending, current))
2974
- await deleteAllTabCopies(pending);
3079
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
2975
3080
  } catch (error) {
2976
3081
  deps.recordRuntimeEvent?.("routing", error, {
2977
3082
  phase: "temporary-thread-restore-sibling",
@@ -3701,6 +3806,25 @@ export function createTelegramInboundRouteRuntime<
3701
3806
  );
3702
3807
  if (!intent) continue;
3703
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
+ };
3704
3828
  if (intent.routing?.cleanup === undefined) {
3705
3829
  if (
3706
3830
  !intent.request.source.updateIds.every((id) =>
@@ -3711,13 +3835,14 @@ export function createTelegramInboundRouteRuntime<
3711
3835
  )
3712
3836
  )
3713
3837
  continue;
3714
- // 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.
3715
3839
  await disposeTemporaryThreadSiblingsAfterRestore(
3716
3840
  intent,
3717
3841
  ctx,
3718
3842
  settlementAuthority.isCurrent,
3719
3843
  );
3720
3844
  assertSettlementCurrent();
3845
+ await retireRestoreChoosers();
3721
3846
  if (
3722
3847
  isRerouteTargetProtected(
3723
3848
  intent.request.binding.target,
@@ -3753,27 +3878,7 @@ export function createTelegramInboundRouteRuntime<
3753
3878
  }
3754
3879
  if (!store.retire(intent, settlementAuthority)) continue;
3755
3880
  retired = true;
3756
- for (const [id, pending] of pendingUnboundReroutes) {
3757
- if (pending.workspaceRestore?.operationId !== operationId)
3758
- continue;
3759
- pending.phase = { kind: "finalizing", message: "Restored" };
3760
- if (
3761
- !deps.editInteractiveMessage ||
3762
- pending.chooserMessageId === undefined
3763
- )
3764
- continue;
3765
- assertSettlementCurrent();
3766
- await deps.editInteractiveMessage(
3767
- pending.sourceTarget.chatId,
3768
- pending.chooserMessageId,
3769
- "<b>✅ Message routed.</b>",
3770
- "html",
3771
- { inline_keyboard: [] },
3772
- );
3773
- assertSettlementCurrent();
3774
- if (pendingUnboundReroutes.get(id) === pending)
3775
- removePendingReroute(id);
3776
- }
3881
+ await retireRestoreChoosers();
3777
3882
  }
3778
3883
  },
3779
3884
  ),
@@ -5089,6 +5194,23 @@ export function createTelegramInboundRouteRuntime<
5089
5194
  messages: selection.messages,
5090
5195
  restoreStore: store,
5091
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,
5092
5214
  authority: {
5093
5215
  executor: { instanceId, leaderEpoch: String(epoch) },
5094
5216
  operatorUserId,
@@ -5783,6 +5905,8 @@ export function createTelegramInboundRouteRuntime<
5783
5905
  string,
5784
5906
  ReturnType<typeof setTimeout> | "running"
5785
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>();
5786
5910
  /** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
5787
5911
  const terminalizeUnissuedLiveRebindCleanup = async (
5788
5912
  operationId: string,
@@ -5855,7 +5979,8 @@ export function createTelegramInboundRouteRuntime<
5855
5979
  liveRebindCleanupChains.delete(operationId);
5856
5980
  return;
5857
5981
  }
5858
- const timer = setTimeout(() => {
5982
+ const attemptNow = () => {
5983
+ liveRebindCleanupWakers.delete(operationId);
5859
5984
  if (liveRebindCleanupChains.get(operationId) !== timer) return;
5860
5985
  liveRebindCleanupChains.set(operationId, "running");
5861
5986
  let continued = false;
@@ -5901,9 +6026,25 @@ export function createTelegramInboundRouteRuntime<
5901
6026
  });
5902
6027
  restoreSettlementTasks.add(task);
5903
6028
  void task.finally(() => restoreSettlementTasks.delete(task));
5904
- }, pacing.delaysMs[attempt] ?? pacing.intervalMs);
6029
+ };
6030
+ const timer = setTimeout(
6031
+ attemptNow,
6032
+ pacing.delaysMs[attempt] ?? pacing.intervalMs,
6033
+ );
5905
6034
  timer.unref?.();
5906
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();
5907
6048
  };
5908
6049
  const restoreWorkspace = deps.workspaceRestoreRecipient
5909
6050
  ? async (input: {
@@ -6441,6 +6582,7 @@ export function createTelegramInboundRouteRuntime<
6441
6582
  assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(
6442
6583
  query,
6443
6584
  ),
6585
+ retainRecord = false,
6444
6586
  ): Promise<void> => {
6445
6587
  assertExecutionCurrent();
6446
6588
  const dismissed = await dismissRerouteChooserMessage(
@@ -6449,12 +6591,18 @@ export function createTelegramInboundRouteRuntime<
6449
6591
  );
6450
6592
  assertExecutionCurrent();
6451
6593
  if (dismissed) {
6452
- 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);
6453
6597
  await deps.answerCallbackQuery(query.id, successMessage);
6454
6598
  await deleteAllTabCopies(pending);
6455
6599
  return;
6456
6600
  }
6457
- pending.phase = { kind: "finalizing", message: successMessage };
6601
+ pending.phase = {
6602
+ kind: "finalizing",
6603
+ message: successMessage,
6604
+ ...(retainRecord ? { retainRecord: true as const } : {}),
6605
+ };
6458
6606
  await deps.answerCallbackQuery(
6459
6607
  query.id,
6460
6608
  `${successMessage}; tap again to clear the menu`,
@@ -7012,16 +7160,14 @@ export function createTelegramInboundRouteRuntime<
7012
7160
  if (
7013
7161
  !isPendingRerouteCancelled(pending) ||
7014
7162
  !isCurrent() ||
7015
- !deps.editInteractiveMessage ||
7163
+ (!deps.deleteMessage && !deps.editInteractiveMessage) ||
7016
7164
  pending.chooserMessageId === undefined
7017
7165
  )
7018
7166
  return false;
7019
- await deps.editInteractiveMessage(
7167
+ await retireRerouteChooserMessage(
7020
7168
  pending.sourceTarget.chatId,
7021
7169
  pending.chooserMessageId,
7022
7170
  "<b>⛔️ Routing cancelled.</b>",
7023
- "html",
7024
- { inline_keyboard: [] },
7025
7171
  );
7026
7172
  if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
7027
7173
  return false;
@@ -7518,6 +7664,7 @@ export function createTelegramInboundRouteRuntime<
7518
7664
  const result = source.expire();
7519
7665
  if (!result) return;
7520
7666
  // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
7667
+ let movedIntoTab = false;
7521
7668
  for (const [id, pending] of pendingUnboundReroutes) {
7522
7669
  if (
7523
7670
  !Updates.collectTelegramAdmissionSourceUpdateIds(
@@ -7525,6 +7672,7 @@ export function createTelegramInboundRouteRuntime<
7525
7672
  ).includes(source.original.updateId)
7526
7673
  )
7527
7674
  continue;
7675
+ movedIntoTab ||= pending.allTabCopiesForwarded === true;
7528
7676
  removePendingReroute(id);
7529
7677
  if (
7530
7678
  signal.aborted ||
@@ -7557,13 +7705,15 @@ export function createTelegramInboundRouteRuntime<
7557
7705
  // never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
7558
7706
  if (temporary?.target && runtimeCurrent()) {
7559
7707
  scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
7560
- await deleteAllTabMessages(
7561
- temporary.target.chatId,
7562
- getTelegramAllTabSourceMessageIds(
7563
- [message!],
7708
+ // A moved original already left All with its forward; only an unmoved one is deleted here.
7709
+ if (!movedIntoTab)
7710
+ await deleteAllTabMessages(
7564
7711
  temporary.target.chatId,
7565
- ),
7566
- );
7712
+ getTelegramAllTabSourceMessageIds(
7713
+ [message!],
7714
+ temporary.target.chatId,
7715
+ ),
7716
+ );
7567
7717
  }
7568
7718
  },
7569
7719
  );
@@ -8167,6 +8317,7 @@ export function createTelegramInboundRouteRuntime<
8167
8317
  query,
8168
8318
  pending.phase.message,
8169
8319
  assertExecutionCurrent,
8320
+ pending.phase.retainRecord === true,
8170
8321
  );
8171
8322
  return true;
8172
8323
  }
@@ -8561,7 +8712,8 @@ export function createTelegramInboundRouteRuntime<
8561
8712
  selection.record.owner?.kind === "leader" ? "leader" : "follower",
8562
8713
  ctx,
8563
8714
  );
8564
- if (outcome === "released") await deleteAllTabCopies(pending);
8715
+ if (outcome === "released")
8716
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
8565
8717
  } catch (error) {
8566
8718
  deps.recordRuntimeEvent?.("routing", error, {
8567
8719
  phase: "live-rebind-chooser",
@@ -8570,31 +8722,41 @@ export function createTelegramInboundRouteRuntime<
8570
8722
  selection.isCurrent = undefined;
8571
8723
  }
8572
8724
  assertExecutionCurrent();
8573
- await deps.answerCallbackQuery(
8574
- query.id,
8575
- outcome === "released"
8576
- ? selection.extensionKind === "generated-prompt"
8577
- ? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
8578
- : selection.extensionCommand
8579
- ? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
8580
- : selection.nameCommand
8581
- ? selection.nameCommand.args.trim()
8582
- ? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
8583
- : "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
8584
- : selection.confirmationCommand
8585
- ? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
8586
- : selection.helpCommand
8587
- ? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
8588
- : selection.stopCommand
8589
- ? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
8590
- : selection.nextCommand
8591
- ? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
8592
- : selection.menuCommand || selection.abortCommand
8593
- ? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
8594
- : selection.coordinator?.selectedCommandReference()
8595
- ? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
8596
- : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
8597
- : "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,
8598
8760
  );
8599
8761
  return true;
8600
8762
  }
@@ -8606,6 +8768,8 @@ export function createTelegramInboundRouteRuntime<
8606
8768
  messages: [...pending.messages],
8607
8769
  });
8608
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;
8609
8773
  const ownerUserId = deps.configStore.getAllowedUserId();
8610
8774
  const leaderEpoch = deps.getCurrentLeaderEpoch?.();
8611
8775
  const admissionScope = deps.getAdmissionScope?.();
@@ -8671,7 +8835,8 @@ export function createTelegramInboundRouteRuntime<
8671
8835
  : routed;
8672
8836
  await dispatchPendingRerouteMessages(pending, messages, ctx);
8673
8837
  assertRecipientCurrent();
8674
- await deleteAllTabCopies(pending);
8838
+ delivered = true;
8839
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
8675
8840
  return true;
8676
8841
  }
8677
8842
  const forwarded = await forwardPendingRerouteMessages(
@@ -8682,7 +8847,10 @@ export function createTelegramInboundRouteRuntime<
8682
8847
  assertRecipientCurrent,
8683
8848
  recordForwardAcceptance,
8684
8849
  );
8685
- if (forwarded) await deleteAllTabCopies(pending);
8850
+ if (forwarded) {
8851
+ delivered = true;
8852
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
8853
+ }
8686
8854
  return forwarded;
8687
8855
  },
8688
8856
  });
@@ -8693,7 +8861,7 @@ export function createTelegramInboundRouteRuntime<
8693
8861
  if (!selectionCurrent()) return true;
8694
8862
  await deps.answerCallbackQuery(
8695
8863
  query.id,
8696
- "Restore unconfirmed; not resending",
8864
+ delivered ? "Restored" : "Restore unconfirmed; not resending",
8697
8865
  );
8698
8866
  return true;
8699
8867
  }
@@ -10075,6 +10243,47 @@ export function createTelegramInboundRouteRuntime<
10075
10243
  }
10076
10244
  return undefined;
10077
10245
  };
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
+ */
10250
+ const findImplicitCreationForAllInput = (
10251
+ message: TMessage,
10252
+ updateId: number,
10253
+ cap: NonNullable<ReturnType<typeof captureTemporaryThreadAuthority>>,
10254
+ ctx: TContext,
10255
+ ) => {
10256
+ const text = (message.text ?? message.caption ?? "").trim();
10257
+ let match:
10258
+ | {
10259
+ key: string;
10260
+ implicit: typeof implicitThreadCreations extends Map<string, infer V>
10261
+ ? V
10262
+ : never;
10263
+ }
10264
+ | undefined;
10265
+ for (const [key, implicit] of implicitThreadCreations) {
10266
+ if (
10267
+ ((implicit.name && text.startsWith(implicit.name)) ||
10268
+ implicit.updateId === updateId - 1) &&
10269
+ implicit.updateId < updateId &&
10270
+ implicit.createdAtSec > 0 &&
10271
+ message.date !== undefined &&
10272
+ message.date >= implicit.createdAtSec &&
10273
+ message.date - implicit.createdAtSec <=
10274
+ TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC &&
10275
+ implicit.ctx === ctx &&
10276
+ implicit.operatorUserId === cap.operatorUserId &&
10277
+ implicit.journalBindingKey === cap.journalBindingKey &&
10278
+ isDeepStrictEqual(implicit.executor, cap.authority.executor) &&
10279
+ implicit.generation === deps.getSessionGeneration?.() &&
10280
+ implicit.scope === deps.getAdmissionScope?.() &&
10281
+ (!match || implicit.updateId > match.implicit.updateId)
10282
+ )
10283
+ match = { key, implicit };
10284
+ }
10285
+ return match;
10286
+ };
10078
10287
  /**
10079
10288
  * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
10080
10289
  * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
@@ -10116,6 +10325,9 @@ export function createTelegramInboundRouteRuntime<
10116
10325
  entry.source.updateId === updateId,
10117
10326
  );
10118
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;
10119
10331
  await deps.runWorkspaceOperation(
10120
10332
  {
10121
10333
  operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
@@ -10130,6 +10342,45 @@ export function createTelegramInboundRouteRuntime<
10130
10342
  entry = found;
10131
10343
  return;
10132
10344
  }
10345
+ // Mobile clients sending from All first create a native tab named after the input, then deliver
10346
+ // the input itself to All: adopt that tab rather than leaving it behind next to a second one.
10347
+ const native = findImplicitCreationForAllInput(
10348
+ message,
10349
+ updateId,
10350
+ cap,
10351
+ ctx,
10352
+ );
10353
+ if (native) {
10354
+ const adopted = store.registerImplicitTemporaryThread(
10355
+ { journalBindingKey, updateIds: [updateId] },
10356
+ native.implicit.target,
10357
+ randomBytes(16).toString("hex"),
10358
+ {
10359
+ ...authority,
10360
+ isCurrent: () =>
10361
+ current() &&
10362
+ implicitThreadCreations.get(native.key) === native.implicit,
10363
+ },
10364
+ );
10365
+ if (adopted && current()) {
10366
+ implicitThreadCreations.delete(native.key);
10367
+ entry = adopted;
10368
+ openedNow = true;
10369
+ // Adopted native tabs share the routing name; a failed rename never blocks routing.
10370
+ try {
10371
+ await deps.callApi!("editForumTopic", {
10372
+ chat_id: native.implicit.target.chatId,
10373
+ message_thread_id: native.implicit.target.threadId,
10374
+ name: TELEGRAM_TEMPORARY_THREAD_NAME,
10375
+ });
10376
+ } catch (error) {
10377
+ deps.recordRuntimeEvent?.("routing", error, {
10378
+ phase: "temporary-thread-name",
10379
+ });
10380
+ }
10381
+ return;
10382
+ }
10383
+ }
10133
10384
  const reservation = store.reserveTemporaryThread(
10134
10385
  source,
10135
10386
  randomBytes(16).toString("hex"),
@@ -10161,12 +10412,13 @@ export function createTelegramInboundRouteRuntime<
10161
10412
  threadId <= 0
10162
10413
  )
10163
10414
  return;
10164
- entry =
10165
- store.acknowledgeTemporaryThread(
10166
- reservation.entry,
10167
- { chatId: operatorUserId, threadId },
10168
- authority,
10169
- ) ?? entry;
10415
+ const acknowledged = store.acknowledgeTemporaryThread(
10416
+ reservation.entry,
10417
+ { chatId: operatorUserId, threadId },
10418
+ authority,
10419
+ );
10420
+ openedNow = !!acknowledged;
10421
+ entry = acknowledged ?? entry;
10170
10422
  },
10171
10423
  );
10172
10424
  if (!current() || !entry)
@@ -10222,6 +10474,9 @@ export function createTelegramInboundRouteRuntime<
10222
10474
  pending.rootChooserText = formatTelegramTemporaryThreadChooserText(
10223
10475
  command?.name,
10224
10476
  );
10477
+ const tabCopyId = openedNow
10478
+ ? await moveAllTabSourceIntoTab(pending, target, current)
10479
+ : undefined;
10225
10480
  try {
10226
10481
  if (!deps.sendInteractiveMessage)
10227
10482
  throw new Error("Temporary Thread chooser publication is unavailable.");
@@ -10233,7 +10488,9 @@ export function createTelegramInboundRouteRuntime<
10233
10488
  canCancel: !!pending.abandonment,
10234
10489
  getDisplayTitle: deps.getDisplayTitle,
10235
10490
  }),
10236
- { target },
10491
+ tabCopyId === undefined
10492
+ ? { target }
10493
+ : { target, replyToMessageId: tabCopyId },
10237
10494
  );
10238
10495
  rememberRerouteChooser(rerouteId, chooserId);
10239
10496
  } catch (error) {
@@ -10625,6 +10882,15 @@ export function createTelegramInboundRouteRuntime<
10625
10882
  executor: structuredClone(cap.authority.executor),
10626
10883
  generation: deps.getSessionGeneration?.(),
10627
10884
  scope: deps.getAdmissionScope?.(),
10885
+ target: {
10886
+ chatId: lifecycle.target.chatId,
10887
+ threadId: lifecycle.target.threadId!,
10888
+ },
10889
+ name:
10890
+ "name" in created && typeof created.name === "string"
10891
+ ? created.name
10892
+ : "",
10893
+ createdAtSec: message.date ?? 0,
10628
10894
  });
10629
10895
  } else implicitThreadCreations.delete(key);
10630
10896
  };
@@ -11015,6 +11281,33 @@ export function createTelegramInboundRouteRuntime<
11015
11281
  threadId: target.threadId,
11016
11282
  };
11017
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
+ };
11018
11311
  const reclaimUnboundTargetForLeader = async (
11019
11312
  base: Partial<Threads.TelegramTopicTargetRecord> &
11020
11313
  Pick<Threads.TelegramTopicTargetRecord, "createdAtMs">,
@@ -11023,7 +11316,25 @@ export function createTelegramInboundRouteRuntime<
11023
11316
  threadName: string,
11024
11317
  event: string,
11025
11318
  details: Record<string, unknown>,
11319
+ leaderBinding?: Threads.TelegramWorkspaceThreadBinding,
11026
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
+ }
11027
11338
  deps.threadStore!.upsert({
11028
11339
  ...base,
11029
11340
  profileKey,
@@ -11055,6 +11366,35 @@ export function createTelegramInboundRouteRuntime<
11055
11366
  slot,
11056
11367
  profileKey,
11057
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
+ }
11058
11398
  await textDispatch.handleMessage(message as TMessage, ctx);
11059
11399
  };
11060
11400
  const records = deps.threadStore.list();
@@ -11195,7 +11535,14 @@ export function createTelegramInboundRouteRuntime<
11195
11535
  if (!currentLeaderIsStale) throw error;
11196
11536
  }
11197
11537
  if (currentLeaderIsStale) {
11198
- 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!);
11199
11546
  if (!slot) {
11200
11547
  deps.threadStore.markStaleByTarget(
11201
11548
  currentLeaderRecord.target,
@@ -11231,6 +11578,7 @@ export function createTelegramInboundRouteRuntime<
11231
11578
  phase: "leader-topic-unbound-stale-reclaim",
11232
11579
  staleThreadId: currentLeaderRecord.target.threadId,
11233
11580
  },
11581
+ leaderBinding?.slot === slot ? leaderBinding : undefined,
11234
11582
  );
11235
11583
  return;
11236
11584
  }
@@ -11245,10 +11593,16 @@ export function createTelegramInboundRouteRuntime<
11245
11593
  deps.threadStore.getByProfileKey(leaderProfileKey);
11246
11594
  const priorLeaderIdentity =
11247
11595
  deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
11596
+ const leaderBinding = claimLeaderWorkspaceBinding();
11248
11597
  const slot = deps.threadStore.allocateSlot(
11249
11598
  leaderProfileKey,
11250
- priorLeaderRecord?.slot ?? priorLeaderIdentity?.slot,
11599
+ leaderBinding?.slot ??
11600
+ priorLeaderRecord?.slot ??
11601
+ priorLeaderIdentity?.slot,
11602
+ leaderBinding?.bindingKey,
11251
11603
  );
11604
+ if (leaderBinding && leaderBinding.slot !== slot)
11605
+ deps.threadStore.releaseWorkspaceClaim(instanceId!);
11252
11606
  if (!slot) {
11253
11607
  await deps.sendTextReply(
11254
11608
  target.chatId,
@@ -11278,6 +11632,7 @@ export function createTelegramInboundRouteRuntime<
11278
11632
  threadName,
11279
11633
  "Bus leader reclaimed unbound thread",
11280
11634
  { phase: "leader-topic-reclaim" },
11635
+ leaderBinding?.slot === slot ? leaderBinding : undefined,
11281
11636
  );
11282
11637
  return;
11283
11638
  }
@@ -11307,6 +11662,7 @@ export function createTelegramInboundRouteRuntime<
11307
11662
  onUpdateCompleted,
11308
11663
  prepareHeldCommand: commandHandler.prepareHeldCommand,
11309
11664
  canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
11665
+ onSessionSettled,
11310
11666
  observeLiveRebindLeaderWork,
11311
11667
  prepareLiveRebindLeaderCleanup,
11312
11668
  prepareLiveRebindFollowerCleanup,