@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
@@ -210,6 +210,8 @@ function getTelegramAllTabSourceMessageIds(messages, chatId) {
210
210
  ? [message.message_id]
211
211
  : []);
212
212
  }
213
+ /** Failure retains the source for recovery; Threaded Mode never emits a fallback answer into All. */
214
+ const TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR = "All is input-only in Threaded Mode; a routing Thread could not be prepared.";
213
215
  /** What a route chooser moves: `<code>/name</code>` for a command, otherwise "this message". */
214
216
  function formatTelegramRouteSubject(command) {
215
217
  return command ? `<code>/${escapeHtml(command)}</code>` : "this message";
@@ -250,13 +252,6 @@ function formatTelegramRoutingInputPreview(message) {
250
252
  const chars = Array.from(label);
251
253
  return (escapeHtml(chars.slice(0, 80).join("")) + (chars.length > 80 ? "…" : ""));
252
254
  }
253
- function formatTelegramAllTabMenuChooserText(command) {
254
- return [
255
- `<b>🚦 Route ${formatTelegramRouteSubject(command)}:</b>`,
256
- "",
257
- "<i>To restore a Pi instead, send a message in a new tab.</i>",
258
- ].join("\n");
259
- }
260
255
  function buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, options) {
261
256
  const activeRecords = records.filter((record) => record.status === "active");
262
257
  const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
@@ -302,7 +297,8 @@ function formatTelegramUnboundRerouteChooserText(command) {
302
297
  return `<b>🔀 Reroute ${formatTelegramRouteSubject(command)} to:</b>`;
303
298
  }
304
299
  function formatTelegramUnboundRerouteRestoreChooserText(command) {
305
- return `<b>🔁 Restore into this tab & send ${formatTelegramRouteSubject(command)}:</b>`;
300
+ const subject = command ? ` for ${formatTelegramRouteSubject(command)}` : "";
301
+ return `<b>🔁 Restore a Pi into this tab${subject}:</b>`;
306
302
  }
307
303
  function formatTelegramUnboundTopicGuidance() {
308
304
  return [
@@ -370,17 +366,21 @@ function classifyLiveRebindCleanupFailure(error) {
370
366
  ? "rejected"
371
367
  : "unknown";
372
368
  }
373
- /** Default live-rebind cleanup pacing: quick early attempts, then minutely, within a 15-minute window. */
369
+ /**
370
+ * Default live-rebind cleanup pacing: quick early attempts, then minutely, within a 15-minute window. The first
371
+ * delay is not cosmetic: a leader's detached command reply still needs the released, cleanup-free row.
372
+ */
374
373
  export const TELEGRAM_LIVE_REBIND_CLEANUP_SCHEDULE = Object.freeze({
375
374
  delaysMs: Object.freeze([1_000, 2_000, 5_000, 10_000, 30_000]),
376
375
  intervalMs: 60_000,
377
376
  windowMs: 15 * 60_000,
378
377
  });
379
- /** One warm live attempt with exact post-release peer donor settlement; no startup recovery, Restore ACK or cleanup. */
378
+ /** One warm live attempt with independent leader input/cleanup grants and exact peer donor settlement; no startup recovery or legacy Restore ACK. */
380
379
  export function createTelegramLiveRebindCoordinator(input) {
381
380
  const request = structuredClone(input.request), messages = [...input.messages];
382
381
  const executor = structuredClone(input.authority.executor), operatorUserId = input.authority.operatorUserId;
383
382
  const leader = input.leader && { ...input.leader }, follower = input.follower, followerRun = follower?.run;
383
+ const beforeCleanup = input.cleanupBeforeRelease;
384
384
  const selectedCommand = follower?.selectedCommand && structuredClone(follower.selectedCommand), held = selectedCommand;
385
385
  const observedRecipient = input.getRecipient(), recipient = observedRecipient && structuredClone(observedRecipient);
386
386
  const sources = messages.map(Updates.inspectTelegramDeferredSource);
@@ -465,7 +465,7 @@ export function createTelegramLiveRebindCoordinator(input) {
465
465
  return confirmed && current();
466
466
  };
467
467
  let preparation;
468
- let saved = false, commitIssued = false, applyIssued = false, peerReleaseIssued = false, donorSettlementAttempted = false, continuationIssued = false, released = false, advancing = false;
468
+ let saved = false, commitIssued = false, applyIssued = false, retitleIssued = false, peerReleaseIssued = false, donorSettlementAttempted = false, continuationIssued = false, cleanupBeforeReleaseIssued = false, cleanupBeforeReleaseDecided = false, released = false, advancing = false;
469
469
  let preparedSource;
470
470
  const runFollower = async (mode) => {
471
471
  if (!input.follower || !recipient || !current())
@@ -513,6 +513,18 @@ export function createTelegramLiveRebindCoordinator(input) {
513
513
  generation: recipient.generation,
514
514
  bindingKey: recipient.bindingKey,
515
515
  });
516
+ const acceptInput = (expected) => {
517
+ if (!expected.inputRelease || expected.inputRelease === "accepted")
518
+ return expected;
519
+ let accepted;
520
+ try {
521
+ accepted = input.restoreStore.advanceLiveRebind(expected, "accept-input", authority);
522
+ }
523
+ catch {
524
+ accepted = retained();
525
+ }
526
+ return accepted?.inputRelease === "accepted" && canonical(accepted) ? accepted : undefined;
527
+ };
516
528
  const releaseFollower = async (intent) => {
517
529
  if (!held || !peerReleaseIssued) {
518
530
  peerReleaseIssued = true;
@@ -535,6 +547,10 @@ export function createTelegramLiveRebindCoordinator(input) {
535
547
  !canonical(intent))
536
548
  return "unknown";
537
549
  }
550
+ const accepted = acceptInput(intent);
551
+ if (!accepted)
552
+ return "unknown";
553
+ intent = accepted;
538
554
  donorSettlementAttempted = true;
539
555
  if (preparation?.settleTransferred(() => canonical(intent)) !== "settled")
540
556
  return "unknown";
@@ -544,19 +560,16 @@ export function createTelegramLiveRebindCoordinator(input) {
544
560
  const observeCommandCompletion = () => {
545
561
  const intent = retained();
546
562
  if (!intent ||
547
- intent.phase !== "released" ||
548
- intent.cleanup ||
563
+ !Threads.isTelegramLiveRebindInputAuthorized(intent) ||
549
564
  !canonical(intent) ||
550
565
  !leader?.isApplied(intent))
551
566
  return "unknown";
552
567
  const completions = messages.map(Updates.inspectTelegramDeferredSourceCompletion);
553
- return completions.every((completion, index) => completion && isDeepStrictEqual(completion, sources[index])) &&
568
+ const completed = completions.every((completion, index) => completion && isDeepStrictEqual(completion, sources[index])) &&
554
569
  current() &&
555
570
  canonical(intent) &&
556
- leader.isApplied(intent) &&
557
- current()
558
- ? "released"
559
- : "unknown";
571
+ leader.isApplied(intent) && current();
572
+ return completed && acceptInput(intent) ? "released" : "unknown";
560
573
  };
561
574
  return {
562
575
  /** Correlation only; a later cleanup attempt rereads and revalidates the canonical row itself. */
@@ -589,8 +602,7 @@ export function createTelegramLiveRebindCoordinator(input) {
589
602
  return observeCommandCompletion();
590
603
  const intent = retained();
591
604
  if (!intent ||
592
- intent.phase !== "released" ||
593
- intent.cleanup ||
605
+ !Threads.isTelegramLiveRebindInputAuthorized(intent) ||
594
606
  !canonical(intent) ||
595
607
  !leader?.isApplied(intent) ||
596
608
  !leader.observeAdmission?.() ||
@@ -598,6 +610,8 @@ export function createTelegramLiveRebindCoordinator(input) {
598
610
  !canonical(intent) ||
599
611
  !leader.isApplied(intent))
600
612
  return "unknown";
613
+ if (!acceptInput(intent))
614
+ return "unknown";
601
615
  released = true;
602
616
  return "released";
603
617
  }
@@ -653,22 +667,22 @@ export function createTelegramLiveRebindCoordinator(input) {
653
667
  instanceId: recipient.instanceId,
654
668
  sessionId: recipient.sessionId,
655
669
  generation: recipient.generation,
656
- }, authority);
670
+ }, authority, beforeCleanup ? { holdInput: true } : undefined);
657
671
  }
658
672
  else if (!commitIssued)
659
673
  return "protected";
660
674
  if (!intent ||
661
675
  !sourceSaved() ||
662
676
  !canonical(intent) ||
663
- intent.phase === "finished" ||
664
- intent.cleanup)
677
+ (intent.phase === "finished" && !cleanupBeforeReleaseIssued) ||
678
+ (intent.cleanup && intent.inputRelease === undefined))
665
679
  return "protected";
666
680
  // A released peer may already have completed its sources; only its retained settlement can reobserve that outcome.
667
681
  if (recipient.kind === "follower" && peerReleaseIssued)
668
- return intent.phase === "released"
682
+ return Threads.isTelegramLiveRebindInputAuthorized(intent)
669
683
  ? await releaseFollower(intent)
670
684
  : "protected";
671
- const mode = applyIssued || intent.phase === "released" ? "inspect" : "apply";
685
+ const mode = applyIssued || intent.phase === "released" || intent.phase === "finished" ? "inspect" : "apply";
672
686
  applyIssued = true;
673
687
  if (recipient.kind === "leader") {
674
688
  await leader.apply(structuredClone(intent), mode, current);
@@ -687,6 +701,19 @@ export function createTelegramLiveRebindCoordinator(input) {
687
701
  }
688
702
  if (!sourceSaved() || (preparation && !preparation.confirmSaved()))
689
703
  return "protected";
704
+ if (input.retitle && !retitleIssued) {
705
+ retitleIssued = true;
706
+ try {
707
+ await input.retitle(current);
708
+ }
709
+ catch (error) {
710
+ input.recordRuntimeEvent?.("telegram", error, {
711
+ phase: "live-rebind-title",
712
+ });
713
+ }
714
+ if (!current() || !canonical(intent))
715
+ return "unknown";
716
+ }
690
717
  if (intent.phase === "rebound") {
691
718
  const expected = intent;
692
719
  input.restoreStore.advanceLiveRebind(expected, "release", recipient.kind === "leader"
@@ -698,22 +725,55 @@ export function createTelegramLiveRebindCoordinator(input) {
698
725
  intent = retained();
699
726
  }
700
727
  if (!intent ||
701
- intent.phase !== "released" ||
702
- intent.cleanup ||
728
+ (intent.phase !== "released" && !(intent.phase === "finished" && intent.inputRelease === "held")) ||
729
+ (intent.cleanup && intent.inputRelease === undefined) ||
703
730
  !canonical(intent))
704
731
  return "protected";
732
+ if (beforeCleanup) {
733
+ if (!cleanupBeforeReleaseIssued) {
734
+ cleanupBeforeReleaseIssued = true;
735
+ cleanupBeforeReleaseDecided = await beforeCleanup(intent, current);
736
+ }
737
+ intent = retained();
738
+ if (!cleanupBeforeReleaseDecided && intent?.phase !== "finished")
739
+ return "unknown";
740
+ if (!intent || !canonical(intent) ||
741
+ (recipient.kind === "leader" && !leader.isApplied(intent)) ||
742
+ intent.inputRelease !== "held" || intent.cleanup === "issued")
743
+ return "unknown";
744
+ let issued;
745
+ try {
746
+ issued = input.restoreStore.advanceLiveRebind(intent, "issue-input", authority);
747
+ }
748
+ catch {
749
+ issued = retained();
750
+ }
751
+ if (!issued || issued.inputRelease !== "issued" || !canonical(issued))
752
+ return "unknown";
753
+ intent = issued;
754
+ }
705
755
  if (recipient.kind === "leader") {
706
756
  const expected = intent;
707
757
  if (!preparation?.beginRelease(() => canonical(expected) && leader.isApplied(expected)))
708
758
  return "protected";
709
759
  continuationIssued = true;
710
- const canContinue = () => current() && canonical(expected) && leader.isApplied(expected);
760
+ const canContinue = () => {
761
+ const observed = expected.inputRelease ? retained() : expected;
762
+ return current() && !!observed &&
763
+ (!expected.inputRelease || (observed.inputRelease === "issued" || observed.inputRelease === "accepted")) &&
764
+ canonical(observed) && leader.isApplied(observed);
765
+ };
711
766
  if (leader.complete) {
712
767
  leader.complete([...messages], canContinue);
768
+ // The detached native producer has taken this one input grant; its source ACK remains a separate observation.
769
+ if (!acceptInput(intent))
770
+ return "unknown";
713
771
  return observeCommandCompletion();
714
772
  }
715
773
  if (!(await leader.continue(messages, canContinue)))
716
774
  return "unknown";
775
+ if (!acceptInput(intent))
776
+ return "unknown";
717
777
  }
718
778
  else
719
779
  return await releaseFollower(intent);
@@ -1561,6 +1621,58 @@ export function createTelegramInboundRouteRuntime(deps) {
1561
1621
  };
1562
1622
  /** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
1563
1623
  const deleteAllTabCopies = (pending) => deleteAllTabMessages(pending.sourceTarget.chatId, pending.allTabCopies?.splice(0) ?? []);
1624
+ /**
1625
+ * Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
1626
+ * holds its forwarded copy. Without one it stays where the operator can still read it.
1627
+ */
1628
+ const releaseAllTabCopiesIntoSurvivingTab = (pending) => {
1629
+ if (pending.allTabCopiesForwarded)
1630
+ return deleteAllTabCopies(pending);
1631
+ pending.allTabCopies?.splice(0);
1632
+ return Promise.resolve();
1633
+ };
1634
+ /**
1635
+ * The Bot API files a threadless All input in All even when a mobile client first draws it in its new tab. One silent
1636
+ * best-effort forward moves it into the routing tab it opened, and only a confirmed copy lets the original leave All;
1637
+ * any failure keeps the original where the operator can still read it.
1638
+ */
1639
+ const moveAllTabSourceIntoTab = async (pending, target, isCurrent) => {
1640
+ const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
1641
+ if (messageIds.length === 0 || !deps.callApi || !isCurrent())
1642
+ return;
1643
+ const route = {
1644
+ chat_id: target.chatId,
1645
+ message_thread_id: target.threadId,
1646
+ from_chat_id: pending.sourceTarget.chatId,
1647
+ disable_notification: true,
1648
+ };
1649
+ try {
1650
+ // An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
1651
+ const copies = messageIds.length === 1
1652
+ ? [
1653
+ await deps.callApi("forwardMessage", {
1654
+ ...route,
1655
+ message_id: messageIds[0],
1656
+ }),
1657
+ ]
1658
+ : await deps.callApi("forwardMessages", { ...route, message_ids: messageIds });
1659
+ const copyIds = Array.isArray(copies)
1660
+ ? copies.map((copy) => copy?.message_id)
1661
+ : [];
1662
+ if (copyIds.length !== messageIds.length ||
1663
+ !copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id)))
1664
+ return;
1665
+ pending.allTabCopiesForwarded = true;
1666
+ await deleteAllTabCopies(pending);
1667
+ return copyIds[0];
1668
+ }
1669
+ catch (error) {
1670
+ deps.recordRuntimeEvent?.("routing", error, {
1671
+ phase: "temporary-thread-source-forward",
1672
+ });
1673
+ return;
1674
+ }
1675
+ };
1564
1676
  const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
1565
1677
  const chatId = query.message?.chat?.id;
1566
1678
  const messageId = query.message?.message_id;
@@ -1586,6 +1698,26 @@ export function createTelegramInboundRouteRuntime(deps) {
1586
1698
  return false;
1587
1699
  }
1588
1700
  };
1701
+ /** Choosers are one-shot: a final outcome deletes the menu; only a failed delete leaves an inert, button-free notice. */
1702
+ const retireRerouteChooserMessage = async (chatId, messageId, fallbackText, assertCurrent) => {
1703
+ if (deps.deleteMessage) {
1704
+ try {
1705
+ await deps.deleteMessage(chatId, messageId);
1706
+ return;
1707
+ }
1708
+ catch (error) {
1709
+ deps.recordRuntimeEvent?.("telegram", error, {
1710
+ phase: "reroute-chooser-delete",
1711
+ chatId,
1712
+ messageId,
1713
+ });
1714
+ }
1715
+ assertCurrent?.();
1716
+ }
1717
+ await deps.editInteractiveMessage?.(chatId, messageId, fallbackText, "html", {
1718
+ inline_keyboard: [],
1719
+ });
1720
+ };
1589
1721
  const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent, ownTemporary) => {
1590
1722
  if (!target || !deps.threadStore)
1591
1723
  return true;
@@ -1708,8 +1840,9 @@ export function createTelegramInboundRouteRuntime(deps) {
1708
1840
  continue;
1709
1841
  if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
1710
1842
  continue;
1843
+ // A sibling cancelled by Restore stays visible in the restored tab, like a desktop input typed there.
1711
1844
  if (await retireCancellationChooser(id, pending, current))
1712
- await deleteAllTabCopies(pending);
1845
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
1713
1846
  }
1714
1847
  catch (error) {
1715
1848
  deps.recordRuntimeEvent?.("routing", error, {
@@ -2239,12 +2372,28 @@ export function createTelegramInboundRouteRuntime(deps) {
2239
2372
  if (!intent)
2240
2373
  continue;
2241
2374
  }
2375
+ // The chooser follows the success proof, never old-thread cleanup, which may stay protected indefinitely.
2376
+ const retireRestoreChoosers = async () => {
2377
+ for (const [id, pending] of pendingUnboundReroutes) {
2378
+ if (pending.workspaceRestore?.operationId !== operationId)
2379
+ continue;
2380
+ pending.phase = { kind: "finalizing", message: "Restored" };
2381
+ if (pending.chooserMessageId === undefined)
2382
+ continue;
2383
+ assertSettlementCurrent();
2384
+ await retireRerouteChooserMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", assertSettlementCurrent);
2385
+ assertSettlementCurrent();
2386
+ if (pendingUnboundReroutes.get(id) === pending)
2387
+ removePendingReroute(id);
2388
+ }
2389
+ };
2242
2390
  if (intent.routing?.cleanup === undefined) {
2243
2391
  if (!intent.request.source.updateIds.every((id) => intent.routing.settlements.some((value) => value.kind !== "queued" && value.updateIds.includes(id))))
2244
2392
  continue;
2245
- // Positive settlement of every selected source is the success proof; old-thread cleanup may stay protected indefinitely.
2393
+ // Positive settlement of every selected source is the success proof.
2246
2394
  await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
2247
2395
  assertSettlementCurrent();
2396
+ await retireRestoreChoosers();
2248
2397
  if (isRerouteTargetProtected(intent.request.binding.target, intent))
2249
2398
  continue;
2250
2399
  const grant = store.issueCleanup(intent, settlementAuthority);
@@ -2265,19 +2414,7 @@ export function createTelegramInboundRouteRuntime(deps) {
2265
2414
  if (!store.retire(intent, settlementAuthority))
2266
2415
  continue;
2267
2416
  retired = true;
2268
- for (const [id, pending] of pendingUnboundReroutes) {
2269
- if (pending.workspaceRestore?.operationId !== operationId)
2270
- continue;
2271
- pending.phase = { kind: "finalizing", message: "Restored" };
2272
- if (!deps.editInteractiveMessage ||
2273
- pending.chooserMessageId === undefined)
2274
- continue;
2275
- assertSettlementCurrent();
2276
- await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
2277
- assertSettlementCurrent();
2278
- if (pendingUnboundReroutes.get(id) === pending)
2279
- removePendingReroute(id);
2280
- }
2417
+ await retireRestoreChoosers();
2281
2418
  }
2282
2419
  }))
2283
2420
  .catch((error) => {
@@ -2936,8 +3073,7 @@ export function createTelegramInboundRouteRuntime(deps) {
2936
3073
  const intent = store
2937
3074
  .listLiveRebindings()
2938
3075
  .find((value) => value.request.operationId === request.operationId);
2939
- if (intent?.phase === "released" &&
2940
- !intent.cleanup &&
3076
+ if (intent && Threads.isTelegramLiveRebindInputAuthorized(intent) &&
2941
3077
  isDeepStrictEqual(intent.request, request) &&
2942
3078
  intent.operatorUserId === operatorUserId &&
2943
3079
  isDeepStrictEqual(intent.executor, {
@@ -3206,17 +3342,67 @@ export function createTelegramInboundRouteRuntime(deps) {
3206
3342
  name ||
3207
3343
  confirmation ||
3208
3344
  extension;
3345
+ const preDispatchCleanup = deps.runWorkspaceOperation !== undefined && deps.canUsePreDispatchRebind?.(recipient) === true;
3209
3346
  const createCoordinator = () => createTelegramLiveRebindCoordinator({
3210
3347
  request,
3211
3348
  messages: selection.messages,
3212
3349
  restoreStore: store,
3213
3350
  threadStore: threads,
3351
+ // Same title as Workspace Restore: the display-mode title of the restored binding on its new tab.
3352
+ retitle: deps.callApi
3353
+ ? async (isCurrent) => {
3354
+ if (!isCurrent())
3355
+ return;
3356
+ const expected = threads.getWorkspaceBindingByTarget(target, binding.sessionId);
3357
+ if (!expected || expected.bindingKey !== binding.bindingKey || expected.slot !== binding.slot)
3358
+ throw new Error("Live-rebind title binding is unavailable.");
3359
+ const callApi = deps.callApi;
3360
+ const slot = binding.slot ?? "";
3361
+ const resolveTitle = () => deps.getDisplayTitle?.(expected.target) ??
3362
+ ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot);
3363
+ const title = resolveTitle();
3364
+ const bindingCurrent = (frame) => isCurrent() && deps.callApi === callApi &&
3365
+ isDeepStrictEqual(threads.getWorkspaceBindingByTarget(frame.target, frame.sessionId), frame);
3366
+ const assertTitle = () => {
3367
+ if (!bindingCurrent(expected) || resolveTitle() !== title)
3368
+ throw new Error("Live-rebind title authority changed.");
3369
+ };
3370
+ assertTitle();
3371
+ const renamed = await callApi.call(deps, "editForumTopic", {
3372
+ chat_id: expected.target.chatId,
3373
+ message_thread_id: expected.target.threadId,
3374
+ name: title,
3375
+ }, { maxAttempts: 1, assertAuthority: assertTitle });
3376
+ assertTitle();
3377
+ if (renamed !== true || !threads.setWorkspaceDisplayTitle(expected, title))
3378
+ throw new Error("Live-rebind title acknowledgement is unconfirmed.");
3379
+ const acknowledged = { ...expected, displayTitle: title };
3380
+ await threads.persist(() => bindingCurrent(acknowledged));
3381
+ if (!bindingCurrent(acknowledged))
3382
+ throw new Error("Live-rebind title publication authority changed.");
3383
+ }
3384
+ : undefined,
3214
3385
  authority: {
3215
3386
  executor: { instanceId, leaderEpoch: String(epoch) },
3216
3387
  operatorUserId,
3217
- isCurrent: current,
3388
+ isCurrent: () => current() && (!preDispatchCleanup || deps.canUsePreDispatchRebind?.(recipient) === true),
3218
3389
  },
3219
3390
  getRecipient,
3391
+ ...(preDispatchCleanup ? {
3392
+ async cleanupBeforeRelease(intent, isCurrent) {
3393
+ const delay = deps.liveRebindCleanupSchedule?.delaysMs[0] ?? 1_000;
3394
+ await new Promise(resolve => { const timer = setTimeout(resolve, delay); timer.unref?.(); });
3395
+ if (!isCurrent())
3396
+ return false;
3397
+ const result = await inspectLiveRebindRecipient(intent, ctx, isLeader ? "issue" : "follower-issue", isCurrent);
3398
+ if (!isCurrent())
3399
+ return false;
3400
+ // A session-replacing producer cannot retain an old-session cleanup grant across its gateway.
3401
+ if (result?.status === "not-ready" && selection.confirmationCommand?.name === "new")
3402
+ return !!store.advanceLiveRebind(intent, "protect-old-target", { executor: intent.executor, operatorUserId, isCurrent });
3403
+ return result?.status === "not-ready" || result?.status === "finished" && result.recorded;
3404
+ },
3405
+ } : {}),
3220
3406
  follower: isLeader
3221
3407
  ? undefined
3222
3408
  : {
@@ -3247,9 +3433,9 @@ export function createTelegramInboundRouteRuntime(deps) {
3247
3433
  instanceId,
3248
3434
  leaderEpoch: String(epoch),
3249
3435
  }) &&
3250
- !intent.cleanup &&
3251
- (intent.phase === "rebound" ||
3252
- intent.phase === "released") &&
3436
+ (!intent.cleanup || intent.inputRelease !== undefined && intent.cleanup !== "issued") &&
3437
+ (intent.phase === "rebound" || intent.phase === "released" ||
3438
+ intent.phase === "finished" && intent.inputRelease !== undefined) &&
3253
3439
  isDeepStrictEqual(intent.recipient, {
3254
3440
  kind: recipient.kind,
3255
3441
  instanceId: recipient.instanceId,
@@ -3423,7 +3609,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3423
3609
  },
3424
3610
  };
3425
3611
  };
3426
- async function inspectLiveRebindRecipient(intent, ctx, mode) {
3612
+ async function inspectLiveRebindRecipient(intent, ctx, mode, admittedOrigin) {
3427
3613
  const ports = deps.workspaceRestoreRecipient, threads = deps.threadStore, store = deps.getWorkspaceRestoreStore?.();
3428
3614
  const followerMode = mode === "follower-cleanup" || mode === "follower-issue", cleanupMode = mode !== "work";
3429
3615
  const issueMode = mode === "issue" || mode === "follower-issue", callApi = deps.callApi;
@@ -3435,8 +3621,9 @@ export function createTelegramInboundRouteRuntime(deps) {
3435
3621
  (followerMode ? !peer || !peerRun || !peerJournal : !collector) ||
3436
3622
  !run ||
3437
3623
  (issueMode && !callApi) ||
3438
- deps.isContextActive?.(ctx) !== true)
3624
+ deps.isContextActive?.(ctx) !== true) {
3439
3625
  return undefined;
3626
+ }
3440
3627
  try {
3441
3628
  const expected = structuredClone(intent), { request, recipient, executor } = expected;
3442
3629
  const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.(), generation = deps.getSessionGeneration?.();
@@ -3526,7 +3713,8 @@ export function createTelegramInboundRouteRuntime(deps) {
3526
3713
  peerJournal.call(peer, live) === recipientJournalKey &&
3527
3714
  isDeepStrictEqual(findReference(), reference));
3528
3715
  };
3529
- const current = () => deps.hasWorkspaceLiveRebindAuthority?.() === true &&
3716
+ const current = () => (!admittedOrigin || admittedOrigin()) &&
3717
+ deps.hasWorkspaceLiveRebindAuthority?.() === true &&
3530
3718
  deps.isContextActive?.(ctx) === true &&
3531
3719
  deps.workspaceRestoreRecipient === ports &&
3532
3720
  (followerMode || ports.observeLeaderWork === collector) &&
@@ -3571,8 +3759,11 @@ export function createTelegramInboundRouteRuntime(deps) {
3571
3759
  };
3572
3760
  const sample = async () => {
3573
3761
  assertCanonical();
3574
- if (cleanupMode && protection.call(threads, expected) !== false)
3575
- return undefined;
3762
+ if (cleanupMode) {
3763
+ const protectedTarget = protection.call(threads, expected);
3764
+ if (protectedTarget !== false)
3765
+ return undefined;
3766
+ }
3576
3767
  const target = { ...request.binding.target };
3577
3768
  let value;
3578
3769
  if (followerMode) {
@@ -3581,7 +3772,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3581
3772
  instanceId: recipient.instanceId,
3582
3773
  sessionId: recipient.sessionId,
3583
3774
  recipientBindingKey: recipientJournalKey,
3584
- mode: "observe",
3775
+ mode: expected.inputRelease === "held" ? "observe-held" : "observe",
3585
3776
  sourceUpdateIds: [...request.source.updateIds],
3586
3777
  slot: request.binding.slot,
3587
3778
  target: { ...request.target },
@@ -3623,12 +3814,13 @@ export function createTelegramInboundRouteRuntime(deps) {
3623
3814
  throw new Error("Live-rebind work sample is unavailable or changed target.");
3624
3815
  assertCanonical();
3625
3816
  if (cleanupMode) {
3817
+ const targetProtected = protection.call(threads, expected);
3626
3818
  const prepared = ThreadReconciler.prepareLiveRebindThreadCleanup({
3627
3819
  operationId: request.operationId,
3628
3820
  oldTarget: request.binding.target,
3629
3821
  recipientTarget: request.target,
3630
3822
  leaderEpoch: executor.leaderEpoch,
3631
- targetProtected: protection.call(threads, expected),
3823
+ targetProtected,
3632
3824
  work,
3633
3825
  });
3634
3826
  assertCanonical();
@@ -3652,32 +3844,27 @@ export function createTelegramInboundRouteRuntime(deps) {
3652
3844
  return undefined;
3653
3845
  if (issueMode) {
3654
3846
  // One admission spans the fresh sample, durable issue marker, single deletion and terminal record.
3655
- return await run({
3656
- operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
3657
- operationKind: "workspace.live-rebind-cleanup",
3658
- scopes: [{ kind: "profile" }],
3659
- }, async () => {
3847
+ const execute = async () => {
3660
3848
  const prepared = await sample();
3661
3849
  if (!prepared ||
3662
3850
  prepared.status !== "prepared" ||
3663
3851
  !current() ||
3664
- protection.call(threads, expected) !== false)
3852
+ protection.call(threads, expected) !== false) {
3665
3853
  return { status: "not-ready" };
3854
+ }
3666
3855
  const authority = {
3667
3856
  executor: structuredClone(executor),
3668
3857
  operatorUserId: expected.operatorUserId,
3669
3858
  isCurrent: current,
3670
3859
  };
3671
3860
  const grant = store.advanceLiveRebind(expected, "issue-cleanup", authority);
3672
- if (!grant)
3861
+ if (!grant) {
3673
3862
  return undefined;
3863
+ }
3674
3864
  const assertGranted = () => assertCanonical(grant);
3675
3865
  const cleanup = await ThreadReconciler.issueLiveRebindThreadCleanup(prepared, {
3676
3866
  assertCurrent: assertGranted,
3677
- deleteTopic: (target) => callApi("deleteForumTopic", {
3678
- chat_id: target.chatId,
3679
- message_thread_id: target.threadId,
3680
- }, { maxAttempts: 1, assertAuthority: assertGranted }),
3867
+ deleteTopic: (target) => callApi("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId }, { maxAttempts: 1, assertAuthority: assertGranted }),
3681
3868
  classifyFailure: classifyLiveRebindCleanupFailure,
3682
3869
  });
3683
3870
  let recorded = false;
@@ -3695,7 +3882,12 @@ export function createTelegramInboundRouteRuntime(deps) {
3695
3882
  cleanup,
3696
3883
  recorded,
3697
3884
  };
3698
- });
3885
+ };
3886
+ // Pre-dispatch calls are private to the chooser callback already holding the same profile admission/gate.
3887
+ return admittedOrigin ? await execute() : await run({
3888
+ operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
3889
+ operationKind: "workspace.live-rebind-cleanup", scopes: [{ kind: "profile" }],
3890
+ }, execute);
3699
3891
  }
3700
3892
  const observation = await run({
3701
3893
  operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
@@ -3729,6 +3921,26 @@ export function createTelegramInboundRouteRuntime(deps) {
3729
3921
  const issueLiveRebindFollowerCleanup = (intent, ctx) => inspectLiveRebindRecipient(intent, ctx, "follower-issue");
3730
3922
  // One pacing chain per operation: a waiting timer or a running attempt; repeated choices never start another.
3731
3923
  const liveRebindCleanupChains = new Map();
3924
+ // One hint slot per existing leader chain; a running attempt retains at most one coalesced recheck.
3925
+ const liveRebindCleanupWakers = new Map();
3926
+ const captureLiveRebindCleanupScope = (ctx) => {
3927
+ try {
3928
+ const generation = deps.getSessionGeneration?.(), instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.(), store = deps.getWorkspaceRestoreStore?.(), operator = deps.configStore.getAllowedUserId();
3929
+ return () => {
3930
+ try {
3931
+ return deps.isContextActive?.(ctx) === true && deps.getSessionGeneration?.() === generation &&
3932
+ deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
3933
+ deps.getWorkspaceRestoreStore?.() === store && deps.configStore.getAllowedUserId() === operator;
3934
+ }
3935
+ catch {
3936
+ return false;
3937
+ }
3938
+ };
3939
+ }
3940
+ catch {
3941
+ return () => false;
3942
+ }
3943
+ };
3732
3944
  /** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
3733
3945
  const terminalizeUnissuedLiveRebindCleanup = async (operationId, ctx) => {
3734
3946
  const run = deps.runWorkspaceOperation, store = deps.getWorkspaceRestoreStore?.();
@@ -3769,23 +3981,53 @@ export function createTelegramInboundRouteRuntime(deps) {
3769
3981
  * a finished outcome or an inactive session stops, and the window ends in `not-issued` so no released row becomes a
3770
3982
  * permanent rebinding blocker. Waiting timers never join settlement; only a running attempt does.
3771
3983
  */
3772
- const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0) => {
3984
+ const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0, current = captureLiveRebindCleanupScope(ctx)) => {
3773
3985
  const pacing = deps.liveRebindCleanupSchedule;
3774
3986
  const chain = liveRebindCleanupChains.get(operationId);
3775
3987
  if (!pacing ||
3776
3988
  (attempt === 0 ? chain !== undefined : chain !== "running") ||
3777
- deps.isContextActive?.(ctx) !== true) {
3778
- if (attempt > 0 && chain === "running")
3989
+ !current()) {
3990
+ if (attempt > 0 && chain === "running") {
3779
3991
  liveRebindCleanupChains.delete(operationId);
3992
+ liveRebindCleanupWakers.delete(operationId);
3993
+ }
3780
3994
  return;
3781
3995
  }
3782
- const timer = setTimeout(() => {
3996
+ let pendingWake;
3997
+ const wake = (reason) => {
3998
+ if (!current())
3999
+ return;
4000
+ if (liveRebindCleanupChains.get(operationId) === "running") {
4001
+ pendingWake = reason;
4002
+ return;
4003
+ }
3783
4004
  if (liveRebindCleanupChains.get(operationId) !== timer)
3784
4005
  return;
4006
+ if (reason === "delivery-drained") {
4007
+ let idle;
4008
+ try {
4009
+ idle = deps.isIdle?.(ctx);
4010
+ }
4011
+ catch { /* A missing idle observation is not a reason to hurry cleanup. */ }
4012
+ if (idle !== true) {
4013
+ // A streamed technical task can drain between deltas while Pi still runs. Keep one hint, not a polling loop.
4014
+ pendingWake = reason;
4015
+ return;
4016
+ }
4017
+ }
4018
+ clearTimeout(timer);
4019
+ attemptNow();
4020
+ };
4021
+ const attemptNow = () => {
4022
+ if (liveRebindCleanupChains.get(operationId) !== timer)
4023
+ return;
4024
+ pendingWake = undefined; // This fresh timer/settlement attempt consumes any previously deferred hint.
3785
4025
  liveRebindCleanupChains.set(operationId, "running");
4026
+ if (role === "leader")
4027
+ liveRebindCleanupWakers.set(operationId, wake);
3786
4028
  let continued = false;
3787
4029
  const task = (async () => {
3788
- if (deps.isContextActive?.(ctx) !== true)
4030
+ if (!current())
3789
4031
  return;
3790
4032
  const intent = deps
3791
4033
  .getWorkspaceRestoreStore?.()
@@ -3797,27 +4039,44 @@ export function createTelegramInboundRouteRuntime(deps) {
3797
4039
  ? await issueLiveRebindLeaderCleanup(intent, ctx)
3798
4040
  : await issueLiveRebindFollowerCleanup(intent, ctx);
3799
4041
  if (result?.status === "finished" ||
3800
- deps.isContextActive?.(ctx) !== true)
4042
+ !current())
3801
4043
  return;
3802
- if (Date.now() - startedAtMs >= pacing.windowMs)
4044
+ if (Date.now() - startedAtMs >= pacing.windowMs) {
3803
4045
  return terminalizeUnissuedLiveRebindCleanup(operationId, ctx);
4046
+ }
3804
4047
  continued = true;
3805
- scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1);
4048
+ scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1, current);
4049
+ if (pendingWake)
4050
+ liveRebindCleanupWakers.get(operationId)?.(pendingWake);
3806
4051
  })()
3807
4052
  .catch((error) => deps.recordRuntimeEvent?.("routing", error, {
3808
4053
  phase: "live-rebind-cleanup-schedule",
3809
4054
  }))
3810
4055
  .finally(() => {
3811
4056
  if (!continued &&
3812
- liveRebindCleanupChains.get(operationId) === "running")
4057
+ liveRebindCleanupChains.get(operationId) === "running") {
3813
4058
  liveRebindCleanupChains.delete(operationId);
4059
+ liveRebindCleanupWakers.delete(operationId);
4060
+ }
3814
4061
  });
3815
4062
  restoreSettlementTasks.add(task);
3816
4063
  void task.finally(() => restoreSettlementTasks.delete(task));
3817
- }, pacing.delaysMs[attempt] ?? pacing.intervalMs);
4064
+ };
4065
+ const timer = setTimeout(attemptNow, pacing.delaysMs[attempt] ?? pacing.intervalMs);
3818
4066
  timer.unref?.();
3819
4067
  liveRebindCleanupChains.set(operationId, timer);
4068
+ // The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
4069
+ if (role === "leader" && attempt > 0)
4070
+ liveRebindCleanupWakers.set(operationId, wake);
3820
4071
  };
4072
+ const wakeLiveRebindCleanup = (ctx, reason) => {
4073
+ if (deps.isContextActive?.(ctx) !== true)
4074
+ return;
4075
+ for (const wake of [...liveRebindCleanupWakers.values()])
4076
+ wake(reason);
4077
+ };
4078
+ const onSessionSettled = (ctx) => wakeLiveRebindCleanup(ctx, "session-settled");
4079
+ const onDeliverySettled = (ctx) => wakeLiveRebindCleanup(ctx, "delivery-drained");
3821
4080
  const restoreWorkspace = deps.workspaceRestoreRecipient
3822
4081
  ? async (input) => {
3823
4082
  const ports = deps.workspaceRestoreRecipient;
@@ -4203,17 +4462,25 @@ export function createTelegramInboundRouteRuntime(deps) {
4203
4462
  }
4204
4463
  await promptEnqueue(messages, ctx);
4205
4464
  };
4206
- const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query)) => {
4465
+ const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query), retainRecord = false) => {
4207
4466
  assertExecutionCurrent();
4208
4467
  const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
4209
4468
  assertExecutionCurrent();
4210
4469
  if (dismissed) {
4211
- removePendingReroute(rerouteId);
4470
+ // A retained record no longer matches any chooser, so a late duplicate tap reads as expired.
4471
+ if (retainRecord)
4472
+ pending.chooserMessageId = undefined;
4473
+ else
4474
+ removePendingReroute(rerouteId);
4212
4475
  await deps.answerCallbackQuery(query.id, successMessage);
4213
4476
  await deleteAllTabCopies(pending);
4214
4477
  return;
4215
4478
  }
4216
- pending.phase = { kind: "finalizing", message: successMessage };
4479
+ pending.phase = {
4480
+ kind: "finalizing",
4481
+ message: successMessage,
4482
+ ...(retainRecord ? { retainRecord: true } : {}),
4483
+ };
4217
4484
  await deps.answerCallbackQuery(query.id, `${successMessage}; tap again to clear the menu`);
4218
4485
  };
4219
4486
  const isTemporaryReroute = (pending) => !!pending.temporaryThread ||
@@ -4613,10 +4880,10 @@ export function createTelegramInboundRouteRuntime(deps) {
4613
4880
  const retireCancellationChooser = async (id, pending, isCurrent) => {
4614
4881
  if (!isPendingRerouteCancelled(pending) ||
4615
4882
  !isCurrent() ||
4616
- !deps.editInteractiveMessage ||
4883
+ (!deps.deleteMessage && !deps.editInteractiveMessage) ||
4617
4884
  pending.chooserMessageId === undefined)
4618
4885
  return false;
4619
- await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⛔️ Routing cancelled.</b>", "html", { inline_keyboard: [] });
4886
+ await retireRerouteChooserMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⛔️ Routing cancelled.</b>");
4620
4887
  if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
4621
4888
  return false;
4622
4889
  removePendingReroute(id);
@@ -4973,9 +5240,11 @@ export function createTelegramInboundRouteRuntime(deps) {
4973
5240
  if (!result)
4974
5241
  return;
4975
5242
  // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
5243
+ let movedIntoTab = false;
4976
5244
  for (const [id, pending] of pendingUnboundReroutes) {
4977
5245
  if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
4978
5246
  continue;
5247
+ movedIntoTab ||= pending.allTabCopiesForwarded === true;
4979
5248
  removePendingReroute(id);
4980
5249
  if (signal.aborted ||
4981
5250
  deps.isContextActive?.(ctx) !== true ||
@@ -4999,7 +5268,9 @@ export function createTelegramInboundRouteRuntime(deps) {
4999
5268
  // never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
5000
5269
  if (temporary?.target && runtimeCurrent()) {
5001
5270
  scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
5002
- await deleteAllTabMessages(temporary.target.chatId, getTelegramAllTabSourceMessageIds([message], temporary.target.chatId));
5271
+ // A moved original already left All with its forward; only an unmoved one is deleted here.
5272
+ if (!movedIntoTab)
5273
+ await deleteAllTabMessages(temporary.target.chatId, getTelegramAllTabSourceMessageIds([message], temporary.target.chatId));
5003
5274
  }
5004
5275
  });
5005
5276
  };
@@ -5448,7 +5719,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5448
5719
  return true;
5449
5720
  }
5450
5721
  if (pending.phase.kind === "finalizing") {
5451
- await finalizePendingReroute(parsed.rerouteId, pending, query, pending.phase.message, assertExecutionCurrent);
5722
+ await finalizePendingReroute(parsed.rerouteId, pending, query, pending.phase.message, assertExecutionCurrent, pending.phase.retainRecord === true);
5452
5723
  return true;
5453
5724
  }
5454
5725
  if (pending.liveRebind &&
@@ -5709,21 +5980,23 @@ export function createTelegramInboundRouteRuntime(deps) {
5709
5980
  pending.liveRebind ??= selection;
5710
5981
  if (!(await selectPendingDestination()))
5711
5982
  return true;
5983
+ const operationId = selection.coordinator.operationId;
5712
5984
  if (current())
5713
5985
  outcome = await selection.coordinator.advance();
5714
5986
  // Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
5715
5987
  // still be pending while every attempt samples current work afresh.
5716
- const operationId = selection.coordinator.operationId;
5717
5988
  const row = outcome === "protected"
5718
5989
  ? undefined
5719
5990
  : deps
5720
5991
  .getWorkspaceRestoreStore?.()
5721
5992
  ?.listLiveRebindings()
5722
5993
  .find((value) => value.request.operationId === operationId);
5723
- if (row?.phase === "released" && row.cleanup === undefined)
5994
+ if (row?.phase === "released" && row.cleanup === undefined &&
5995
+ (row.inputRelease === undefined || row.inputRelease === "accepted")) {
5724
5996
  scheduleLiveRebindCleanup(operationId, selection.record.owner?.kind === "leader" ? "leader" : "follower", ctx);
5997
+ }
5725
5998
  if (outcome === "released")
5726
- await deleteAllTabCopies(pending);
5999
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
5727
6000
  }
5728
6001
  catch (error) {
5729
6002
  deps.recordRuntimeEvent?.("routing", error, {
@@ -5734,8 +6007,24 @@ export function createTelegramInboundRouteRuntime(deps) {
5734
6007
  selection.isCurrent = undefined;
5735
6008
  }
5736
6009
  assertExecutionCurrent();
5737
- await deps.answerCallbackQuery(query.id, outcome === "released"
5738
- ? selection.extensionKind === "generated-prompt"
6010
+ if (outcome !== "released") {
6011
+ await deps.answerCallbackQuery(query.id, "Live rebind is unconfirmed; this choice never replays issued input");
6012
+ return true;
6013
+ }
6014
+ const cleanupRow = deps.getWorkspaceRestoreStore?.()?.listLiveRebindings().find(value => value.request.operationId === selection.coordinator?.operationId);
6015
+ const cleanupResult = cleanupRow?.inputRelease && cleanupRow.cleanup !== undefined
6016
+ ? cleanupRow.cleanup === "confirmed" ? "old-Thread removal confirmed"
6017
+ : cleanupRow.cleanup === "unknown" || cleanupRow.cleanup === "issued" ? "old-Thread removal unconfirmed; deletion will not repeat"
6018
+ : cleanupRow.cleanup === "failed" ? "old-Thread removal failed" : "old-Thread removal was not issued"
6019
+ : undefined;
6020
+ const commandDisposed = !!(selection.extensionCommand && selection.extensionKind !== "generated-prompt" || selection.nameCommand ||
6021
+ selection.confirmationCommand || selection.helpCommand || selection.stopCommand || selection.nextCommand ||
6022
+ selection.menuCommand || selection.abortCommand || selection.coordinator?.selectedCommandReference());
6023
+ // The tab now belongs to the rebound thread; only the one-shot chooser message goes.
6024
+ await finalizePendingReroute(parsed.rerouteId, pending, query, cleanupResult
6025
+ ? commandDisposed ? `Command source disposal confirmed; ${cleanupResult}; delivery and follow-on effects remain independent`
6026
+ : `Input queued in the rebound Thread; ${cleanupResult}`
6027
+ : selection.extensionKind === "generated-prompt"
5739
6028
  ? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
5740
6029
  : selection.extensionCommand
5741
6030
  ? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
@@ -5755,8 +6044,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5755
6044
  ? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
5756
6045
  : selection.coordinator?.selectedCommandReference()
5757
6046
  ? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
5758
- : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
5759
- : "Live rebind is unconfirmed; this choice never replays issued input");
6047
+ : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
5760
6048
  return true;
5761
6049
  }
5762
6050
  if (!(await selectPendingDestination()))
@@ -5768,6 +6056,8 @@ export function createTelegramInboundRouteRuntime(deps) {
5768
6056
  messages: [...pending.messages],
5769
6057
  });
5770
6058
  let active = true;
6059
+ // Delivery proof for the toast only; chooser retirement still waits for the positive settlement that follows.
6060
+ let delivered = false;
5771
6061
  const ownerUserId = deps.configStore.getAllowedUserId();
5772
6062
  const leaderEpoch = deps.getCurrentLeaderEpoch?.();
5773
6063
  const admissionScope = deps.getAdmissionScope?.();
@@ -5818,12 +6108,15 @@ export function createTelegramInboundRouteRuntime(deps) {
5818
6108
  : routed;
5819
6109
  await dispatchPendingRerouteMessages(pending, messages, ctx);
5820
6110
  assertRecipientCurrent();
5821
- await deleteAllTabCopies(pending);
6111
+ delivered = true;
6112
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
5822
6113
  return true;
5823
6114
  }
5824
6115
  const forwarded = await forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
5825
- if (forwarded)
5826
- await deleteAllTabCopies(pending);
6116
+ if (forwarded) {
6117
+ delivered = true;
6118
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
6119
+ }
5827
6120
  return forwarded;
5828
6121
  },
5829
6122
  });
@@ -5834,7 +6127,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5834
6127
  assertExecutionCurrent();
5835
6128
  if (!selectionCurrent())
5836
6129
  return true;
5837
- await deps.answerCallbackQuery(query.id, "Restore unconfirmed; not resending");
6130
+ await deps.answerCallbackQuery(query.id, delivered ? "Restored" : "Restore unconfirmed; not resending");
5838
6131
  return true;
5839
6132
  }
5840
6133
  if (record.instanceId &&
@@ -6856,13 +7149,16 @@ export function createTelegramInboundRouteRuntime(deps) {
6856
7149
  }
6857
7150
  return undefined;
6858
7151
  };
6859
- /** A fresh implicit native tab whose name the All input starts with, observed just before it in the same exact scope. */
7152
+ /**
7153
+ * A fresh implicit native tab observed just before the All input in the same exact scope: mobile names it after
7154
+ * the input, while other clients may use a generic name, so the immediately preceding creation also qualifies.
7155
+ */
6860
7156
  const findImplicitCreationForAllInput = (message, updateId, cap, ctx) => {
6861
7157
  const text = (message.text ?? message.caption ?? "").trim();
6862
7158
  let match;
6863
7159
  for (const [key, implicit] of implicitThreadCreations) {
6864
- if (implicit.name &&
6865
- text.startsWith(implicit.name) &&
7160
+ if (((implicit.name && text.startsWith(implicit.name)) ||
7161
+ implicit.updateId === updateId - 1) &&
6866
7162
  implicit.updateId < updateId &&
6867
7163
  implicit.createdAtSec > 0 &&
6868
7164
  message.date !== undefined &&
@@ -6884,28 +7180,42 @@ export function createTelegramInboundRouteRuntime(deps) {
6884
7180
  * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
6885
7181
  * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
6886
7182
  */
6887
- const sendAllTabTemporaryInputChooser = async (command, commandText, message, ctx) => {
6888
- const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([
6889
- message,
6890
- ]);
7183
+ /**
7184
+ * Opens (or adopts) the routing tab for input sent from All. One text message may carry a command; anything else,
7185
+ * a voice note, a file or a coalesced album, is held untouched as one prompt.
7186
+ */
7187
+ const sendAllTabTemporaryInputChooser = async (command, commandText, messages, ctx) => {
7188
+ const message = messages[0];
7189
+ if (!message)
7190
+ return false;
7191
+ const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds([
7192
+ ...messages,
7193
+ ]).sort((a, b) => a - b);
7194
+ const updateId = updateIds[0];
7195
+ const group = { journalBindingKey: "", updateIds };
6891
7196
  const execution = Updates.getTelegramUpdateExecutionFence(message);
6892
7197
  const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
6893
7198
  if (!cap ||
6894
7199
  !deps.runWorkspaceOperation ||
6895
7200
  !deps.callApi ||
6896
7201
  updateId === undefined ||
6897
- message.chat.type !== "private" ||
6898
- message.chat.id !== cap.operatorUserId ||
6899
- message.from?.id !== cap.operatorUserId ||
7202
+ updateIds.length !== messages.length ||
7203
+ messages.some((held) => held.chat.type !== "private" ||
7204
+ held.chat.id !== cap.operatorUserId ||
7205
+ held.from?.id !== cap.operatorUserId) ||
6900
7206
  !cap.isCurrent())
6901
7207
  return false;
6902
7208
  const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
6903
7209
  const source = { journalBindingKey, updateId };
7210
+ group.journalBindingKey = journalBindingKey;
6904
7211
  const find = () => store
6905
7212
  .listTemporaryThreads()
6906
7213
  .find((entry) => entry.source.journalBindingKey === journalBindingKey &&
6907
7214
  entry.source.updateId === updateId);
6908
7215
  let entry;
7216
+ // A tab opened by this call, by the bot or adopted from the client, gets the input's one forwarded copy; re-entry
7217
+ // after a restart finds its tab already prepared and never forwards again.
7218
+ let openedNow = false;
6909
7219
  await deps.runWorkspaceOperation({
6910
7220
  operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
6911
7221
  operationKind: "workspace.temporary-thread",
@@ -6923,7 +7233,7 @@ export function createTelegramInboundRouteRuntime(deps) {
6923
7233
  // the input itself to All: adopt that tab rather than leaving it behind next to a second one.
6924
7234
  const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
6925
7235
  if (native) {
6926
- const adopted = store.registerImplicitTemporaryThread({ journalBindingKey, updateIds: [updateId] }, native.implicit.target, randomBytes(16).toString("hex"), {
7236
+ const adopted = store.registerImplicitTemporaryThread(group, native.implicit.target, randomBytes(16).toString("hex"), {
6927
7237
  ...authority,
6928
7238
  isCurrent: () => current() &&
6929
7239
  implicitThreadCreations.get(native.key) === native.implicit,
@@ -6931,6 +7241,7 @@ export function createTelegramInboundRouteRuntime(deps) {
6931
7241
  if (adopted && current()) {
6932
7242
  implicitThreadCreations.delete(native.key);
6933
7243
  entry = adopted;
7244
+ openedNow = true;
6934
7245
  // Adopted native tabs share the routing name; a failed rename never blocks routing.
6935
7246
  try {
6936
7247
  await deps.callApi("editForumTopic", {
@@ -6947,7 +7258,7 @@ export function createTelegramInboundRouteRuntime(deps) {
6947
7258
  return;
6948
7259
  }
6949
7260
  }
6950
- const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
7261
+ const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority, group);
6951
7262
  entry = reservation?.entry;
6952
7263
  if (!reservation?.reserved || !current())
6953
7264
  return;
@@ -6971,31 +7282,39 @@ export function createTelegramInboundRouteRuntime(deps) {
6971
7282
  !Number.isSafeInteger(threadId) ||
6972
7283
  threadId <= 0)
6973
7284
  return;
6974
- entry =
6975
- store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority) ?? entry;
7285
+ const acknowledged = store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority);
7286
+ openedNow = !!acknowledged;
7287
+ entry = acknowledged ?? entry;
6976
7288
  });
6977
7289
  if (!current() || !entry)
6978
7290
  throw new Error("Temporary Thread authority changed; the input remains retryable.");
6979
- const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
6980
- ...message,
6981
- text: commandText,
6982
- caption: undefined,
6983
- });
7291
+ // Only a lone text message is rewritten for command detection; media keeps its caption and payload.
7292
+ const held = messages.length === 1 && typeof message.text === "string"
7293
+ ? [
7294
+ Updates.carryTelegramUpdateExecutionFence(message, {
7295
+ ...message,
7296
+ text: commandText,
7297
+ caption: undefined,
7298
+ }),
7299
+ ]
7300
+ : [...messages];
6984
7301
  if (entry.phase !== "created" || !entry.target) {
6985
- Updates.reportTelegramUpdateDeferred(commandMessage);
6986
- await deps.sendTextReply(message.chat.id, message.message_id, "<b>⚠️ The routing tab for this command could not be confirmed.</b> It stays held and was not sent to Pi.", { parseMode: "HTML" });
7302
+ for (const input of held)
7303
+ Updates.reportTelegramUpdateDeferred(input);
7304
+ deps.recordRuntimeEvent?.("routing", new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR), { phase: "temporary-thread-unconfirmed" });
6987
7305
  return true;
6988
7306
  }
6989
7307
  const target = entry.target;
6990
7308
  const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
6991
- const rerouteId = storePendingUnboundReroute([commandMessage], command ? "command" : "prompt", target);
7309
+ const rerouteId = storePendingUnboundReroute(held, command ? "command" : "prompt", target);
6992
7310
  const pending = pendingUnboundReroutes.get(rerouteId);
6993
7311
  pending.temporaryThread = entry;
6994
- pending.allTabCopies = getTelegramAllTabSourceMessageIds([message], target.chatId);
6995
- Updates.reportTelegramUpdateDeferred(commandMessage);
7312
+ pending.allTabCopies = getTelegramAllTabSourceMessageIds(messages, target.chatId);
7313
+ for (const input of held)
7314
+ Updates.reportTelegramUpdateDeferred(input);
6996
7315
  // Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
6997
7316
  if (deps.editInteractiveMessage &&
6998
- Updates.supportsTelegramDeferredAbandonment(commandMessage, journalBindingKey)) {
7317
+ held.every((input) => Updates.supportsTelegramDeferredAbandonment(input, journalBindingKey))) {
6999
7318
  pending.abandonment = {
7000
7319
  ownerUserId: operatorUserId,
7001
7320
  journalBindingKey,
@@ -7003,6 +7322,9 @@ export function createTelegramInboundRouteRuntime(deps) {
7003
7322
  };
7004
7323
  }
7005
7324
  pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
7325
+ const tabCopyId = openedNow
7326
+ ? await moveAllTabSourceIntoTab(pending, target, current)
7327
+ : undefined;
7006
7328
  try {
7007
7329
  if (!deps.sendInteractiveMessage)
7008
7330
  throw new Error("Temporary Thread chooser publication is unavailable.");
@@ -7010,7 +7332,9 @@ export function createTelegramInboundRouteRuntime(deps) {
7010
7332
  canRestore: true,
7011
7333
  canCancel: !!pending.abandonment,
7012
7334
  getDisplayTitle: deps.getDisplayTitle,
7013
- }), { target });
7335
+ }), tabCopyId === undefined
7336
+ ? { target }
7337
+ : { target, replyToMessageId: tabCopyId });
7014
7338
  rememberRerouteChooser(rerouteId, chooserId);
7015
7339
  }
7016
7340
  catch (error) {
@@ -7019,72 +7343,11 @@ export function createTelegramInboundRouteRuntime(deps) {
7019
7343
  }
7020
7344
  return true;
7021
7345
  };
7022
- const sendAllTabCommandChooser = async (command, commandText, message, options = {}) => {
7023
- if (!deps.threadStore)
7024
- return false;
7025
- const records = deps.threadStore.list();
7026
- const activeRecords = getTelegramRoutableThreadRecords(records, deps.getLiveThreadTargets?.());
7027
- if (activeRecords.length === 0)
7028
- return false;
7029
- const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
7030
- ...message,
7031
- text: commandText,
7032
- caption: undefined,
7033
- });
7034
- const rerouteId = storePendingUnboundReroute([commandMessage], "command");
7035
- Updates.reportTelegramUpdateDeferred(commandMessage);
7036
- const text = formatTelegramAllTabMenuChooserText(command.name);
7037
- pendingUnboundReroutes.get(rerouteId).rootChooserText = text;
7038
- const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, {
7039
- canRestore: typeof message.message_thread_id === "number",
7040
- getDisplayTitle: deps.getDisplayTitle,
7041
- });
7042
- let chooserId;
7043
- try {
7044
- if (deps.sendInteractiveMessage) {
7045
- chooserId = await deps.sendInteractiveMessage(message.chat.id, text, "html", replyMarkup, options.target || options.replyToSource
7046
- ? {
7047
- ...(options.target ? { target: options.target } : {}),
7048
- ...(options.replyToSource
7049
- ? { replyToMessageId: message.message_id }
7050
- : {}),
7051
- }
7052
- : undefined);
7053
- }
7054
- else if (deps.callApi) {
7055
- const chooser = await deps.callApi("sendMessage", {
7056
- chat_id: message.chat.id,
7057
- text,
7058
- parse_mode: "HTML",
7059
- reply_markup: replyMarkup,
7060
- ...(typeof options.target?.threadId === "number"
7061
- ? { message_thread_id: options.target.threadId }
7062
- : {}),
7063
- ...(options.replyToSource
7064
- ? {
7065
- reply_parameters: {
7066
- message_id: message.message_id,
7067
- allow_sending_without_reply: true,
7068
- },
7069
- }
7070
- : {}),
7071
- });
7072
- chooserId = chooser?.message_id;
7073
- }
7074
- else {
7075
- chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
7076
- parseMode: "HTML",
7077
- target: options.target,
7078
- });
7079
- }
7080
- }
7081
- catch (error) {
7082
- removePendingReroute(rerouteId);
7083
- throw error;
7084
- }
7085
- rememberRerouteChooser(rerouteId, chooserId);
7086
- Updates.reportTelegramUpdateCompleted(commandMessage);
7087
- return true;
7346
+ /** A coalesced album from All requires one routing tab; failure cannot answer in All or discard its source. */
7347
+ const routeAllTabAlbum = async (messages, ctx) => {
7348
+ if (await sendAllTabTemporaryInputChooser(undefined, "", messages, ctx))
7349
+ return;
7350
+ throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
7088
7351
  };
7089
7352
  const dispatchCommandOrPrompt = Commands.createTelegramCommandOrPromptDispatcher({
7090
7353
  extractRawText: Media.extractFirstTelegramMessageText,
@@ -7311,7 +7574,9 @@ export function createTelegramInboundRouteRuntime(deps) {
7311
7574
  chatId: lifecycle.target.chatId,
7312
7575
  threadId: lifecycle.target.threadId,
7313
7576
  },
7314
- name: "name" in created && typeof created.name === "string" ? created.name : "",
7577
+ name: "name" in created && typeof created.name === "string"
7578
+ ? created.name
7579
+ : "",
7315
7580
  createdAtSec: message.date ?? 0,
7316
7581
  });
7317
7582
  }
@@ -7529,28 +7794,39 @@ export function createTelegramInboundRouteRuntime(deps) {
7529
7794
  .some((entry) => entry.source.updateId === sourceUpdateId &&
7530
7795
  entry.source.journalBindingKey ===
7531
7796
  deps.getAdmissionJournalBinding?.()) === true;
7797
+ const needsThread = deps.threadStore.getBotState().threadMode === "enabled" || bindings.length > 0 || presented;
7532
7798
  // Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
7533
7799
  if (command &&
7534
7800
  command.name !== "thread" &&
7535
7801
  !presented &&
7536
7802
  isTelegramAllTabCommandExpired(message))
7537
7803
  return;
7538
- if (command?.name !== "thread" &&
7539
- (bindings.length > 0 || presented) &&
7804
+ if (needsThread &&
7540
7805
  typeof message.text === "string" &&
7541
7806
  message.text.trim() &&
7542
- (await sendAllTabTemporaryInputChooser(command, text, message, ctx)))
7807
+ (await sendAllTabTemporaryInputChooser(command, text, [message], ctx)))
7543
7808
  return;
7544
- if (bindings.length > 0 && command && command.name !== "thread") {
7545
- if (await sendAllTabCommandChooser(command, text, message, {
7546
- replyToSource: true,
7547
- })) {
7809
+ // Voice notes, files, stickers and albums from All get the same routing tab as text, whether or not the
7810
+ // client opened a tab for them; an album first gathers into one input so it opens one tab.
7811
+ if (!command &&
7812
+ needsThread &&
7813
+ typeof message.text !== "string" &&
7814
+ Media.hasTelegramMessagesPromptContent([message])) {
7815
+ if (message.media_group_id) {
7816
+ deps.mediaGroupRuntime.queueMessage({
7817
+ message: message,
7818
+ context: ctx,
7819
+ dispatchMessages: (messages, queuedCtx) => routeAllTabAlbum(messages, queuedCtx ?? ctx),
7820
+ });
7821
+ Updates.reportTelegramUpdateDeferred(message);
7548
7822
  return;
7549
7823
  }
7824
+ if (await sendAllTabTemporaryInputChooser(undefined, text, [message], ctx))
7825
+ return;
7550
7826
  }
7551
- if (bindings.length > 0 && !text.startsWith("/")) {
7827
+ if (needsThread) {
7552
7828
  const probeTarget = bindings[0]?.target;
7553
- if (probeTarget?.threadId && deps.callApi) {
7829
+ if (!command && !text.startsWith("/") && probeTarget?.threadId && deps.callApi) {
7554
7830
  try {
7555
7831
  await deps.callApi("sendChatAction", {
7556
7832
  chat_id: probeTarget.chatId,
@@ -7559,8 +7835,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7559
7835
  });
7560
7836
  }
7561
7837
  catch (error) {
7562
- if (Threads.isTelegramTopicModeUnavailableError(error) ||
7563
- Threads.isTelegramTopicTargetStaleError(error)) {
7838
+ if (Threads.isTelegramTopicModeUnavailableError(error)) {
7564
7839
  deps.threadStore.setBotState({
7565
7840
  threadMode: "disabled",
7566
7841
  updatedAtMs: Date.now(),
@@ -7578,8 +7853,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7578
7853
  });
7579
7854
  }
7580
7855
  }
7581
- await deps.sendTextReply(message.chat.id, message.message_id, "This bot is in threaded multi-instance mode. Send prompts in a bound Pi thread tab so they route to the right instance.");
7582
- return;
7856
+ throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
7583
7857
  }
7584
7858
  }
7585
7859
  await textDispatch.handleMessage(message, ctx);
@@ -7623,7 +7897,35 @@ export function createTelegramInboundRouteRuntime(deps) {
7623
7897
  threadId: target.threadId,
7624
7898
  };
7625
7899
  /** Rebinds the leader record to this unbound tab, publishes its identity and handles the message there. */
7626
- const reclaimUnboundTargetForLeader = async (base, profileKey, slot, threadName, event, details) => {
7900
+ // The leader's own Workspace binding follows a reclaimed tab, keeping its slot and name; without it, the tab
7901
+ // would gain a second slot while Restore, labels and titles still pointed at the deleted target.
7902
+ const leaderWorkspaceCwd = typeof ctx.cwd === "string"
7903
+ ? ctx.cwd
7904
+ : undefined;
7905
+ const claimLeaderWorkspaceBinding = () => {
7906
+ const sessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
7907
+ if (!leaderWorkspaceCwd || !sessionId || !instanceId)
7908
+ return undefined;
7909
+ const identity = deps.threadStore.claimWorkspaceIdentity(leaderWorkspaceCwd, instanceId, undefined, { existingBindingOnly: true, sessionId });
7910
+ const binding = identity
7911
+ ? deps.threadStore.getWorkspaceBinding(identity.cwd, identity.instanceSlot, identity.sessionId)
7912
+ : undefined;
7913
+ if (!binding)
7914
+ deps.threadStore.releaseWorkspaceClaim(instanceId);
7915
+ return binding;
7916
+ };
7917
+ const reclaimUnboundTargetForLeader = async (base, profileKey, slot, threadName, event, details, leaderBinding) => {
7918
+ if (leaderBinding) {
7919
+ const { inactiveSinceMs: _inactiveSinceMs, displayTitle: _displayTitle, ...moved } = leaderBinding;
7920
+ const committed = deps.threadStore.upsertWorkspaceBinding({
7921
+ ...moved,
7922
+ target: { ...unboundTarget },
7923
+ updatedAtMs: Date.now(),
7924
+ }, instanceId);
7925
+ deps.threadStore.releaseWorkspaceClaim(instanceId);
7926
+ if (committed)
7927
+ threadName = committed.threadName ?? threadName;
7928
+ }
7627
7929
  deps.threadStore.upsert({
7628
7930
  ...base,
7629
7931
  profileKey,
@@ -7654,6 +7956,33 @@ export function createTelegramInboundRouteRuntime(deps) {
7654
7956
  slot,
7655
7957
  profileKey,
7656
7958
  });
7959
+ if (leaderBinding && deps.callApi) {
7960
+ const title = deps.getDisplayTitle?.(unboundTarget);
7961
+ if (title)
7962
+ try {
7963
+ await deps.callApi("editForumTopic", {
7964
+ chat_id: unboundTarget.chatId,
7965
+ message_thread_id: unboundTarget.threadId,
7966
+ name: title,
7967
+ });
7968
+ const current = deps
7969
+ .threadStore.listWorkspaceBindings()
7970
+ .find((value) => value.bindingKey === leaderBinding.bindingKey &&
7971
+ isDeepStrictEqual(value.target, unboundTarget));
7972
+ if (current) {
7973
+ deps.threadStore.upsertWorkspaceBinding({
7974
+ ...current,
7975
+ displayTitle: title,
7976
+ });
7977
+ await deps.threadStore.persist();
7978
+ }
7979
+ }
7980
+ catch (error) {
7981
+ deps.recordRuntimeEvent?.("bus", error, {
7982
+ phase: "leader-topic-reclaim-title",
7983
+ });
7984
+ }
7985
+ }
7657
7986
  await textDispatch.handleMessage(message, ctx);
7658
7987
  };
7659
7988
  const records = deps.threadStore.list();
@@ -7754,7 +8083,10 @@ export function createTelegramInboundRouteRuntime(deps) {
7754
8083
  throw error;
7755
8084
  }
7756
8085
  if (currentLeaderIsStale) {
7757
- const slot = deps.threadStore.allocateSlot(leaderProfileKey);
8086
+ const leaderBinding = claimLeaderWorkspaceBinding();
8087
+ const slot = deps.threadStore.allocateSlot(leaderProfileKey, leaderBinding?.slot, leaderBinding?.bindingKey);
8088
+ if (leaderBinding && leaderBinding.slot !== slot)
8089
+ deps.threadStore.releaseWorkspaceClaim(instanceId);
7758
8090
  if (!slot) {
7759
8091
  deps.threadStore.markStaleByTarget(currentLeaderRecord.target, "deleted", "Current leader thread is stale during unbound prompt routing.");
7760
8092
  await deps.threadStore.persist();
@@ -7767,7 +8099,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7767
8099
  await reclaimUnboundTargetForLeader(currentLeaderRecord, leaderProfileKey, slot, threadName, "Bus leader reclaimed stale-current unbound thread", {
7768
8100
  phase: "leader-topic-unbound-stale-reclaim",
7769
8101
  staleThreadId: currentLeaderRecord.target.threadId,
7770
- });
8102
+ }, leaderBinding?.slot === slot ? leaderBinding : undefined);
7771
8103
  return;
7772
8104
  }
7773
8105
  }
@@ -7777,7 +8109,12 @@ export function createTelegramInboundRouteRuntime(deps) {
7777
8109
  !hasAnyRoutableThread) {
7778
8110
  const priorLeaderRecord = deps.threadStore.getByProfileKey(leaderProfileKey);
7779
8111
  const priorLeaderIdentity = deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
7780
- const slot = deps.threadStore.allocateSlot(leaderProfileKey, priorLeaderRecord?.slot ?? priorLeaderIdentity?.slot);
8112
+ const leaderBinding = claimLeaderWorkspaceBinding();
8113
+ const slot = deps.threadStore.allocateSlot(leaderProfileKey, leaderBinding?.slot ??
8114
+ priorLeaderRecord?.slot ??
8115
+ priorLeaderIdentity?.slot, leaderBinding?.bindingKey);
8116
+ if (leaderBinding && leaderBinding.slot !== slot)
8117
+ deps.threadStore.releaseWorkspaceClaim(instanceId);
7781
8118
  if (!slot) {
7782
8119
  await deps.sendTextReply(target.chatId, message.message_id, TELEGRAM_SLOT_CAPACITY_MESSAGE, { target });
7783
8120
  return;
@@ -7790,7 +8127,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7790
8127
  identityThreadName ??
7791
8128
  ThreadNaming.chooseTelegramThreadName({ slot }) ??
7792
8129
  "Pi";
7793
- await reclaimUnboundTargetForLeader({ createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now() }, leaderProfileKey, slot, threadName, "Bus leader reclaimed unbound thread", { phase: "leader-topic-reclaim" });
8130
+ await reclaimUnboundTargetForLeader({ createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now() }, leaderProfileKey, slot, threadName, "Bus leader reclaimed unbound thread", { phase: "leader-topic-reclaim" }, leaderBinding?.slot === slot ? leaderBinding : undefined);
7794
8131
  return;
7795
8132
  }
7796
8133
  await sendUnboundRerouteChooser(message, ctx);
@@ -7817,6 +8154,8 @@ export function createTelegramInboundRouteRuntime(deps) {
7817
8154
  onUpdateCompleted,
7818
8155
  prepareHeldCommand: commandHandler.prepareHeldCommand,
7819
8156
  canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
8157
+ onSessionSettled,
8158
+ onDeliverySettled,
7820
8159
  observeLiveRebindLeaderWork,
7821
8160
  prepareLiveRebindLeaderCleanup,
7822
8161
  prepareLiveRebindFollowerCleanup,