@llblab/pi-telegram 0.54.4 → 0.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +16 -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 +3 -1
  8. package/dist/lib/bindings.js +8 -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 +50 -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 +7 -3
  16. package/dist/lib/bus.js +24 -16
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +48 -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 +7 -2
  34. package/dist/lib/routing.js +585 -229
  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 +18 -5
  44. package/dist/lib/threads.js +107 -30
  45. package/dist/lib/turns.d.ts +6 -0
  46. package/dist/lib/turns.js +12 -1
  47. package/dist/lib/updates.d.ts +13 -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 +14 -11
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +20 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +2 -2
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +9 -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 +30 -18
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +51 -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 +614 -257
  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 +120 -36
  78. package/lib/turns.ts +15 -1
  79. package/lib/updates.ts +60 -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;
@@ -380,11 +375,12 @@ export const TELEGRAM_LIVE_REBIND_CLEANUP_SCHEDULE = Object.freeze({
380
375
  intervalMs: 60_000,
381
376
  windowMs: 15 * 60_000,
382
377
  });
383
- /** 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. */
384
379
  export function createTelegramLiveRebindCoordinator(input) {
385
380
  const request = structuredClone(input.request), messages = [...input.messages];
386
381
  const executor = structuredClone(input.authority.executor), operatorUserId = input.authority.operatorUserId;
387
382
  const leader = input.leader && { ...input.leader }, follower = input.follower, followerRun = follower?.run;
383
+ const beforeCleanup = input.cleanupBeforeRelease;
388
384
  const selectedCommand = follower?.selectedCommand && structuredClone(follower.selectedCommand), held = selectedCommand;
389
385
  const observedRecipient = input.getRecipient(), recipient = observedRecipient && structuredClone(observedRecipient);
390
386
  const sources = messages.map(Updates.inspectTelegramDeferredSource);
@@ -469,7 +465,7 @@ export function createTelegramLiveRebindCoordinator(input) {
469
465
  return confirmed && current();
470
466
  };
471
467
  let preparation;
472
- let saved = false, commitIssued = false, applyIssued = false, retitleIssued = 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;
473
469
  let preparedSource;
474
470
  const runFollower = async (mode) => {
475
471
  if (!input.follower || !recipient || !current())
@@ -517,6 +513,18 @@ export function createTelegramLiveRebindCoordinator(input) {
517
513
  generation: recipient.generation,
518
514
  bindingKey: recipient.bindingKey,
519
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
+ };
520
528
  const releaseFollower = async (intent) => {
521
529
  if (!held || !peerReleaseIssued) {
522
530
  peerReleaseIssued = true;
@@ -539,6 +547,10 @@ export function createTelegramLiveRebindCoordinator(input) {
539
547
  !canonical(intent))
540
548
  return "unknown";
541
549
  }
550
+ const accepted = acceptInput(intent);
551
+ if (!accepted)
552
+ return "unknown";
553
+ intent = accepted;
542
554
  donorSettlementAttempted = true;
543
555
  if (preparation?.settleTransferred(() => canonical(intent)) !== "settled")
544
556
  return "unknown";
@@ -548,19 +560,16 @@ export function createTelegramLiveRebindCoordinator(input) {
548
560
  const observeCommandCompletion = () => {
549
561
  const intent = retained();
550
562
  if (!intent ||
551
- intent.phase !== "released" ||
552
- intent.cleanup ||
563
+ !Threads.isTelegramLiveRebindInputAuthorized(intent) ||
553
564
  !canonical(intent) ||
554
565
  !leader?.isApplied(intent))
555
566
  return "unknown";
556
567
  const completions = messages.map(Updates.inspectTelegramDeferredSourceCompletion);
557
- return completions.every((completion, index) => completion && isDeepStrictEqual(completion, sources[index])) &&
568
+ const completed = completions.every((completion, index) => completion && isDeepStrictEqual(completion, sources[index])) &&
558
569
  current() &&
559
570
  canonical(intent) &&
560
- leader.isApplied(intent) &&
561
- current()
562
- ? "released"
563
- : "unknown";
571
+ leader.isApplied(intent) && current();
572
+ return completed && acceptInput(intent) ? "released" : "unknown";
564
573
  };
565
574
  return {
566
575
  /** Correlation only; a later cleanup attempt rereads and revalidates the canonical row itself. */
@@ -593,8 +602,7 @@ export function createTelegramLiveRebindCoordinator(input) {
593
602
  return observeCommandCompletion();
594
603
  const intent = retained();
595
604
  if (!intent ||
596
- intent.phase !== "released" ||
597
- intent.cleanup ||
605
+ !Threads.isTelegramLiveRebindInputAuthorized(intent) ||
598
606
  !canonical(intent) ||
599
607
  !leader?.isApplied(intent) ||
600
608
  !leader.observeAdmission?.() ||
@@ -602,6 +610,8 @@ export function createTelegramLiveRebindCoordinator(input) {
602
610
  !canonical(intent) ||
603
611
  !leader.isApplied(intent))
604
612
  return "unknown";
613
+ if (!acceptInput(intent))
614
+ return "unknown";
605
615
  released = true;
606
616
  return "released";
607
617
  }
@@ -657,22 +667,22 @@ export function createTelegramLiveRebindCoordinator(input) {
657
667
  instanceId: recipient.instanceId,
658
668
  sessionId: recipient.sessionId,
659
669
  generation: recipient.generation,
660
- }, authority);
670
+ }, authority, beforeCleanup ? { holdInput: true } : undefined);
661
671
  }
662
672
  else if (!commitIssued)
663
673
  return "protected";
664
674
  if (!intent ||
665
675
  !sourceSaved() ||
666
676
  !canonical(intent) ||
667
- intent.phase === "finished" ||
668
- intent.cleanup)
677
+ (intent.phase === "finished" && !cleanupBeforeReleaseIssued) ||
678
+ (intent.cleanup && intent.inputRelease === undefined))
669
679
  return "protected";
670
680
  // A released peer may already have completed its sources; only its retained settlement can reobserve that outcome.
671
681
  if (recipient.kind === "follower" && peerReleaseIssued)
672
- return intent.phase === "released"
682
+ return Threads.isTelegramLiveRebindInputAuthorized(intent)
673
683
  ? await releaseFollower(intent)
674
684
  : "protected";
675
- const mode = applyIssued || intent.phase === "released" ? "inspect" : "apply";
685
+ const mode = applyIssued || intent.phase === "released" || intent.phase === "finished" ? "inspect" : "apply";
676
686
  applyIssued = true;
677
687
  if (recipient.kind === "leader") {
678
688
  await leader.apply(structuredClone(intent), mode, current);
@@ -715,22 +725,55 @@ export function createTelegramLiveRebindCoordinator(input) {
715
725
  intent = retained();
716
726
  }
717
727
  if (!intent ||
718
- intent.phase !== "released" ||
719
- intent.cleanup ||
728
+ (intent.phase !== "released" && !(intent.phase === "finished" && intent.inputRelease === "held")) ||
729
+ (intent.cleanup && intent.inputRelease === undefined) ||
720
730
  !canonical(intent))
721
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
+ }
722
755
  if (recipient.kind === "leader") {
723
756
  const expected = intent;
724
757
  if (!preparation?.beginRelease(() => canonical(expected) && leader.isApplied(expected)))
725
758
  return "protected";
726
759
  continuationIssued = true;
727
- 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
+ };
728
766
  if (leader.complete) {
729
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";
730
771
  return observeCommandCompletion();
731
772
  }
732
773
  if (!(await leader.continue(messages, canContinue)))
733
774
  return "unknown";
775
+ if (!acceptInput(intent))
776
+ return "unknown";
734
777
  }
735
778
  else
736
779
  return await releaseFollower(intent);
@@ -1288,13 +1331,20 @@ export function createTelegramInboundRouteRuntime(deps) {
1288
1331
  requestDispatchNextQueuedTelegramTurn(ctx);
1289
1332
  },
1290
1333
  });
