@llblab/pi-telegram 0.54.4 → 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 +11 -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 +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 +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 +6 -1
  34. package/dist/lib/routing.js +334 -210
  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 +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 +12 -11
  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 +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 +17 -15
  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 +325 -238
  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 +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;
@@ -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);
@@ -1594,23 +1637,34 @@ export function createTelegramInboundRouteRuntime(deps) {
1594
1637
  * any failure keeps the original where the operator can still read it.
1595
1638
  */
1596
1639
  const moveAllTabSourceIntoTab = async (pending, target, isCurrent) => {
1597
- const [messageId] = pending.allTabCopies ?? [];
1598
- if (messageId === undefined || !deps.callApi || !isCurrent())
1640
+ const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
1641
+ if (messageIds.length === 0 || !deps.callApi || !isCurrent())
1599
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
+ };
1600
1649
  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))
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)))
1610
1664
  return;
1611
1665
  pending.allTabCopiesForwarded = true;
1612
1666
  await deleteAllTabCopies(pending);
1613
- return copyId;
1667
+ return copyIds[0];
1614
1668
  }
1615
1669
  catch (error) {
1616
1670
  deps.recordRuntimeEvent?.("routing", error, {
@@ -3019,8 +3073,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3019
3073
  const intent = store
3020
3074
  .listLiveRebindings()
3021
3075
  .find((value) => value.request.operationId === request.operationId);
3022
- if (intent?.phase === "released" &&
3023
- !intent.cleanup &&
3076
+ if (intent && Threads.isTelegramLiveRebindInputAuthorized(intent) &&
3024
3077
  isDeepStrictEqual(intent.request, request) &&
3025
3078
  intent.operatorUserId === operatorUserId &&
3026
3079
  isDeepStrictEqual(intent.executor, {
@@ -3289,6 +3342,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3289
3342
  name ||
3290
3343
  confirmation ||
3291
3344
  extension;
3345
+ const preDispatchCleanup = deps.runWorkspaceOperation !== undefined && deps.canUsePreDispatchRebind?.(recipient) === true;
3292
3346
  const createCoordinator = () => createTelegramLiveRebindCoordinator({
3293
3347
  request,
3294
3348
  messages: selection.messages,
@@ -3299,21 +3353,56 @@ export function createTelegramInboundRouteRuntime(deps) {
3299
3353
  ? async (isCurrent) => {
3300
3354
  if (!isCurrent())
3301
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;
3302
3360
  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
- });
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.");
3309
3383
  }
3310
3384
  : undefined,
3311
3385
  authority: {
3312
3386
  executor: { instanceId, leaderEpoch: String(epoch) },
3313
3387
  operatorUserId,
3314
- isCurrent: current,
3388
+ isCurrent: () => current() && (!preDispatchCleanup || deps.canUsePreDispatchRebind?.(recipient) === true),
3315
3389
  },
3316
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
+ } : {}),
3317
3406
  follower: isLeader
3318
3407
  ? undefined
3319
3408
  : {
@@ -3344,9 +3433,9 @@ export function createTelegramInboundRouteRuntime(deps) {
3344
3433
  instanceId,
3345
3434
  leaderEpoch: String(epoch),
3346
3435
  }) &&
3347
- !intent.cleanup &&
3348
- (intent.phase === "rebound" ||
3349
- 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) &&
3350
3439
  isDeepStrictEqual(intent.recipient, {
3351
3440
  kind: recipient.kind,
3352
3441
  instanceId: recipient.instanceId,
@@ -3520,7 +3609,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3520
3609
  },
3521
3610
  };
3522
3611
  };
3523
- async function inspectLiveRebindRecipient(intent, ctx, mode) {
3612
+ async function inspectLiveRebindRecipient(intent, ctx, mode, admittedOrigin) {
3524
3613
  const ports = deps.workspaceRestoreRecipient, threads = deps.threadStore, store = deps.getWorkspaceRestoreStore?.();
3525
3614
  const followerMode = mode === "follower-cleanup" || mode === "follower-issue", cleanupMode = mode !== "work";
3526
3615
  const issueMode = mode === "issue" || mode === "follower-issue", callApi = deps.callApi;
@@ -3532,8 +3621,9 @@ export function createTelegramInboundRouteRuntime(deps) {
3532
3621
  (followerMode ? !peer || !peerRun || !peerJournal : !collector) ||
3533
3622
  !run ||
3534
3623
  (issueMode && !callApi) ||
3535
- deps.isContextActive?.(ctx) !== true)
3624
+ deps.isContextActive?.(ctx) !== true) {
3536
3625
  return undefined;
3626
+ }
3537
3627
  try {
3538
3628
  const expected = structuredClone(intent), { request, recipient, executor } = expected;
3539
3629
  const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.(), generation = deps.getSessionGeneration?.();
@@ -3623,7 +3713,8 @@ export function createTelegramInboundRouteRuntime(deps) {
3623
3713
  peerJournal.call(peer, live) === recipientJournalKey &&
3624
3714
  isDeepStrictEqual(findReference(), reference));
3625
3715
  };
3626
- const current = () => deps.hasWorkspaceLiveRebindAuthority?.() === true &&
3716
+ const current = () => (!admittedOrigin || admittedOrigin()) &&
3717
+ deps.hasWorkspaceLiveRebindAuthority?.() === true &&
3627
3718
  deps.isContextActive?.(ctx) === true &&
3628
3719
  deps.workspaceRestoreRecipient === ports &&
3629
3720
  (followerMode || ports.observeLeaderWork === collector) &&
@@ -3668,8 +3759,11 @@ export function createTelegramInboundRouteRuntime(deps) {
3668
3759
  };
3669
3760
  const sample = async () => {
3670
3761
  assertCanonical();
3671
- if (cleanupMode && protection.call(threads, expected) !== false)
3672
- return undefined;
3762
+ if (cleanupMode) {
3763
+ const protectedTarget = protection.call(threads, expected);
3764
+ if (protectedTarget !== false)
3765
+ return undefined;
3766
+ }
3673
3767
  const target = { ...request.binding.target };
3674
3768
  let value;
3675
3769
  if (followerMode) {
@@ -3678,7 +3772,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3678
3772
  instanceId: recipient.instanceId,
3679
3773
  sessionId: recipient.sessionId,
3680
3774
  recipientBindingKey: recipientJournalKey,
3681
- mode: "observe",
3775
+ mode: expected.inputRelease === "held" ? "observe-held" : "observe",
3682
3776
  sourceUpdateIds: [...request.source.updateIds],
3683
3777
  slot: request.binding.slot,
3684
3778
  target: { ...request.target },
@@ -3720,12 +3814,13 @@ export function createTelegramInboundRouteRuntime(deps) {
3720
3814
  throw new Error("Live-rebind work sample is unavailable or changed target.");
3721
3815
  assertCanonical();
3722
3816
  if (cleanupMode) {
3817
+ const targetProtected = protection.call(threads, expected);
3723
3818
  const prepared = ThreadReconciler.prepareLiveRebindThreadCleanup({
3724
3819
  operationId: request.operationId,
3725
3820
  oldTarget: request.binding.target,
3726
3821
  recipientTarget: request.target,
3727
3822
  leaderEpoch: executor.leaderEpoch,
3728
- targetProtected: protection.call(threads, expected),
3823
+ targetProtected,
3729
3824
  work,
3730
3825
  });
3731
3826
  assertCanonical();
@@ -3749,32 +3844,27 @@ export function createTelegramInboundRouteRuntime(deps) {
3749
3844
  return undefined;
3750
3845
  if (issueMode) {
3751
3846
  // 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 () => {
3847
+ const execute = async () => {
3757
3848
  const prepared = await sample();
3758
3849
  if (!prepared ||
3759
3850
  prepared.status !== "prepared" ||
3760
3851
  !current() ||
3761
- protection.call(threads, expected) !== false)
3852
+ protection.call(threads, expected) !== false) {
3762
3853
  return { status: "not-ready" };
3854
+ }
3763
3855
  const authority = {
3764
3856
  executor: structuredClone(executor),
3765
3857
  operatorUserId: expected.operatorUserId,
3766
3858
  isCurrent: current,
3767
3859
  };
3768
3860
  const grant = store.advanceLiveRebind(expected, "issue-cleanup", authority);
3769
- if (!grant)
3861
+ if (!grant) {
3770
3862
  return undefined;
3863
+ }
3771
3864
  const assertGranted = () => assertCanonical(grant);
3772
3865
  const cleanup = await ThreadReconciler.issueLiveRebindThreadCleanup(prepared, {
3773
3866
  assertCurrent: assertGranted,
3774
- deleteTopic: (target) => callApi("deleteForumTopic", {
3775
- chat_id: target.chatId,
3776
- message_thread_id: target.threadId,
3777
- }, { maxAttempts: 1, assertAuthority: assertGranted }),
3867
+ deleteTopic: (target) => callApi("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId }, { maxAttempts: 1, assertAuthority: assertGranted }),
3778
3868
  classifyFailure: classifyLiveRebindCleanupFailure,
3779
3869
  });
3780
3870
  let recorded = false;
@@ -3792,7 +3882,12 @@ export function createTelegramInboundRouteRuntime(deps) {
3792
3882
  cleanup,
3793
3883
  recorded,
3794
3884
  };
3795
- });
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);
3796
3891
  }
3797
3892
  const observation = await run({
3798
3893
  operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
@@ -3826,8 +3921,26 @@ export function createTelegramInboundRouteRuntime(deps) {
3826
3921
  const issueLiveRebindFollowerCleanup = (intent, ctx) => inspectLiveRebindRecipient(intent, ctx, "follower-issue");
3827
3922
  // One pacing chain per operation: a waiting timer or a running attempt; repeated choices never start another.
3828
3923
  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.
3924
+ // One hint slot per existing leader chain; a running attempt retains at most one coalesced recheck.
3830
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
+ };
3831
3944
  /** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
3832
3945
  const terminalizeUnissuedLiveRebindCleanup = async (operationId, ctx) => {
3833
3946
  const run = deps.runWorkspaceOperation, store = deps.getWorkspaceRestoreStore?.();
@@ -3868,24 +3981,53 @@ export function createTelegramInboundRouteRuntime(deps) {
3868
3981
  * a finished outcome or an inactive session stops, and the window ends in `not-issued` so no released row becomes a
3869
3982
  * permanent rebinding blocker. Waiting timers never join settlement; only a running attempt does.
3870
3983
  */
3871
- const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0) => {
3984
+ const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0, current = captureLiveRebindCleanupScope(ctx)) => {
3872
3985
  const pacing = deps.liveRebindCleanupSchedule;
3873
3986
  const chain = liveRebindCleanupChains.get(operationId);
3874
3987
  if (!pacing ||
3875
3988
  (attempt === 0 ? chain !== undefined : chain !== "running") ||
3876
- deps.isContextActive?.(ctx) !== true) {
3877
- if (attempt > 0 && chain === "running")
3989
+ !current()) {
3990
+ if (attempt > 0 && chain === "running") {
3878
3991
  liveRebindCleanupChains.delete(operationId);
3992
+ liveRebindCleanupWakers.delete(operationId);
3993
+ }
3879
3994
  return;
3880
3995
  }
3996
+ let pendingWake;
3997
+ const wake = (reason) => {
3998
+ if (!current())
3999
+ return;
4000
+ if (liveRebindCleanupChains.get(operationId) === "running") {
4001
+ pendingWake = reason;
4002
+ return;
4003
+ }
4004
+ if (liveRebindCleanupChains.get(operationId) !== timer)
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
+ };
3881
4021
  const attemptNow = () => {
3882
- liveRebindCleanupWakers.delete(operationId);
3883
4022
  if (liveRebindCleanupChains.get(operationId) !== timer)
3884
4023
  return;
4024
+ pendingWake = undefined; // This fresh timer/settlement attempt consumes any previously deferred hint.
3885
4025
  liveRebindCleanupChains.set(operationId, "running");
4026
+ if (role === "leader")
4027
+ liveRebindCleanupWakers.set(operationId, wake);
3886
4028
  let continued = false;
3887
4029
  const task = (async () => {
3888
- if (deps.isContextActive?.(ctx) !== true)
4030
+ if (!current())
3889
4031
  return;
3890
4032
  const intent = deps
3891
4033
  .getWorkspaceRestoreStore?.()
@@ -3897,20 +4039,25 @@ export function createTelegramInboundRouteRuntime(deps) {
3897
4039
  ? await issueLiveRebindLeaderCleanup(intent, ctx)
3898
4040
  : await issueLiveRebindFollowerCleanup(intent, ctx);
3899
4041
  if (result?.status === "finished" ||
3900
- deps.isContextActive?.(ctx) !== true)
4042
+ !current())
3901
4043
  return;
3902
- if (Date.now() - startedAtMs >= pacing.windowMs)
4044
+ if (Date.now() - startedAtMs >= pacing.windowMs) {
3903
4045
  return terminalizeUnissuedLiveRebindCleanup(operationId, ctx);
4046
+ }
3904
4047
  continued = true;
3905
- scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1);
4048
+ scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1, current);
4049
+ if (pendingWake)
4050
+ liveRebindCleanupWakers.get(operationId)?.(pendingWake);
3906
4051
  })()
3907
4052
  .catch((error) => deps.recordRuntimeEvent?.("routing", error, {
3908
4053
  phase: "live-rebind-cleanup-schedule",
3909
4054
  }))
3910
4055
  .finally(() => {
3911
4056
  if (!continued &&
3912
- liveRebindCleanupChains.get(operationId) === "running")
4057
+ liveRebindCleanupChains.get(operationId) === "running") {
3913
4058
  liveRebindCleanupChains.delete(operationId);
4059
+ liveRebindCleanupWakers.delete(operationId);
4060
+ }
3914
4061
  });
3915
4062
  restoreSettlementTasks.add(task);
3916
4063
  void task.finally(() => restoreSettlementTasks.delete(task));
@@ -3920,20 +4067,16 @@ export function createTelegramInboundRouteRuntime(deps) {
3920
4067
  liveRebindCleanupChains.set(operationId, timer);
3921
4068
  // The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
3922
4069
  if (role === "leader" && attempt > 0)
3923
- liveRebindCleanupWakers.set(operationId, () => {
3924
- if (liveRebindCleanupChains.get(operationId) !== timer)
3925
- return;
3926
- clearTimeout(timer);
3927
- attemptNow();
3928
- });
4070
+ liveRebindCleanupWakers.set(operationId, wake);
3929
4071
  };
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) => {
4072
+ const wakeLiveRebindCleanup = (ctx, reason) => {
3932
4073
  if (deps.isContextActive?.(ctx) !== true)
3933
4074
  return;
3934
4075
  for (const wake of [...liveRebindCleanupWakers.values()])
3935
- wake();
4076
+ wake(reason);
3936
4077
  };
4078
+ const onSessionSettled = (ctx) => wakeLiveRebindCleanup(ctx, "session-settled");
4079
+ const onDeliverySettled = (ctx) => wakeLiveRebindCleanup(ctx, "delivery-drained");
3937
4080
  const restoreWorkspace = deps.workspaceRestoreRecipient
3938
4081
  ? async (input) => {
3939
4082
  const ports = deps.workspaceRestoreRecipient;
@@ -5837,19 +5980,21 @@ export function createTelegramInboundRouteRuntime(deps) {
5837
5980
  pending.liveRebind ??= selection;
5838
5981
  if (!(await selectPendingDestination()))
5839
5982
  return true;
5983
+ const operationId = selection.coordinator.operationId;
5840
5984
  if (current())
5841
5985
  outcome = await selection.coordinator.advance();
5842
5986
  // Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
5843
5987
  // still be pending while every attempt samples current work afresh.
5844
- const operationId = selection.coordinator.operationId;
5845
5988
  const row = outcome === "protected"
5846
5989
  ? undefined
5847
5990
  : deps
5848
5991
  .getWorkspaceRestoreStore?.()
5849
5992
  ?.listLiveRebindings()
5850
5993
  .find((value) => value.request.operationId === operationId);
5851
- if (row?.phase === "released" && row.cleanup === undefined)
5994
+ if (row?.phase === "released" && row.cleanup === undefined &&
5995
+ (row.inputRelease === undefined || row.inputRelease === "accepted")) {
5852
5996
  scheduleLiveRebindCleanup(operationId, selection.record.owner?.kind === "leader" ? "leader" : "follower", ctx);
5997
+ }
5853
5998
  if (outcome === "released")
5854
5999
  await releaseAllTabCopiesIntoSurvivingTab(pending);
5855
6000
  }
@@ -5866,28 +6011,40 @@ export function createTelegramInboundRouteRuntime(deps) {
5866
6011
  await deps.answerCallbackQuery(query.id, "Live rebind is unconfirmed; this choice never replays issued input");
5867
6012
  return true;
5868
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());
5869
6023
  // 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);
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"
6028
+ ? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
6029
+ : selection.extensionCommand
6030
+ ? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
6031
+ : selection.nameCommand
6032
+ ? selection.nameCommand.args.trim()
6033
+ ? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
6034
+ : "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
6035
+ : selection.confirmationCommand
6036
+ ? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
6037
+ : selection.helpCommand
6038
+ ? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
6039
+ : selection.stopCommand
6040
+ ? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
6041
+ : selection.nextCommand
6042
+ ? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
6043
+ : selection.menuCommand || selection.abortCommand
6044
+ ? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
6045
+ : selection.coordinator?.selectedCommandReference()
6046
+ ? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
6047
+ : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
5891
6048
  return true;
5892
6049
  }
5893
6050
  if (!(await selectPendingDestination()))
@@ -7023,23 +7180,34 @@ export function createTelegramInboundRouteRuntime(deps) {
7023
7180
  * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
7024
7181
  * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
7025
7182
  */
7026
- const sendAllTabTemporaryInputChooser = async (command, commandText, message, ctx) => {
7027
- const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([
7028
- message,
7029
- ]);
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 };
7030
7196
  const execution = Updates.getTelegramUpdateExecutionFence(message);
7031
7197
  const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
7032
7198
  if (!cap ||
7033
7199
  !deps.runWorkspaceOperation ||
7034
7200
  !deps.callApi ||
7035
7201
  updateId === undefined ||
7036
- message.chat.type !== "private" ||
7037
- message.chat.id !== cap.operatorUserId ||
7038
- 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) ||
7039
7206
  !cap.isCurrent())
7040
7207
  return false;
7041
7208
  const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
7042
7209
  const source = { journalBindingKey, updateId };
7210
+ group.journalBindingKey = journalBindingKey;
7043
7211
  const find = () => store
7044
7212
  .listTemporaryThreads()
7045
7213
  .find((entry) => entry.source.journalBindingKey === journalBindingKey &&
@@ -7065,7 +7233,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7065
7233
  // the input itself to All: adopt that tab rather than leaving it behind next to a second one.
7066
7234
  const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
7067
7235
  if (native) {
7068
- 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"), {
7069
7237
  ...authority,
7070
7238
  isCurrent: () => current() &&
7071
7239
  implicitThreadCreations.get(native.key) === native.implicit,
@@ -7090,7 +7258,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7090
7258
  return;
7091
7259
  }
7092
7260
  }
7093
- const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
7261
+ const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority, group);
7094
7262
  entry = reservation?.entry;
7095
7263
  if (!reservation?.reserved || !current())
7096
7264
  return;
@@ -7120,26 +7288,33 @@ export function createTelegramInboundRouteRuntime(deps) {
7120
7288
  });
7121
7289
  if (!current() || !entry)
7122
7290
  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
- });
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];
7128
7301
  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" });
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" });
7131
7305
  return true;
7132
7306
  }
7133
7307
  const target = entry.target;
7134
7308
  const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
7135
- const rerouteId = storePendingUnboundReroute([commandMessage], command ? "command" : "prompt", target);
7309
+ const rerouteId = storePendingUnboundReroute(held, command ? "command" : "prompt", target);
7136
7310
  const pending = pendingUnboundReroutes.get(rerouteId);
7137
7311
  pending.temporaryThread = entry;
7138
- pending.allTabCopies = getTelegramAllTabSourceMessageIds([message], target.chatId);
7139
- Updates.reportTelegramUpdateDeferred(commandMessage);
7312
+ pending.allTabCopies = getTelegramAllTabSourceMessageIds(messages, target.chatId);
7313
+ for (const input of held)
7314
+ Updates.reportTelegramUpdateDeferred(input);
7140
7315
  // Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
7141
7316
  if (deps.editInteractiveMessage &&
7142
- Updates.supportsTelegramDeferredAbandonment(commandMessage, journalBindingKey)) {
7317
+ held.every((input) => Updates.supportsTelegramDeferredAbandonment(input, journalBindingKey))) {
7143
7318
  pending.abandonment = {
7144
7319
  ownerUserId: operatorUserId,
7145
7320
  journalBindingKey,
@@ -7168,72 +7343,11 @@ export function createTelegramInboundRouteRuntime(deps) {
7168
7343
  }
7169
7344
  return true;
7170
7345
  };
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;
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);
7237
7351
  };
7238
7352
  const dispatchCommandOrPrompt = Commands.createTelegramCommandOrPromptDispatcher({
7239
7353
  extractRawText: Media.extractFirstTelegramMessageText,
@@ -7680,28 +7794,39 @@ export function createTelegramInboundRouteRuntime(deps) {
7680
7794
  .some((entry) => entry.source.updateId === sourceUpdateId &&
7681
7795
  entry.source.journalBindingKey ===
7682
7796
  deps.getAdmissionJournalBinding?.()) === true;
7797
+ const needsThread = deps.threadStore.getBotState().threadMode === "enabled" || bindings.length > 0 || presented;
7683
7798
  // Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
7684
7799
  if (command &&
7685
7800
  command.name !== "thread" &&
7686
7801
  !presented &&
7687
7802
  isTelegramAllTabCommandExpired(message))
7688
7803
  return;
7689
- if (command?.name !== "thread" &&
7690
- (bindings.length > 0 || presented) &&
7804
+ if (needsThread &&
7691
7805
  typeof message.text === "string" &&
7692
7806
  message.text.trim() &&
7693
- (await sendAllTabTemporaryInputChooser(command, text, message, ctx)))
7807
+ (await sendAllTabTemporaryInputChooser(command, text, [message], ctx)))
7694
7808
  return;
7695
- if (bindings.length > 0 && command && command.name !== "thread") {
7696
- if (await sendAllTabCommandChooser(command, text, message, {
7697
- replyToSource: true,
7698
- })) {
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);
7699
7822
  return;
7700
7823
  }
7824
+ if (await sendAllTabTemporaryInputChooser(undefined, text, [message], ctx))
7825
+ return;
7701
7826
  }
7702
- if (bindings.length > 0 && !text.startsWith("/")) {
7827
+ if (needsThread) {
7703
7828
  const probeTarget = bindings[0]?.target;
7704
- if (probeTarget?.threadId && deps.callApi) {
7829
+ if (!command && !text.startsWith("/") && probeTarget?.threadId && deps.callApi) {
7705
7830
  try {
7706
7831
  await deps.callApi("sendChatAction", {
7707
7832
  chat_id: probeTarget.chatId,
@@ -7710,8 +7835,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7710
7835
  });
7711
7836
  }
7712
7837
  catch (error) {
7713
- if (Threads.isTelegramTopicModeUnavailableError(error) ||
7714
- Threads.isTelegramTopicTargetStaleError(error)) {
7838
+ if (Threads.isTelegramTopicModeUnavailableError(error)) {
7715
7839
  deps.threadStore.setBotState({
7716
7840
  threadMode: "disabled",
7717
7841
  updatedAtMs: Date.now(),
@@ -7729,8 +7853,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7729
7853
  });
7730
7854
  }
7731
7855
  }
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;
7856
+ throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
7734
7857
  }
7735
7858
  }
7736
7859
  await textDispatch.handleMessage(message, ctx);
@@ -8032,6 +8155,7 @@ export function createTelegramInboundRouteRuntime(deps) {
8032
8155
  prepareHeldCommand: commandHandler.prepareHeldCommand,
8033
8156
  canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
8034
8157
  onSessionSettled,
8158
+ onDeliverySettled,
8035
8159
  observeLiveRebindLeaderWork,
8036
8160
  prepareLiveRebindLeaderCleanup,
8037
8161
  prepareLiveRebindFollowerCleanup,