1291
- const cloneTelegramMessagesForThread = (messages, threadId) => {
1292
- return messages.map((message) => Updates.carryTelegramUpdateExecutionFence(message, {
1293
- ...message,
1294
- message_id: 0,
1295
- message_thread_id: threadId,
1296
- reply_to_message: undefined,
1297
- }));
1334
+ const cloneTelegramMessagesForThread = (messages, threadId, copies) => {
1335
+ return messages.map((message) => {
1336
+ const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
1337
+ const copyId = copies && ids.length === 1 ? copies.get(ids[0]) : undefined;
1338
+ if (copies && (!Number.isSafeInteger(copyId) || copyId <= 0))
1339
+ throw new Error("Reroute destination copy identity is unavailable.");
1340
+ const routed = {
1341
+ ...message,
1342
+ message_id: copyId ?? 0,
1343
+ message_thread_id: threadId,
1344
+ reply_to_message: undefined,
1345
+ };
1346
+ return Updates.carryTelegramUpdateExecutionFence(message, copies ? Turns.withTelegramTurnReplyAnchor(routed) : routed);
1347
+ });
1298
1348
  };
1299
1349
  /** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
1300
1350
  const hasPendingRerouteForTarget = (target, own) => [...pendingUnboundReroutes.values()].some((pending) => pending.sourceTarget.chatId === target.chatId &&
@@ -1560,10 +1610,12 @@ export function createTelegramInboundRouteRuntime(deps) {
1560
1610
  return !acceptedWorkClear;
1561
1611
  };
1562
1612
  /** Best-effort, once consumed: All keeps no copy of an input whose routing tab carried it; failure is only recorded. */
1563
- const deleteAllTabMessages = async (chatId, messageIds) => {
1613
+ const deleteAllTabMessages = async (chatId, messageIds, isCurrent) => {
1564
1614
  if (!deps.deleteMessage)
1565
1615
  return;
1566
1616
  for (const messageId of messageIds) {
1617
+ if (isCurrent?.() === false)
1618
+ return;
1567
1619
  try {
1568
1620
  await deps.deleteMessage(chatId, messageId);
1569
1621
  }
@@ -1577,13 +1629,14 @@ export function createTelegramInboundRouteRuntime(deps) {
1577
1629
  }
1578
1630
  };
1579
1631
  /** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
1580
- const deleteAllTabCopies = (pending) => deleteAllTabMessages(pending.sourceTarget.chatId, pending.allTabCopies?.splice(0) ?? []);
1632
+ const deleteAllTabCopies = (pending, isCurrent) => deleteAllTabMessages(pending.sourceTarget.chatId, pending.allTabCopies?.splice(0) ?? [], isCurrent);
1581
1633
  /**
1582
1634
  * Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
1583
1635
  * holds its forwarded copy. Without one it stays where the operator can still read it.
1584
1636
  */
1585
1637
  const releaseAllTabCopiesIntoSurvivingTab = (pending) => {
1586
- if (pending.allTabCopiesForwarded)
1638
+ if (pending.allTabCopiesForwarded &&
1639
+ isDeepStrictEqual(pending.allTabCopiesForwarded.target, pending.sourceTarget))
1587
1640
  return deleteAllTabCopies(pending);
1588
1641
  pending.allTabCopies?.splice(0);
1589
1642
  return Promise.resolve();
@@ -1594,31 +1647,112 @@ export function createTelegramInboundRouteRuntime(deps) {
1594
1647
  * any failure keeps the original where the operator can still read it.
1595
1648
  */
1596
1649
  const moveAllTabSourceIntoTab = async (pending, target, isCurrent) => {
1597
- const [messageId] = pending.allTabCopies ?? [];
1598
- if (messageId === undefined || !deps.callApi || !isCurrent())
1650
+ const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
1651
+ if (messageIds.length === 0 || !deps.callApi || !isCurrent())
1599
1652
  return;
1653
+ const copyTarget = { ...target };
1654
+ const route = {
1655
+ chat_id: copyTarget.chatId,
1656
+ message_thread_id: copyTarget.threadId,
1657
+ from_chat_id: pending.sourceTarget.chatId,
1658
+ disable_notification: true,
1659
+ };
1660
+ const options = {
1661
+ maxAttempts: 1,
1662
+ retrySafety: "non-idempotent",
1663
+ assertAuthority() {
1664
+ if (!isCurrent())
1665
+ throw new Error("Temporary Thread source forwarding authority changed.");
1666
+ },
1667
+ };
1600
1668
  try {
1601
- const copy = await deps.callApi("forwardMessage", {
1602
- chat_id: target.chatId,
1603
- message_thread_id: target.threadId,
1604
- from_chat_id: pending.sourceTarget.chatId,
1605
- message_id: messageId,
1606
- disable_notification: true,
1607
- });
1608
- const copyId = copy?.message_id;
1609
- if (typeof copyId !== "number" || !Number.isSafeInteger(copyId))
1669
+ // An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
1670
+ const copies = messageIds.length === 1
1671
+ ? [
1672
+ await deps.callApi("forwardMessage", { ...route, message_id: messageIds[0] }, options),
1673
+ ]
1674
+ : await deps.callApi("forwardMessages", { ...route, message_ids: messageIds }, options);
1675
+ if (!isCurrent())
1610
1676
  return;
1611
- pending.allTabCopiesForwarded = true;
1612
- await deleteAllTabCopies(pending);
1613
- return copyId;
1677
+ const copyIds = Array.isArray(copies)
1678
+ ? copies.map((copy) => copy?.message_id)
1679
+ : [];
1680
+ if (copyIds.length !== messageIds.length ||
1681
+ !copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
1682
+ new Set(copyIds).size !== copyIds.length)
1683
+ return;
1684
+ pending.allTabCopiesForwarded = {
1685
+ target: copyTarget,
1686
+ sourceMessageIds: messageIds,
1687
+ messageIds: copyIds,
1688
+ };
1689
+ await deleteAllTabCopies(pending, isCurrent);
1690
+ if (!isCurrent())
1691
+ return;
1692
+ return pending.allTabCopiesForwarded.messageIds[0];
1614
1693
  }
1615
1694
  catch (error) {
1616
- deps.recordRuntimeEvent?.("routing", error, {
1617
- phase: "temporary-thread-source-forward",
1618
- });
1695
+ if (isCurrent())
1696
+ deps.recordRuntimeEvent?.("routing", error, {
1697
+ phase: "temporary-thread-source-forward",
1698
+ });
1619
1699
  return;
1620
1700
  }
1621
1701
  };
1702
+ /** Read original source addresses, not warm target rewrites; unknown copy provenance never grants a Reroute. */
1703
+ const resolvePendingRerouteForwardSource = (pending) => {
1704
+ try {
1705
+ const originals = pending.messages.map((message) => {
1706
+ const snapshot = Updates.inspectTelegramDeferredSourceSnapshot(message);
1707
+ const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
1708
+ const original = snapshot?.update.message;
1709
+ if (!snapshot ||
1710
+ ids.length !== 1 ||
1711
+ snapshot.source.updateId !== ids[0] ||
1712
+ snapshot.update.update_id !== ids[0] ||
1713
+ Object.keys(snapshot.update).some((key) => key !== "update_id" && key !== "message") ||
1714
+ snapshot.source.journalBindingKey !== deps.getAdmissionJournalBinding?.() ||
1715
+ !original ||
1716
+ original.chat?.type !== "private" ||
1717
+ original.chat.id !== pending.sourceTarget.chatId ||
1718
+ original.from?.id !== deps.configStore.getAllowedUserId() ||
1719
+ original.from?.is_bot !== false ||
1720
+ !Number.isSafeInteger(original.message_id) ||
1721
+ original.message_id <= 0)
1722
+ return undefined;
1723
+ return { message: original, updateId: ids[0] };
1724
+ });
1725
+ if (originals.length === 0 || originals.some((source) => !source))
1726
+ return;
1727
+ originals.sort((a, b) => a.message.message_id - b.message.message_id);
1728
+ const sourceMessageIds = originals.map((source) => source.message.message_id);
1729
+ const sourceUpdateIds = originals.map((source) => source.updateId);
1730
+ if (new Set(sourceMessageIds).size !== sourceMessageIds.length ||
1731
+ new Set(sourceUpdateIds).size !== sourceUpdateIds.length)
1732
+ return;
1733
+ const copied = pending.allTabCopiesForwarded;
1734
+ if (copied) {
1735
+ if (!isDeepStrictEqual(copied.target, pending.sourceTarget) ||
1736
+ !isDeepStrictEqual(copied.sourceMessageIds, sourceMessageIds) ||
1737
+ copied.messageIds.length !== sourceMessageIds.length ||
1738
+ !copied.messageIds.every((id) => Number.isSafeInteger(id) && id > 0) ||
1739
+ new Set(copied.messageIds).size !== copied.messageIds.length)
1740
+ return;
1741
+ return { chatId: copied.target.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...copied.messageIds] };
1742
+ }
1743
+ const nativeOriginals = originals.every((source) => typeof pending.sourceTarget.threadId === "number" &&
1744
+ source.message.message_thread_id === pending.sourceTarget.threadId);
1745
+ const retainedAllOriginals = pending.allTabSourceRetained === true &&
1746
+ originals.every((source) => source.message.message_thread_id === undefined) &&
1747
+ isDeepStrictEqual([...(pending.allTabCopies ?? [])].sort((a, b) => a - b), sourceMessageIds);
1748
+ if (nativeOriginals || retainedAllOriginals)
1749
+ return { chatId: pending.sourceTarget.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...sourceMessageIds] };
1750
+ }
1751
+ catch (error) {
1752
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-source-proof" });
1753
+ }
1754
+ return undefined;
1755
+ };
1622
1756
  const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
1623
1757
  const chatId = query.message?.chat?.id;
1624
1758
  const messageId = query.message?.message_id;
@@ -3019,8 +3153,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3019
3153
  const intent = store
3020
3154
  .listLiveRebindings()
3021
3155
  .find((value) => value.request.operationId === request.operationId);
3022
- if (intent?.phase === "released" &&
3023
- !intent.cleanup &&
3156
+ if (intent && Threads.isTelegramLiveRebindInputAuthorized(intent) &&
3024
3157
  isDeepStrictEqual(intent.request, request) &&
3025
3158
  intent.operatorUserId === operatorUserId &&
3026
3159
  isDeepStrictEqual(intent.executor, {
@@ -3289,6 +3422,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3289
3422
  name ||
3290
3423
  confirmation ||
3291
3424
  extension;
3425
+ const preDispatchCleanup = deps.runWorkspaceOperation !== undefined && deps.canUsePreDispatchRebind?.(recipient) === true;
3292
3426
  const createCoordinator = () => createTelegramLiveRebindCoordinator({
3293
3427
  request,
3294
3428
  messages: selection.messages,
@@ -3299,21 +3433,56 @@ export function createTelegramInboundRouteRuntime(deps) {
3299
3433
  ? async (isCurrent) => {
3300
3434
  if (!isCurrent())
3301
3435
  return;
3436
+ const expected = threads.getWorkspaceBindingByTarget(target, binding.sessionId);
3437
+ if (!expected || expected.bindingKey !== binding.bindingKey || expected.slot !== binding.slot)
3438
+ throw new Error("Live-rebind title binding is unavailable.");
3439
+ const callApi = deps.callApi;
3302
3440
  const slot = binding.slot ?? "";
3303
- await deps.callApi("editForumTopic", {
3304
- chat_id: target.chatId,
3305
- message_thread_id: target.threadId,
3306
- name: deps.getDisplayTitle?.(target) ??
3307
- ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot),
3308
- });
3441
+ const resolveTitle = () => deps.getDisplayTitle?.(expected.target) ??
3442
+ ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot);
3443
+ const title = resolveTitle();
3444
+ const bindingCurrent = (frame) => isCurrent() && deps.callApi === callApi &&
3445
+ isDeepStrictEqual(threads.getWorkspaceBindingByTarget(frame.target, frame.sessionId), frame);
3446
+ const assertTitle = () => {
3447
+ if (!bindingCurrent(expected) || resolveTitle() !== title)
3448
+ throw new Error("Live-rebind title authority changed.");
3449
+ };
3450
+ assertTitle();
3451
+ const renamed = await callApi.call(deps, "editForumTopic", {
3452
+ chat_id: expected.target.chatId,
3453
+ message_thread_id: expected.target.threadId,
3454
+ name: title,
3455
+ }, { maxAttempts: 1, assertAuthority: assertTitle });
3456
+ assertTitle();
3457
+ if (renamed !== true || !threads.setWorkspaceDisplayTitle(expected, title))
3458
+ throw new Error("Live-rebind title acknowledgement is unconfirmed.");
3459
+ const acknowledged = { ...expected, displayTitle: title };
3460
+ await threads.persist(() => bindingCurrent(acknowledged));
3461
+ if (!bindingCurrent(acknowledged))
3462
+ throw new Error("Live-rebind title publication authority changed.");
3309
3463
  }
3310
3464
  : undefined,
3311
3465
  authority: {
3312
3466
  executor: { instanceId, leaderEpoch: String(epoch) },
3313
3467
  operatorUserId,
3314
- isCurrent: current,
3468
+ isCurrent: () => current() && (!preDispatchCleanup || deps.canUsePreDispatchRebind?.(recipient) === true),
3315
3469
  },
3316
3470
  getRecipient,
3471
+ ...(preDispatchCleanup ? {
3472
+ async cleanupBeforeRelease(intent, isCurrent) {
3473
+ const delay = deps.liveRebindCleanupSchedule?.delaysMs[0] ?? 1_000;
3474
+ await new Promise(resolve => { const timer = setTimeout(resolve, delay); timer.unref?.(); });
3475
+ if (!isCurrent())
3476
+ return false;
3477
+ const result = await inspectLiveRebindRecipient(intent, ctx, isLeader ? "issue" : "follower-issue", isCurrent);
3478
+ if (!isCurrent())
3479
+ return false;
3480
+ // A session-replacing producer cannot retain an old-session cleanup grant across its gateway.
3481
+ if (result?.status === "not-ready" && selection.confirmationCommand?.name === "new")
3482
+ return !!store.advanceLiveRebind(intent, "protect-old-target", { executor: intent.executor, operatorUserId, isCurrent });
3483
+ return result?.status === "not-ready" || result?.status === "finished" && result.recorded;
3484
+ },
3485
+ } : {}),
3317
3486
  follower: isLeader
3318
3487
  ? undefined
3319
3488
  : {
@@ -3344,9 +3513,9 @@ export function createTelegramInboundRouteRuntime(deps) {
3344
3513
  instanceId,
3345
3514
  leaderEpoch: String(epoch),
3346
3515
  }) &&
3347
- !intent.cleanup &&
3348
- (intent.phase === "rebound" ||
3349
- intent.phase === "released") &&
3516
+ (!intent.cleanup || intent.inputRelease !== undefined && intent.cleanup !== "issued") &&
3517
+ (intent.phase === "rebound" || intent.phase === "released" ||
3518
+ intent.phase === "finished" && intent.inputRelease !== undefined) &&
3350
3519
  isDeepStrictEqual(intent.recipient, {
3351
3520
  kind: recipient.kind,
3352
3521
  instanceId: recipient.instanceId,
@@ -3520,7 +3689,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3520
3689
  },
3521
3690
  };
3522
3691
  };
3523
- async function inspectLiveRebindRecipient(intent, ctx, mode) {
3692
+ async function inspectLiveRebindRecipient(intent, ctx, mode, admittedOrigin) {
3524
3693
  const ports = deps.workspaceRestoreRecipient, threads = deps.threadStore, store = deps.getWorkspaceRestoreStore?.();
3525
3694
  const followerMode = mode === "follower-cleanup" || mode === "follower-issue", cleanupMode = mode !== "work";
3526
3695
  const issueMode = mode === "issue" || mode === "follower-issue", callApi = deps.callApi;
@@ -3532,8 +3701,9 @@ export function createTelegramInboundRouteRuntime(deps) {
3532
3701
  (followerMode ? !peer || !peerRun || !peerJournal : !collector) ||
3533
3702
  !run ||
3534
3703
  (issueMode && !callApi) ||
3535
- deps.isContextActive?.(ctx) !== true)
3704
+ deps.isContextActive?.(ctx) !== true) {
3536
3705
  return undefined;
3706
+ }
3537
3707
  try {
3538
3708
  const expected = structuredClone(intent), { request, recipient, executor } = expected;
3539
3709
  const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.(), generation = deps.getSessionGeneration?.();
@@ -3623,7 +3793,8 @@ export function createTelegramInboundRouteRuntime(deps) {
3623
3793
  peerJournal.call(peer, live) === recipientJournalKey &&
3624
3794
  isDeepStrictEqual(findReference(), reference));
3625
3795
  };
3626
- const current = () => deps.hasWorkspaceLiveRebindAuthority?.() === true &&
3796
+ const current = () => (!admittedOrigin || admittedOrigin()) &&
3797
+ deps.hasWorkspaceLiveRebindAuthority?.() === true &&
3627
3798
  deps.isContextActive?.(ctx) === true &&
3628
3799
  deps.workspaceRestoreRecipient === ports &&
3629
3800
  (followerMode || ports.observeLeaderWork === collector) &&
@@ -3668,8 +3839,11 @@ export function createTelegramInboundRouteRuntime(deps) {
3668
3839
  };
3669
3840
  const sample = async () => {
3670
3841
  assertCanonical();
3671
- if (cleanupMode && protection.call(threads, expected) !== false)
3672
- return undefined;
3842
+ if (cleanupMode) {
3843
+ const protectedTarget = protection.call(threads, expected);
3844
+ if (protectedTarget !== false)
3845
+ return undefined;
3846
+ }
3673
3847
  const target = { ...request.binding.target };
3674
3848
  let value;
3675
3849
  if (followerMode) {
@@ -3678,7 +3852,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3678
3852
  instanceId: recipient.instanceId,
3679
3853
  sessionId: recipient.sessionId,
3680
3854
  recipientBindingKey: recipientJournalKey,
3681
- mode: "observe",
3855
+ mode: expected.inputRelease === "held" ? "observe-held" : "observe",
3682
3856
  sourceUpdateIds: [...request.source.updateIds],
3683
3857
  slot: request.binding.slot,
3684
3858
  target: { ...request.target },
@@ -3720,12 +3894,13 @@ export function createTelegramInboundRouteRuntime(deps) {
3720
3894
  throw new Error("Live-rebind work sample is unavailable or changed target.");
3721
3895
  assertCanonical();
3722
3896
  if (cleanupMode) {
3897
+ const targetProtected = protection.call(threads, expected);
3723
3898
  const prepared = ThreadReconciler.prepareLiveRebindThreadCleanup({
3724
3899
  operationId: request.operationId,
3725
3900
  oldTarget: request.binding.target,
3726
3901
  recipientTarget: request.target,
3727
3902
  leaderEpoch: executor.leaderEpoch,
3728
- targetProtected: protection.call(threads, expected),
3903
+ targetProtected,
3729
3904
  work,
3730
3905
  });
3731
3906
  assertCanonical();
@@ -3749,32 +3924,27 @@ export function createTelegramInboundRouteRuntime(deps) {
3749
3924
  return undefined;
3750
3925
  if (issueMode) {
3751
3926
  // One admission spans the fresh sample, durable issue marker, single deletion and terminal record.
3752
- return await run({
3753
- operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
3754
- operationKind: "workspace.live-rebind-cleanup",
3755
- scopes: [{ kind: "profile" }],
3756
- }, async () => {
3927
+ const execute = async () => {
3757
3928
  const prepared = await sample();
3758
3929
  if (!prepared ||
3759
3930
  prepared.status !== "prepared" ||
3760
3931
  !current() ||
3761
- protection.call(threads, expected) !== false)
3932
+ protection.call(threads, expected) !== false) {
3762
3933
  return { status: "not-ready" };
3934
+ }
3763
3935
  const authority = {
3764
3936
  executor: structuredClone(executor),
3765
3937
  operatorUserId: expected.operatorUserId,
3766
3938
  isCurrent: current,
3767
3939
  };
3768
3940
  const grant = store.advanceLiveRebind(expected, "issue-cleanup", authority);
3769
- if (!grant)
3941
+ if (!grant) {
3770
3942
  return undefined;
3943
+ }
3771
3944
  const assertGranted = () => assertCanonical(grant);
3772
3945
  const cleanup = await ThreadReconciler.issueLiveRebindThreadCleanup(prepared, {
3773
3946
  assertCurrent: assertGranted,
3774
- deleteTopic: (target) => callApi("deleteForumTopic", {
3775
- chat_id: target.chatId,
3776
- message_thread_id: target.threadId,
3777
- }, { maxAttempts: 1, assertAuthority: assertGranted }),
3947
+ deleteTopic: (target) => callApi("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId }, { maxAttempts: 1, assertAuthority: assertGranted }),
3778
3948
  classifyFailure: classifyLiveRebindCleanupFailure,
3779
3949
  });
3780
3950
  let recorded = false;
@@ -3792,7 +3962,12 @@ export function createTelegramInboundRouteRuntime(deps) {
3792
3962
  cleanup,
3793
3963
  recorded,
3794
3964
  };
3795
- });
3965
+ };
3966
+ // Pre-dispatch calls are private to the chooser callback already holding the same profile admission/gate.
3967
+ return admittedOrigin ? await execute() : await run({
3968
+ operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
3969
+ operationKind: "workspace.live-rebind-cleanup", scopes: [{ kind: "profile" }],
3970
+ }, execute);
3796
3971
  }
3797
3972
  const observation = await run({
3798
3973
  operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
@@ -3826,8 +4001,26 @@ export function createTelegramInboundRouteRuntime(deps) {
3826
4001
  const issueLiveRebindFollowerCleanup = (intent, ctx) => inspectLiveRebindRecipient(intent, ctx, "follower-issue");
3827
4002
  // One pacing chain per operation: a waiting timer or a running attempt; repeated choices never start another.
3828
4003
  const liveRebindCleanupChains = new Map();
3829
- // A leader retry waiting out its delay; a settled session wakes it at once, since its own busy run is the usual wait.
4004
+ // One hint slot per existing leader chain; a running attempt retains at most one coalesced recheck.
3830
4005
  const liveRebindCleanupWakers = new Map();
4006
+ const captureLiveRebindCleanupScope = (ctx) => {
4007
+ try {
4008
+ const generation = deps.getSessionGeneration?.(), instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.(), store = deps.getWorkspaceRestoreStore?.(), operator = deps.configStore.getAllowedUserId();
4009
+ return () => {
4010
+ try {
4011
+ return deps.isContextActive?.(ctx) === true && deps.getSessionGeneration?.() === generation &&
4012
+ deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
4013
+ deps.getWorkspaceRestoreStore?.() === store && deps.configStore.getAllowedUserId() === operator;
4014
+ }
4015
+ catch {
4016
+ return false;
4017
+ }
4018
+ };
4019
+ }
4020
+ catch {
4021
+ return () => false;
4022
+ }
4023
+ };
3831
4024
  /** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
3832
4025
  const terminalizeUnissuedLiveRebindCleanup = async (operationId, ctx) => {
3833
4026
  const run = deps.runWorkspaceOperation, store = deps.getWorkspaceRestoreStore?.();
@@ -3868,24 +4061,53 @@ export function createTelegramInboundRouteRuntime(deps) {
3868
4061
  * a finished outcome or an inactive session stops, and the window ends in `not-issued` so no released row becomes a
3869
4062
  * permanent rebinding blocker. Waiting timers never join settlement; only a running attempt does.
3870
4063
  */
3871
- const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0) => {
4064
+ const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0, current = captureLiveRebindCleanupScope(ctx)) => {
3872
4065
  const pacing = deps.liveRebindCleanupSchedule;
3873
4066
  const chain = liveRebindCleanupChains.get(operationId);
3874
4067
  if (!pacing ||
3875
4068
  (attempt === 0 ? chain !== undefined : chain !== "running") ||
3876
- deps.isContextActive?.(ctx) !== true) {
3877
- if (attempt > 0 && chain === "running")
4069
+ !current()) {
4070
+ if (attempt > 0 && chain === "running") {
3878
4071
  liveRebindCleanupChains.delete(operationId);
4072
+ liveRebindCleanupWakers.delete(operationId);
4073
+ }
3879
4074
  return;
3880
4075
  }
4076
+ let pendingWake;
4077
+ const wake = (reason) => {
4078
+ if (!current())
4079
+ return;
4080
+ if (liveRebindCleanupChains.get(operationId) === "running") {
4081
+ pendingWake = reason;
4082
+ return;
4083
+ }
4084
+ if (liveRebindCleanupChains.get(operationId) !== timer)
4085
+ return;
4086
+ if (reason === "delivery-drained") {
4087
+ let idle;
4088
+ try {
4089
+ idle = deps.isIdle?.(ctx);
4090
+ }
4091
+ catch { /* A missing idle observation is not a reason to hurry cleanup. */ }
4092
+ if (idle !== true) {
4093
+ // A streamed technical task can drain between deltas while Pi still runs. Keep one hint, not a polling loop.
4094
+ pendingWake = reason;
4095
+ return;
4096
+ }
4097
+ }
4098
+ clearTimeout(timer);
4099
+ attemptNow();
4100
+ };
3881
4101
  const attemptNow = () => {
3882
- liveRebindCleanupWakers.delete(operationId);
3883
4102
  if (liveRebindCleanupChains.get(operationId) !== timer)
3884
4103
  return;
4104
+ pendingWake = undefined; // This fresh timer/settlement attempt consumes any previously deferred hint.
3885
4105
  liveRebindCleanupChains.set(operationId, "running");
4106
+ if (role === "leader")
4107
+ liveRebindCleanupWakers.set(operationId, wake);
3886
4108
  let continued = false;
3887
4109
  const task = (async () => {
3888
- if (deps.isContextActive?.(ctx) !== true)
4110
+ if (!current())
3889
4111
  return;
3890
4112
  const intent = deps
3891
4113
  .getWorkspaceRestoreStore?.()
@@ -3897,20 +4119,25 @@ export function createTelegramInboundRouteRuntime(deps) {
3897
4119
  ? await issueLiveRebindLeaderCleanup(intent, ctx)
3898
4120
  : await issueLiveRebindFollowerCleanup(intent, ctx);
3899
4121
  if (result?.status === "finished" ||
3900
- deps.isContextActive?.(ctx) !== true)
4122
+ !current())
3901
4123
  return;
3902
- if (Date.now() - startedAtMs >= pacing.windowMs)
4124
+ if (Date.now() - startedAtMs >= pacing.windowMs) {
3903
4125
  return terminalizeUnissuedLiveRebindCleanup(operationId, ctx);
4126
+ }
3904
4127
  continued = true;
3905
- scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1);
4128
+ scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1, current);
4129
+ if (pendingWake)
4130
+ liveRebindCleanupWakers.get(operationId)?.(pendingWake);
3906
4131
  })()
3907
4132
  .catch((error) => deps.recordRuntimeEvent?.("routing", error, {
3908
4133
  phase: "live-rebind-cleanup-schedule",
3909
4134
  }))
3910
4135
  .finally(() => {
3911
4136
  if (!continued &&
3912
- liveRebindCleanupChains.get(operationId) === "running")
4137
+ liveRebindCleanupChains.get(operationId) === "running") {
3913
4138
  liveRebindCleanupChains.delete(operationId);
4139
+ liveRebindCleanupWakers.delete(operationId);
4140
+ }
3914
4141
  });
3915
4142
  restoreSettlementTasks.add(task);
3916
4143
  void task.finally(() => restoreSettlementTasks.delete(task));
@@ -3920,19 +4147,32 @@ export function createTelegramInboundRouteRuntime(deps) {
3920
4147
  liveRebindCleanupChains.set(operationId, timer);
3921
4148
  // The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
3922
4149
  if (role === "leader" && attempt > 0)
3923
- liveRebindCleanupWakers.set(operationId, () => {
3924
- if (liveRebindCleanupChains.get(operationId) !== timer)
3925
- return;
3926
- clearTimeout(timer);
3927
- attemptNow();
3928
- });
4150
+ liveRebindCleanupWakers.set(operationId, wake);
3929
4151
  };
3930
- /** The agent settled: every gate is re-sampled now, so an old tab goes right after the answer, not at the next tick. */
3931
- const onSessionSettled = (ctx) => {
4152
+ const wakeLiveRebindCleanup = (ctx, reason) => {
3932
4153
  if (deps.isContextActive?.(ctx) !== true)
3933
4154
  return;
3934
4155
  for (const wake of [...liveRebindCleanupWakers.values()])
3935
- wake();
4156
+ wake(reason);
4157
+ };
4158
+ const onSessionSettled = (ctx) => wakeLiveRebindCleanup(ctx, "session-settled");
4159
+ const onDeliverySettled = (ctx) => {
4160
+ wakeLiveRebindCleanup(ctx, "delivery-drained");
4161
+ const cap = captureTemporaryThreadAuthority(ctx);
4162
+ if (!cap?.isCurrent())
4163
+ return;
4164
+ try {
4165
+ for (const entry of cap.store.listTemporaryThreads()) {
4166
+ if (!cap.isCurrent())
4167
+ return;
4168
+ if (entry.phase === "created" && entry.target && !entry.cleanupIssued &&
4169
+ Threads.isTelegramTemporaryThreadFullyResolved(entry))
4170
+ scheduleTemporaryThreadCleanup(entry.target, ctx);
4171
+ }
4172
+ }
4173
+ catch (error) {
4174
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-drain" });
4175
+ }
3936
4176
  };
3937
4177
  const restoreWorkspace = deps.workspaceRestoreRecipient
3938
4178
  ? async (input) => {
@@ -4389,6 +4629,92 @@ export function createTelegramInboundRouteRuntime(deps) {
4389
4629
  return false;
4390
4630
  }
4391
4631
  };
4632
+ /** Publish display copies under the native one-shot grant; positive output is not input/source settlement. */
4633
+ const publishPendingRerouteCopies = async (pending, record, ctx, assertExecutionCurrent, foreign) => {
4634
+ const source = resolvePendingRerouteForwardSource(pending);
4635
+ const target = { ...record.target };
4636
+ const recordIdentity = (value) => value && ({
4637
+ target: value.target, profileKey: value.profileKey, owner: value.owner,
4638
+ instanceId: value.instanceId, slot: value.slot, createdAtMs: value.createdAtMs,
4639
+ });
4640
+ const bindingIdentity = () => deps.threadStore.listWorkspaceBindings()
4641
+ .filter((value) => isDeepStrictEqual(value.target, target))
4642
+ .map((value) => ({ bindingKey: value.bindingKey, sessionId: value.sessionId, sessionKey: value.sessionKey,
4643
+ cwd: value.cwd, workspaceKey: value.workspaceKey, instanceSlot: value.instanceSlot, slot: value.slot,
4644
+ target: value.target }));
4645
+ const expectedRecord = structuredClone(recordIdentity(record));
4646
+ const expectedBindings = structuredClone(bindingIdentity());
4647
+ const ownership = deps.getTargetOwnership?.(target);
4648
+ if (!source || !deps.callApi || expectedBindings.length > 1 ||
4649
+ (foreign && (!ownership || ownership.instanceId !== record.instanceId ||
4650
+ !ownership.ownerGeneration || !ownership.recipientBindingKey)))
4651
+ return;
4652
+ const expectedOwnership = ownership && structuredClone(ownership);
4653
+ const cap = captureTemporaryThreadAuthority(ctx, () => {
4654
+ try {
4655
+ assertExecutionCurrent();
4656
+ const current = getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.())
4657
+ .find((value) => isDeepStrictEqual(value.target, target));
4658
+ return isDeepStrictEqual(recordIdentity(current), expectedRecord) &&
4659
+ isDeepStrictEqual(bindingIdentity(), expectedBindings) &&
4660
+ (!foreign || Bus.isTelegramBusForwardOwnershipCurrent(expectedOwnership, deps.getTargetOwnership?.(target)));
4661
+ }
4662
+ catch {
4663
+ return false;
4664
+ }
4665
+ });
4666
+ if (!cap?.isCurrent())
4667
+ return;
4668
+ const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
4669
+ if (!temporary || !issueTemporaryThreadForward(pending, ctx))
4670
+ return;
4671
+ pending.phase = { kind: "forward-unknown" };
4672
+ const assertRecipientCurrent = () => {
4673
+ assertExecutionCurrent();
4674
+ if (!cap.isCurrent())
4675
+ throw new Error("Reroute publication recipient authority changed.");
4676
+ };
4677
+ const assertPublicationCurrent = () => {
4678
+ assertRecipientCurrent();
4679
+ const entry = cap.store.listTemporaryThreads().find((value) => value.token === temporary.token);
4680
+ const ids = [...source.sourceUpdateIds].sort((a, b) => a - b);
4681
+ if (!entry || entry.phase !== "created" || entry.cleanupIssued ||
4682
+ entry.operatorUserId !== cap.operatorUserId ||
4683
+ !isDeepStrictEqual(entry.executor, cap.authority.executor) ||
4684
+ !isDeepStrictEqual(entry.target, pending.sourceTarget) ||
4685
+ !entry.forwardedInputs?.some((input) => input.journalBindingKey === cap.journalBindingKey &&
4686
+ isDeepStrictEqual(input.updateIds, ids)) ||
4687
+ !isDeepStrictEqual(resolvePendingRerouteForwardSource(pending), source))
4688
+ throw new Error("Reroute publication source authority changed.");
4689
+ };
4690
+ try {
4691
+ assertPublicationCurrent();
4692
+ // Bot API requires ascending source IDs. Preserve the source-UID association even if copy IDs reorder them.
4693
+ const order = source.messageIds.map((_, index) => index)
4694
+ .sort((a, b) => source.messageIds[a] - source.messageIds[b]);
4695
+ const messageIds = order.map((index) => source.messageIds[index]);
4696
+ const body = { chat_id: target.chatId, message_thread_id: target.threadId,
4697
+ from_chat_id: source.chatId, disable_notification: true };
4698
+ const options = { maxAttempts: 1, retrySafety: "non-idempotent", assertAuthority: assertPublicationCurrent };
4699
+ const copies = messageIds.length === 1
4700
+ ? [await deps.callApi("forwardMessage", { ...body, message_id: messageIds[0] }, options)]
4701
+ : await deps.callApi("forwardMessages", { ...body, message_ids: messageIds }, options);
4702
+ assertPublicationCurrent();
4703
+ const copyIds = Array.isArray(copies) ? copies.map((copy) => copy?.message_id) : [];
4704
+ if (copyIds.length !== messageIds.length ||
4705
+ !copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
4706
+ new Set(copyIds).size !== copyIds.length)
4707
+ return;
4708
+ pending.rerouteCopies = { target, bySourceUpdateId: new Map(order.map((index, position) => [source.sourceUpdateIds[index], copyIds[position]])) };
4709
+ pending.phase = { kind: "selected" };
4710
+ return assertRecipientCurrent;
4711
+ }
4712
+ catch (error) {
4713
+ if (cap.isCurrent())
4714
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-destination-forward" });
4715
+ return;
4716
+ }
4717
+ };
4392
4718
  const forwardPendingRerouteMessages = async (pending, instanceId, threadId, ctx, assertExecutionCurrent, recordForwardAcceptance) => {
4393
4719
  assertExecutionCurrent?.();
4394
4720
  const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
@@ -4402,11 +4728,14 @@ export function createTelegramInboundRouteRuntime(deps) {
4402
4728
  !liveOwnership.recipientBindingKey)
4403
4729
  return false;
4404
4730
  const ownership = structuredClone(liveOwnership);
4405
- const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
4731
+ const copies = pending.rerouteCopies;
4732
+ const published = !!copies && isDeepStrictEqual(copies.target, target);
4733
+ const messages = cloneTelegramMessagesForThread(pending.messages, threadId, published ? copies.bySourceUpdateId : undefined);
4406
4734
  // Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
4407
4735
  // even after restart. Any refusal or publication failure sends nothing and keeps the input held.
4408
4736
  if (!recordForwardAcceptance &&
4409
4737
  isTemporaryReroute(pending) &&
4738
+ !published &&
4410
4739
  !issueTemporaryThreadForward(pending, ctx)) {
4411
4740
  if (isTemporaryThreadForwardIssued(pending))
4412
4741
  pending.phase = { kind: "forward-unknown" };
@@ -4418,6 +4747,10 @@ export function createTelegramInboundRouteRuntime(deps) {
4418
4747
  message,
4419
4748
  ownership,
4420
4749
  ctx,
4750
+ assertAuthority() {
4751
+ assertExecutionCurrent?.();
4752
+ Updates.assertTelegramUpdateExecutionCurrent(message);
4753
+ },
4421
4754
  })));
4422
4755
  assertExecutionCurrent?.();
4423
4756
  if (!isDeepStrictEqual(deps.getTargetOwnership?.(target), ownership))
@@ -4576,6 +4909,17 @@ export function createTelegramInboundRouteRuntime(deps) {
4576
4909
  if (retry)
4577
4910
  restoreSettlementTasks.add(task);
4578
4911
  runTemporaryThreadCleanup(token, cap, reconcileExpiry)
4912
+ .then(() => {
4913
+ if (!cap.isCurrent())
4914
+ return;
4915
+ const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
4916
+ // A follower ACK can precede recipient receipt disposal. Reobserve a protected, unissued tab;
4917
+ // elapsed time never grants deletion, and an issued/unknown delete can never enter this path.
4918
+ if (retained?.phase === "created" && !retained.cleanupIssued &&
4919
+ Threads.isTelegramTemporaryThreadFullyResolved(retained) &&
4920
+ cap.store.inspectTemporaryThreadTarget(retained, cap.authority)?.kind === "temporary" && cap.isCurrent())
4921
+ queueTemporaryThreadCleanup(token, cap, 1000, reconcileExpiry, true);
4922
+ })
4579
4923
  .catch((error) => {
4580
4924
  deps.recordRuntimeEvent?.("routing", error, {
4581
4925
  phase: "temporary-thread-cleanup",
@@ -5101,7 +5445,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5101
5445
  for (const [id, pending] of pendingUnboundReroutes) {
5102
5446
  if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
5103
5447
  continue;
5104
- movedIntoTab ||= pending.allTabCopiesForwarded === true;
5448
+ movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
5105
5449
  removePendingReroute(id);
5106
5450
  if (signal.aborted ||
5107
5451
  deps.isContextActive?.(ctx) !== true ||
@@ -5837,19 +6181,21 @@ export function createTelegramInboundRouteRuntime(deps) {
5837
6181
  pending.liveRebind ??= selection;
5838
6182
  if (!(await selectPendingDestination()))
5839
6183
  return true;
6184
+ const operationId = selection.coordinator.operationId;
5840
6185
  if (current())
5841
6186
  outcome = await selection.coordinator.advance();
5842
6187
  // Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
5843
6188
  // still be pending while every attempt samples current work afresh.
5844
- const operationId = selection.coordinator.operationId;
5845
6189
  const row = outcome === "protected"
5846
6190
  ? undefined
5847
6191
  : deps
5848
6192
  .getWorkspaceRestoreStore?.()
5849
6193
  ?.listLiveRebindings()
5850
6194
  .find((value) => value.request.operationId === operationId);
5851
- if (row?.phase === "released" && row.cleanup === undefined)
6195
+ if (row?.phase === "released" && row.cleanup === undefined &&
6196
+ (row.inputRelease === undefined || row.inputRelease === "accepted")) {
5852
6197
  scheduleLiveRebindCleanup(operationId, selection.record.owner?.kind === "leader" ? "leader" : "follower", ctx);
6198
+ }
5853
6199
  if (outcome === "released")
5854
6200
  await releaseAllTabCopiesIntoSurvivingTab(pending);
5855
6201
  }
@@ -5866,28 +6212,46 @@ export function createTelegramInboundRouteRuntime(deps) {
5866
6212
  await deps.answerCallbackQuery(query.id, "Live rebind is unconfirmed; this choice never replays issued input");
5867
6213
  return true;
5868
6214
  }
6215
+ const cleanupRow = deps.getWorkspaceRestoreStore?.()?.listLiveRebindings().find(value => value.request.operationId === selection.coordinator?.operationId);
6216
+ const cleanupResult = cleanupRow?.inputRelease && cleanupRow.cleanup !== undefined
6217
+ ? cleanupRow.cleanup === "confirmed" ? "old-Thread removal confirmed"
6218
+ : cleanupRow.cleanup === "unknown" || cleanupRow.cleanup === "issued" ? "old-Thread removal unconfirmed; deletion will not repeat"
6219
+ : cleanupRow.cleanup === "failed" ? "old-Thread removal failed" : "old-Thread removal was not issued"
6220
+ : undefined;
6221
+ const commandDisposed = !!(selection.extensionCommand && selection.extensionKind !== "generated-prompt" || selection.nameCommand ||
6222
+ selection.confirmationCommand || selection.helpCommand || selection.stopCommand || selection.nextCommand ||
6223
+ selection.menuCommand || selection.abortCommand || selection.coordinator?.selectedCommandReference());
5869
6224
  // The tab now belongs to the rebound thread; only the one-shot chooser message goes.
5870
- await finalizePendingReroute(parsed.rerouteId, pending, query, selection.extensionKind === "generated-prompt"
5871
- ? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
5872
- : selection.extensionCommand
5873
- ? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
5874
- : selection.nameCommand
5875
- ? selection.nameCommand.args.trim()
5876
- ? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
5877
- : "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
5878
- : selection.confirmationCommand
5879
- ? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
5880
- : selection.helpCommand
5881
- ? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
5882
- : selection.stopCommand
5883
- ? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
5884
- : selection.nextCommand
5885
- ? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
5886
- : selection.menuCommand || selection.abortCommand
5887
- ? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
5888
- : selection.coordinator?.selectedCommandReference()
5889
- ? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
5890
- : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
6225
+ await finalizePendingReroute(parsed.rerouteId, pending, query, cleanupResult
6226
+ ? commandDisposed ? `Command source disposal confirmed; ${cleanupResult}; delivery and follow-on effects remain independent`
6227
+ : `Input queued in the rebound Thread; ${cleanupResult}`
6228
+ : selection.extensionKind === "generated-prompt"
6229
+ ? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
6230
+ : selection.extensionCommand
6231
+ ? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
6232
+ : selection.nameCommand
6233
+ ? selection.nameCommand.args.trim()
6234
+ ? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
6235
+ : "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
6236
+ : selection.confirmationCommand
6237
+ ? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
6238
+ : selection.helpCommand
6239
+ ? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
6240
+ : selection.stopCommand
6241
+ ? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
6242
+ : selection.nextCommand
6243
+ ? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
6244
+ : selection.menuCommand || selection.abortCommand
6245
+ ? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
6246
+ : selection.coordinator?.selectedCommandReference()
6247
+ ? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
6248
+ : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
6249
+ return true;
6250
+ }
6251
+ if (!parsed.useNewSlot &&
6252
+ isTemporaryReroute(pending) &&
6253
+ !resolvePendingRerouteForwardSource(pending)) {
6254
+ await deps.answerCallbackQuery(query.id, "Source copy unavailable; not routing");
5891
6255
  return true;
5892
6256
  }
5893
6257
  if (!(await selectPendingDestination()))
@@ -5973,10 +6337,22 @@ export function createTelegramInboundRouteRuntime(deps) {
5973
6337
  await deps.answerCallbackQuery(query.id, delivered ? "Restored" : "Restore unconfirmed; not resending");
5974
6338
  return true;
5975
6339
  }
6340
+ let assertRerouteCurrent = assertExecutionCurrent;
6341
+ if (isTemporaryReroute(pending)) {
6342
+ const confirmed = await publishPendingRerouteCopies(pending, record, ctx, assertExecutionCurrent, !!record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord);
6343
+ if (!confirmed) {
6344
+ await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
6345
+ ? "Publication unconfirmed; not resending"
6346
+ : "Publication unavailable; input kept");
6347
+ return true;
6348
+ }
6349
+ assertRerouteCurrent = confirmed;
6350
+ }
6351
+ assertRerouteCurrent();
5976
6352
  if (record.instanceId &&
5977
6353
  record.instanceId !== currentInstanceId &&
5978
6354
  !isCurrentLeaderRecord) {
5979
- const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertExecutionCurrent);
6355
+ const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertRerouteCurrent);
5980
6356
  if (!allForwarded) {
5981
6357
  await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
5982
6358
  ? "Delivery unconfirmed; not resending"
@@ -5987,11 +6363,16 @@ export function createTelegramInboundRouteRuntime(deps) {
5987
6363
  }
5988
6364
  // Local handlers and queue admission consume the same one-time group grant as follower forwarding.
5989
6365
  if (isTemporaryReroute(pending) &&
6366
+ !pending.rerouteCopies &&
5990
6367
  !issueTemporaryThreadForward(pending, ctx)) {
5991
6368
  await deps.answerCallbackQuery(query.id, "Delivery unconfirmed; not resending");
5992
6369
  return true;
5993
6370
  }
5994
- await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
6371
+ const localMessages = pending.rerouteCopies
6372
+ ? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
6373
+ : reroutedMessages;
6374
+ await dispatchPendingRerouteMessages(pending, localMessages, ctx);
6375
+ assertRerouteCurrent();
5995
6376
  pending.messages = [];
5996
6377
  return completeRoutedReroute();
5997
6378
  };
@@ -7023,23 +7404,34 @@ export function createTelegramInboundRouteRuntime(deps) {
7023
7404
  * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
7024
7405
  * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
7025
7406
  */
7026
- const sendAllTabTemporaryInputChooser = async (command, commandText, message, ctx) => {
7027
- const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([
7028
- message,
7029
- ]);
7407
+ /**
7408
+ * Opens (or adopts) the routing tab for input sent from All. One text message may carry a command; anything else,
7409
+ * a voice note, a file or a coalesced album, is held untouched as one prompt.
7410
+ */
7411
+ const sendAllTabTemporaryInputChooser = async (command, commandText, messages, ctx) => {
7412
+ const message = messages[0];
7413
+ if (!message)
7414
+ return false;
7415
+ const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds([
7416
+ ...messages,
7417
+ ]).sort((a, b) => a - b);
7418
+ const updateId = updateIds[0];
7419
+ const group = { journalBindingKey: "", updateIds };
7030
7420
  const execution = Updates.getTelegramUpdateExecutionFence(message);
7031
7421
  const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
7032
7422
  if (!cap ||
7033
7423
  !deps.runWorkspaceOperation ||
7034
7424
  !deps.callApi ||
7035
7425
  updateId === undefined ||
7036
- message.chat.type !== "private" ||
7037
- message.chat.id !== cap.operatorUserId ||
7038
- message.from?.id !== cap.operatorUserId ||
7426
+ updateIds.length !== messages.length ||
7427
+ messages.some((held) => held.chat.type !== "private" ||
7428
+ held.chat.id !== cap.operatorUserId ||
7429
+ held.from?.id !== cap.operatorUserId) ||
7039
7430
  !cap.isCurrent())
7040
7431
  return false;
7041
7432
  const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
7042
7433
  const source = { journalBindingKey, updateId };
7434
+ group.journalBindingKey = journalBindingKey;
7043
7435
  const find = () => store
7044
7436
  .listTemporaryThreads()
7045
7437
  .find((entry) => entry.source.journalBindingKey === journalBindingKey &&
@@ -7065,7 +7457,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7065
7457
  // the input itself to All: adopt that tab rather than leaving it behind next to a second one.
7066
7458
  const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
7067
7459
  if (native) {
7068
- const adopted = store.registerImplicitTemporaryThread({ journalBindingKey, updateIds: [updateId] }, native.implicit.target, randomBytes(16).toString("hex"), {
7460
+ const adopted = store.registerImplicitTemporaryThread(group, native.implicit.target, randomBytes(16).toString("hex"), {
7069
7461
  ...authority,
7070
7462
  isCurrent: () => current() &&
7071
7463
  implicitThreadCreations.get(native.key) === native.implicit,
@@ -7090,7 +7482,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7090
7482
  return;
7091
7483
  }
7092
7484
  }
7093
- const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
7485
+ const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority, group);
7094
7486
  entry = reservation?.entry;
7095
7487
  if (!reservation?.reserved || !current())
7096
7488
  return;
@@ -7120,26 +7512,33 @@ export function createTelegramInboundRouteRuntime(deps) {
7120
7512
  });
7121
7513
  if (!current() || !entry)
7122
7514
  throw new Error("Temporary Thread authority changed; the input remains retryable.");
7123
- const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
7124
- ...message,
7125
- text: commandText,
7126
- caption: undefined,
7127
- });
7515
+ // Only a lone text message is rewritten for command detection; media keeps its caption and payload.
7516
+ const held = messages.length === 1 && typeof message.text === "string"
7517
+ ? [
7518
+ Updates.carryTelegramUpdateExecutionFence(message, {
7519
+ ...message,
7520
+ text: commandText,
7521
+ caption: undefined,
7522
+ }),
7523
+ ]
7524
+ : [...messages];
7128
7525
  if (entry.phase !== "created" || !entry.target) {
7129
- Updates.reportTelegramUpdateDeferred(commandMessage);
7130
- 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" });
7526
+ for (const input of held)
7527
+ Updates.reportTelegramUpdateDeferred(input);
7528
+ deps.recordRuntimeEvent?.("routing", new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR), { phase: "temporary-thread-unconfirmed" });
7131
7529
  return true;
7132
7530
  }
7133
7531
  const target = entry.target;
7134
7532
  const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
7135
- const rerouteId = storePendingUnboundReroute([commandMessage], command ? "command" : "prompt", target);
7533
+ const rerouteId = storePendingUnboundReroute(held, command ? "command" : "prompt", target);
7136
7534
  const pending = pendingUnboundReroutes.get(rerouteId);
7137
7535
  pending.temporaryThread = entry;
7138
- pending.allTabCopies = getTelegramAllTabSourceMessageIds([message], target.chatId);
7139
- Updates.reportTelegramUpdateDeferred(commandMessage);
7536
+ pending.allTabCopies = getTelegramAllTabSourceMessageIds(messages, target.chatId);
7537
+ for (const input of held)
7538
+ Updates.reportTelegramUpdateDeferred(input);
7140
7539
  // Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
7141
7540
  if (deps.editInteractiveMessage &&
7142
- Updates.supportsTelegramDeferredAbandonment(commandMessage, journalBindingKey)) {
7541
+ held.every((input) => Updates.supportsTelegramDeferredAbandonment(input, journalBindingKey))) {
7143
7542
  pending.abandonment = {
7144
7543
  ownerUserId: operatorUserId,
7145
7544
  journalBindingKey,
@@ -7150,6 +7549,14 @@ export function createTelegramInboundRouteRuntime(deps) {
7150
7549
  const tabCopyId = openedNow
7151
7550
  ? await moveAllTabSourceIntoTab(pending, target, current)
7152
7551
  : undefined;
7552
+ if (!current()) {
7553
+ removePendingReroute(rerouteId);
7554
+ throw new Error("Temporary Thread source presentation authority changed.");
7555
+ }
7556
+ if (openedNow &&
7557
+ !pending.allTabCopiesForwarded &&
7558
+ pending.allTabCopies.length === held.length)
7559
+ pending.allTabSourceRetained = true;
7153
7560
  try {
7154
7561
  if (!deps.sendInteractiveMessage)
7155
7562
  throw new Error("Temporary Thread chooser publication is unavailable.");
@@ -7168,72 +7575,11 @@ export function createTelegramInboundRouteRuntime(deps) {
7168
7575
  }
7169
7576
  return true;
7170
7577
  };
7171
- const sendAllTabCommandChooser = async (command, commandText, message, options = {}) => {
7172
- if (!deps.threadStore)
7173
- return false;
7174
- const records = deps.threadStore.list();
7175
- const activeRecords = getTelegramRoutableThreadRecords(records, deps.getLiveThreadTargets?.());
7176
- if (activeRecords.length === 0)
7177
- return false;
7178
- const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
7179
- ...message,
7180
- text: commandText,
7181
- caption: undefined,
7182
- });
7183
- const rerouteId = storePendingUnboundReroute([commandMessage], "command");
7184
- Updates.reportTelegramUpdateDeferred(commandMessage);
7185
- const text = formatTelegramAllTabMenuChooserText(command.name);
7186
- pendingUnboundReroutes.get(rerouteId).rootChooserText = text;
7187
- const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, {
7188
- canRestore: typeof message.message_thread_id === "number",
7189
- getDisplayTitle: deps.getDisplayTitle,
7190
- });
7191
- let chooserId;
7192
- try {
7193
- if (deps.sendInteractiveMessage) {
7194
- chooserId = await deps.sendInteractiveMessage(message.chat.id, text, "html", replyMarkup, options.target || options.replyToSource
7195
- ? {
7196
- ...(options.target ? { target: options.target } : {}),
7197
- ...(options.replyToSource
7198
- ? { replyToMessageId: message.message_id }
7199
- : {}),
7200
- }
7201
- : undefined);
7202
- }
7203
- else if (deps.callApi) {
7204
- const chooser = await deps.callApi("sendMessage", {
7205
- chat_id: message.chat.id,
7206
- text,
7207
- parse_mode: "HTML",
7208
- reply_markup: replyMarkup,
7209
- ...(typeof options.target?.threadId === "number"
7210
- ? { message_thread_id: options.target.threadId }
7211
- : {}),
7212
- ...(options.replyToSource
7213
- ? {
7214
- reply_parameters: {
7215
- message_id: message.message_id,
7216
- allow_sending_without_reply: true,
7217
- },
7218
- }
7219
- : {}),
7220
- });
7221
- chooserId = chooser?.message_id;
7222
- }
7223
- else {
7224
- chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
7225
- parseMode: "HTML",
7226
- target: options.target,
7227
- });
7228
- }
7229
- }
7230
- catch (error) {
7231
- removePendingReroute(rerouteId);
7232
- throw error;
7233
- }
7234
- rememberRerouteChooser(rerouteId, chooserId);
7235
- Updates.reportTelegramUpdateCompleted(commandMessage);
7236
- return true;
7578
+ /** A coalesced album from All requires one routing tab; failure cannot answer in All or discard its source. */
7579
+ const routeAllTabAlbum = async (messages, ctx) => {
7580
+ if (await sendAllTabTemporaryInputChooser(undefined, "", messages, ctx))
7581
+ return;
7582
+ throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
7237
7583
  };
7238
7584
  const dispatchCommandOrPrompt = Commands.createTelegramCommandOrPromptDispatcher({
7239
7585
  extractRawText: Media.extractFirstTelegramMessageText,
@@ -7680,28 +8026,39 @@ export function createTelegramInboundRouteRuntime(deps) {
7680
8026
  .some((entry) => entry.source.updateId === sourceUpdateId &&
7681
8027
  entry.source.journalBindingKey ===
7682
8028
  deps.getAdmissionJournalBinding?.()) === true;
8029
+ const needsThread = deps.threadStore.getBotState().threadMode === "enabled" || bindings.length > 0 || presented;
7683
8030
  // Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
7684
8031
  if (command &&
7685
8032
  command.name !== "thread" &&
7686
8033
  !presented &&
7687
8034
  isTelegramAllTabCommandExpired(message))
7688
8035
  return;
7689
- if (command?.name !== "thread" &&
7690
- (bindings.length > 0 || presented) &&
8036
+ if (needsThread &&
7691
8037
  typeof message.text === "string" &&
7692
8038
  message.text.trim() &&
7693
- (await sendAllTabTemporaryInputChooser(command, text, message, ctx)))
8039
+ (await sendAllTabTemporaryInputChooser(command, text, [message], ctx)))
7694
8040
  return;
7695
- if (bindings.length > 0 && command && command.name !== "thread") {
7696
- if (await sendAllTabCommandChooser(command, text, message, {
7697
- replyToSource: true,
7698
- })) {
8041
+ // Voice notes, files, stickers and albums from All get the same routing tab as text, whether or not the
8042
+ // client opened a tab for them; an album first gathers into one input so it opens one tab.
8043
+ if (!command &&
8044
+ needsThread &&
8045
+ typeof message.text !== "string" &&
8046
+ Media.hasTelegramMessagesPromptContent([message])) {
8047
+ if (message.media_group_id) {
8048
+ deps.mediaGroupRuntime.queueMessage({
8049
+ message: message,
8050
+ context: ctx,
8051
+ dispatchMessages: (messages, queuedCtx) => routeAllTabAlbum(messages, queuedCtx ?? ctx),
8052
+ });
8053
+ Updates.reportTelegramUpdateDeferred(message);
7699
8054
  return;
7700
8055
  }
8056
+ if (await sendAllTabTemporaryInputChooser(undefined, text, [message], ctx))
8057
+ return;
7701
8058
  }
7702
- if (bindings.length > 0 && !text.startsWith("/")) {
8059
+ if (needsThread) {
7703
8060
  const probeTarget = bindings[0]?.target;
7704
- if (probeTarget?.threadId && deps.callApi) {
8061
+ if (!command && !text.startsWith("/") && probeTarget?.threadId && deps.callApi) {
7705
8062
  try {
7706
8063
  await deps.callApi("sendChatAction", {
7707
8064
  chat_id: probeTarget.chatId,
@@ -7710,8 +8067,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7710
8067
  });
7711
8068
  }
7712
8069
  catch (error) {
7713
- if (Threads.isTelegramTopicModeUnavailableError(error) ||
7714
- Threads.isTelegramTopicTargetStaleError(error)) {
8070
+ if (Threads.isTelegramTopicModeUnavailableError(error)) {
7715
8071
  deps.threadStore.setBotState({
7716
8072
  threadMode: "disabled",
7717
8073
  updatedAtMs: Date.now(),
@@ -7729,8 +8085,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7729
8085
  });
7730
8086
  }
7731
8087
  }
7732
- 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.");
7733
- return;
8088
+ throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
7734
8089
  }
7735
8090
  }
7736
8091
  await textDispatch.handleMessage(message, ctx);
@@ -8032,6 +8387,7 @@ export function createTelegramInboundRouteRuntime(deps) {
8032
8387
  prepareHeldCommand: commandHandler.prepareHeldCommand,
8033
8388
  canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
8034
8389
  onSessionSettled,
8390
+ onDeliverySettled,
8035
8391
  observeLiveRebindLeaderWork,
8036
8392
  prepareLiveRebindLeaderCleanup,
8037
8393
  prepareLiveRebindFollowerCleanup,