@llblab/pi-kit 0.33.0 → 0.34.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 (67) hide show
  1. package/BACKLOG.md +2 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +3 -3
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +5 -4
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +6 -1
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +20 -0
  7. package/node_modules/@llblab/pi-telegram/README.md +29 -18
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +8 -4
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +41 -1
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +2 -0
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +10 -5
  12. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +2 -0
  13. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +34 -20
  14. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +10 -0
  15. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +32 -13
  16. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +1 -1
  17. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +33 -28
  18. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +18 -3
  19. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -1
  20. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +28 -20
  21. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +6 -2
  22. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +3 -3
  23. package/node_modules/@llblab/pi-telegram/dist/lib/menu-queue.d.ts +1 -0
  24. package/node_modules/@llblab/pi-telegram/dist/lib/menu-queue.js +102 -99
  25. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +3 -2
  26. package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +3 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +6 -2
  28. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +5 -3
  29. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +57 -5
  30. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +313 -25
  31. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +5 -1
  32. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +323 -30
  33. package/node_modules/@llblab/pi-telegram/dist/lib/status.d.ts +1 -2
  34. package/node_modules/@llblab/pi-telegram/dist/lib/status.js +6 -7
  35. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +11 -1
  36. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +11 -0
  37. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +57 -6
  38. package/node_modules/@llblab/pi-telegram/dist/lib/turns.d.ts +4 -0
  39. package/node_modules/@llblab/pi-telegram/dist/lib/turns.js +6 -0
  40. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +14 -7
  41. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +44 -9
  42. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  43. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
  44. package/node_modules/@llblab/pi-telegram/docs/architecture.md +27 -14
  45. package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +4 -0
  46. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +8 -4
  47. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +14 -10
  48. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +52 -3
  49. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +13 -5
  50. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +37 -23
  51. package/node_modules/@llblab/pi-telegram/lib/bus.ts +36 -10
  52. package/node_modules/@llblab/pi-telegram/lib/commands.ts +52 -47
  53. package/node_modules/@llblab/pi-telegram/lib/extension.ts +18 -2
  54. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +34 -28
  55. package/node_modules/@llblab/pi-telegram/lib/locks.ts +5 -2
  56. package/node_modules/@llblab/pi-telegram/lib/menu-queue.ts +116 -162
  57. package/node_modules/@llblab/pi-telegram/lib/pi.ts +8 -0
  58. package/node_modules/@llblab/pi-telegram/lib/polling.ts +7 -2
  59. package/node_modules/@llblab/pi-telegram/lib/queue.ts +328 -33
  60. package/node_modules/@llblab/pi-telegram/lib/routing.ts +350 -30
  61. package/node_modules/@llblab/pi-telegram/lib/status.ts +7 -7
  62. package/node_modules/@llblab/pi-telegram/lib/sync.ts +11 -0
  63. package/node_modules/@llblab/pi-telegram/lib/threads.ts +57 -5
  64. package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
  65. package/node_modules/@llblab/pi-telegram/lib/updates.ts +46 -15
  66. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  67. package/package.json +2 -2
@@ -584,6 +584,20 @@ export function createTelegramLiveRebindCoordinator(input) {
584
584
  }
585
585
  : undefined;
586
586
  },
587
+ /** Observe only an already-issued leader command; never prepares, relocates or executes input. */
588
+ observeCommandCompletion() {
589
+ if (!continuationIssued || !leader?.complete)
590
+ return undefined;
591
+ if (!current())
592
+ return "unknown";
593
+ try {
594
+ return observeCommandCompletion();
595
+ }
596
+ catch (error) {
597
+ input.recordRuntimeEvent?.("telegram", error, { phase: "live-rebind-command-observation" });
598
+ return "unknown";
599
+ }
600
+ },
587
601
  async advance() {
588
602
  if (advancing || !current())
589
603
  return "protected";
@@ -1331,13 +1345,20 @@ export function createTelegramInboundRouteRuntime(deps) {
1331
1345
  requestDispatchNextQueuedTelegramTurn(ctx);
1332
1346
  },
1333
1347
  });
1334
- const cloneTelegramMessagesForThread = (messages, threadId) => {
1335
- return messages.map((message) => Updates.carryTelegramUpdateExecutionFence(message, {
1336
- ...message,
1337
- message_id: 0,
1338
- message_thread_id: threadId,
1339
- reply_to_message: undefined,
1340
- }));
1348
+ const cloneTelegramMessagesForThread = (messages, threadId, copies) => {
1349
+ return messages.map((message) => {
1350
+ const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
1351
+ const copyId = copies && ids.length === 1 ? copies.get(ids[0]) : undefined;
1352
+ if (copies && (!Number.isSafeInteger(copyId) || copyId <= 0))
1353
+ throw new Error("Reroute destination copy identity is unavailable.");
1354
+ const routed = {
1355
+ ...message,
1356
+ message_id: copyId ?? 0,
1357
+ message_thread_id: threadId,
1358
+ reply_to_message: undefined,
1359
+ };
1360
+ return Updates.carryTelegramUpdateExecutionFence(message, copies ? Turns.withTelegramTurnReplyAnchor(routed) : routed);
1361
+ });
1341
1362
  };
1342
1363
  /** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
1343
1364
  const hasPendingRerouteForTarget = (target, own) => [...pendingUnboundReroutes.values()].some((pending) => pending.sourceTarget.chatId === target.chatId &&
@@ -1603,10 +1624,12 @@ export function createTelegramInboundRouteRuntime(deps) {
1603
1624
  return !acceptedWorkClear;
1604
1625
  };
1605
1626
  /** Best-effort, once consumed: All keeps no copy of an input whose routing tab carried it; failure is only recorded. */
1606
- const deleteAllTabMessages = async (chatId, messageIds) => {
1627
+ const deleteAllTabMessages = async (chatId, messageIds, isCurrent) => {
1607
1628
  if (!deps.deleteMessage)
1608
1629
  return;
1609
1630
  for (const messageId of messageIds) {
1631
+ if (isCurrent?.() === false)
1632
+ return;
1610
1633
  try {
1611
1634
  await deps.deleteMessage(chatId, messageId);
1612
1635
  }
@@ -1620,13 +1643,14 @@ export function createTelegramInboundRouteRuntime(deps) {
1620
1643
  }
1621
1644
  };
1622
1645
  /** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
1623
- const deleteAllTabCopies = (pending) => deleteAllTabMessages(pending.sourceTarget.chatId, pending.allTabCopies?.splice(0) ?? []);
1646
+ const deleteAllTabCopies = (pending, isCurrent) => deleteAllTabMessages(pending.sourceTarget.chatId, pending.allTabCopies?.splice(0) ?? [], isCurrent);
1624
1647
  /**
1625
1648
  * Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
1626
1649
  * holds its forwarded copy. Without one it stays where the operator can still read it.
1627
1650
  */
1628
1651
  const releaseAllTabCopiesIntoSurvivingTab = (pending) => {
1629
- if (pending.allTabCopiesForwarded)
1652
+ if (pending.allTabCopiesForwarded &&
1653
+ isDeepStrictEqual(pending.allTabCopiesForwarded.target, pending.sourceTarget))
1630
1654
  return deleteAllTabCopies(pending);
1631
1655
  pending.allTabCopies?.splice(0);
1632
1656
  return Promise.resolve();
@@ -1640,39 +1664,109 @@ export function createTelegramInboundRouteRuntime(deps) {
1640
1664
  const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
1641
1665
  if (messageIds.length === 0 || !deps.callApi || !isCurrent())
1642
1666
  return;
1667
+ const copyTarget = { ...target };
1643
1668
  const route = {
1644
- chat_id: target.chatId,
1645
- message_thread_id: target.threadId,
1669
+ chat_id: copyTarget.chatId,
1670
+ message_thread_id: copyTarget.threadId,
1646
1671
  from_chat_id: pending.sourceTarget.chatId,
1647
1672
  disable_notification: true,
1648
1673
  };
1674
+ const options = {
1675
+ maxAttempts: 1,
1676
+ retrySafety: "non-idempotent",
1677
+ assertAuthority() {
1678
+ if (!isCurrent())
1679
+ throw new Error("Temporary Thread source forwarding authority changed.");
1680
+ },
1681
+ };
1649
1682
  try {
1650
1683
  // An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
1651
1684
  const copies = messageIds.length === 1
1652
1685
  ? [
1653
- await deps.callApi("forwardMessage", {
1654
- ...route,
1655
- message_id: messageIds[0],
1656
- }),
1686
+ await deps.callApi("forwardMessage", { ...route, message_id: messageIds[0] }, options),
1657
1687
  ]
1658
- : await deps.callApi("forwardMessages", { ...route, message_ids: messageIds });
1688
+ : await deps.callApi("forwardMessages", { ...route, message_ids: messageIds }, options);
1689
+ if (!isCurrent())
1690
+ return;
1659
1691
  const copyIds = Array.isArray(copies)
1660
1692
  ? copies.map((copy) => copy?.message_id)
1661
1693
  : [];
1662
1694
  if (copyIds.length !== messageIds.length ||
1663
- !copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id)))
1695
+ !copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
1696
+ new Set(copyIds).size !== copyIds.length)
1664
1697
  return;
1665
- pending.allTabCopiesForwarded = true;
1666
- await deleteAllTabCopies(pending);
1667
- return copyIds[0];
1698
+ pending.allTabCopiesForwarded = {
1699
+ target: copyTarget,
1700
+ sourceMessageIds: messageIds,
1701
+ messageIds: copyIds,
1702
+ };
1703
+ await deleteAllTabCopies(pending, isCurrent);
1704
+ if (!isCurrent())
1705
+ return;
1706
+ return pending.allTabCopiesForwarded.messageIds[0];
1668
1707
  }
1669
1708
  catch (error) {
1670
- deps.recordRuntimeEvent?.("routing", error, {
1671
- phase: "temporary-thread-source-forward",
1672
- });
1709
+ if (isCurrent())
1710
+ deps.recordRuntimeEvent?.("routing", error, {
1711
+ phase: "temporary-thread-source-forward",
1712
+ });
1673
1713
  return;
1674
1714
  }
1675
1715
  };
1716
+ /** Read original source addresses, not warm target rewrites; unknown copy provenance never grants a Reroute. */
1717
+ const resolvePendingRerouteForwardSource = (pending) => {
1718
+ try {
1719
+ const originals = pending.messages.map((message) => {
1720
+ const snapshot = Updates.inspectTelegramDeferredSourceSnapshot(message);
1721
+ const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
1722
+ const original = snapshot?.update.message;
1723
+ if (!snapshot ||
1724
+ ids.length !== 1 ||
1725
+ snapshot.source.updateId !== ids[0] ||
1726
+ snapshot.update.update_id !== ids[0] ||
1727
+ Object.keys(snapshot.update).some((key) => key !== "update_id" && key !== "message") ||
1728
+ snapshot.source.journalBindingKey !== deps.getAdmissionJournalBinding?.() ||
1729
+ !original ||
1730
+ original.chat?.type !== "private" ||
1731
+ original.chat.id !== pending.sourceTarget.chatId ||
1732
+ original.from?.id !== deps.configStore.getAllowedUserId() ||
1733
+ original.from?.is_bot !== false ||
1734
+ !Number.isSafeInteger(original.message_id) ||
1735
+ original.message_id <= 0)
1736
+ return undefined;
1737
+ return { message: original, updateId: ids[0] };
1738
+ });
1739
+ if (originals.length === 0 || originals.some((source) => !source))
1740
+ return;
1741
+ originals.sort((a, b) => a.message.message_id - b.message.message_id);
1742
+ const sourceMessageIds = originals.map((source) => source.message.message_id);
1743
+ const sourceUpdateIds = originals.map((source) => source.updateId);
1744
+ if (new Set(sourceMessageIds).size !== sourceMessageIds.length ||
1745
+ new Set(sourceUpdateIds).size !== sourceUpdateIds.length)
1746
+ return;
1747
+ const copied = pending.allTabCopiesForwarded;
1748
+ if (copied) {
1749
+ if (!isDeepStrictEqual(copied.target, pending.sourceTarget) ||
1750
+ !isDeepStrictEqual(copied.sourceMessageIds, sourceMessageIds) ||
1751
+ copied.messageIds.length !== sourceMessageIds.length ||
1752
+ !copied.messageIds.every((id) => Number.isSafeInteger(id) && id > 0) ||
1753
+ new Set(copied.messageIds).size !== copied.messageIds.length)
1754
+ return;
1755
+ return { chatId: copied.target.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...copied.messageIds] };
1756
+ }
1757
+ const nativeOriginals = originals.every((source) => typeof pending.sourceTarget.threadId === "number" &&
1758
+ source.message.message_thread_id === pending.sourceTarget.threadId);
1759
+ const retainedAllOriginals = pending.allTabSourceRetained === true &&
1760
+ originals.every((source) => source.message.message_thread_id === undefined) &&
1761
+ isDeepStrictEqual([...(pending.allTabCopies ?? [])].sort((a, b) => a - b), sourceMessageIds);
1762
+ if (nativeOriginals || retainedAllOriginals)
1763
+ return { chatId: pending.sourceTarget.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...sourceMessageIds] };
1764
+ }
1765
+ catch (error) {
1766
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-source-proof" });
1767
+ }
1768
+ return undefined;
1769
+ };
1676
1770
  const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
1677
1771
  const chatId = query.message?.chat?.id;
1678
1772
  const messageId = query.message?.message_id;
@@ -3020,7 +3114,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3020
3114
  Bus.isSameTelegramBusFollowerRegistration(live, follower) &&
3021
3115
  [
3022
3116
  ...Bus.TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES,
3023
- ...(heldSelected ? Bus.TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES : []),
3117
+ ...(heldSelected ? Bus.getTelegramBusHeldCommandCapabilities(heldSelected.name) : []),
3024
3118
  ].every((cap) => Bus.hasTelegramBusCapability(live.protocol, cap)) &&
3025
3119
  !!live.target &&
3026
3120
  live.target.chatId === binding.target.chatId &&
@@ -3573,6 +3667,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3573
3667
  return {
3574
3668
  operationId: request.operationId,
3575
3669
  selectedCommandReference: () => coordinator?.selectedCommandReference(),
3670
+ observeCommandCompletion: () => coordinator?.observeCommandCompletion(),
3576
3671
  async advance() {
3577
3672
  if (!coordinator) {
3578
3673
  if (!current() || !getRecipient())
@@ -4076,7 +4171,24 @@ export function createTelegramInboundRouteRuntime(deps) {
4076
4171
  wake(reason);
4077
4172
  };
4078
4173
  const onSessionSettled = (ctx) => wakeLiveRebindCleanup(ctx, "session-settled");
4079
- const onDeliverySettled = (ctx) => wakeLiveRebindCleanup(ctx, "delivery-drained");
4174
+ const onDeliverySettled = (ctx) => {
4175
+ wakeLiveRebindCleanup(ctx, "delivery-drained");
4176
+ const cap = captureTemporaryThreadAuthority(ctx);
4177
+ if (!cap?.isCurrent())
4178
+ return;
4179
+ try {
4180
+ for (const entry of cap.store.listTemporaryThreads()) {
4181
+ if (!cap.isCurrent())
4182
+ return;
4183
+ if (entry.phase === "created" && entry.target && !entry.cleanupIssued &&
4184
+ Threads.isTelegramTemporaryThreadFullyResolved(entry))
4185
+ scheduleTemporaryThreadCleanup(entry.target, ctx);
4186
+ }
4187
+ }
4188
+ catch (error) {
4189
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-drain" });
4190
+ }
4191
+ };
4080
4192
  const restoreWorkspace = deps.workspaceRestoreRecipient
4081
4193
  ? async (input) => {
4082
4194
  const ports = deps.workspaceRestoreRecipient;
@@ -4532,6 +4644,92 @@ export function createTelegramInboundRouteRuntime(deps) {
4532
4644
  return false;
4533
4645
  }
4534
4646
  };
4647
+ /** Publish display copies under the native one-shot grant; positive output is not input/source settlement. */
4648
+ const publishPendingRerouteCopies = async (pending, record, ctx, assertExecutionCurrent, foreign) => {
4649
+ const source = resolvePendingRerouteForwardSource(pending);
4650
+ const target = { ...record.target };
4651
+ const recordIdentity = (value) => value && ({
4652
+ target: value.target, profileKey: value.profileKey, owner: value.owner,
4653
+ instanceId: value.instanceId, slot: value.slot, createdAtMs: value.createdAtMs,
4654
+ });
4655
+ const bindingIdentity = () => deps.threadStore.listWorkspaceBindings()
4656
+ .filter((value) => isDeepStrictEqual(value.target, target))
4657
+ .map((value) => ({ bindingKey: value.bindingKey, sessionId: value.sessionId, sessionKey: value.sessionKey,
4658
+ cwd: value.cwd, workspaceKey: value.workspaceKey, instanceSlot: value.instanceSlot, slot: value.slot,
4659
+ target: value.target }));
4660
+ const expectedRecord = structuredClone(recordIdentity(record));
4661
+ const expectedBindings = structuredClone(bindingIdentity());
4662
+ const ownership = deps.getTargetOwnership?.(target);
4663
+ if (!source || !deps.callApi || expectedBindings.length > 1 ||
4664
+ (foreign && (!ownership || ownership.instanceId !== record.instanceId ||
4665
+ !ownership.ownerGeneration || !ownership.recipientBindingKey)))
4666
+ return;
4667
+ const expectedOwnership = ownership && structuredClone(ownership);
4668
+ const cap = captureTemporaryThreadAuthority(ctx, () => {
4669
+ try {
4670
+ assertExecutionCurrent();
4671
+ const current = getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.())
4672
+ .find((value) => isDeepStrictEqual(value.target, target));
4673
+ return isDeepStrictEqual(recordIdentity(current), expectedRecord) &&
4674
+ isDeepStrictEqual(bindingIdentity(), expectedBindings) &&
4675
+ (!foreign || Bus.isTelegramBusForwardOwnershipCurrent(expectedOwnership, deps.getTargetOwnership?.(target)));
4676
+ }
4677
+ catch {
4678
+ return false;
4679
+ }
4680
+ });
4681
+ if (!cap?.isCurrent())
4682
+ return;
4683
+ const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
4684
+ if (!temporary || !issueTemporaryThreadForward(pending, ctx))
4685
+ return;
4686
+ pending.phase = { kind: "forward-unknown" };
4687
+ const assertRecipientCurrent = () => {
4688
+ assertExecutionCurrent();
4689
+ if (!cap.isCurrent())
4690
+ throw new Error("Reroute publication recipient authority changed.");
4691
+ };
4692
+ const assertPublicationCurrent = () => {
4693
+ assertRecipientCurrent();
4694
+ const entry = cap.store.listTemporaryThreads().find((value) => value.token === temporary.token);
4695
+ const ids = [...source.sourceUpdateIds].sort((a, b) => a - b);
4696
+ if (!entry || entry.phase !== "created" || entry.cleanupIssued ||
4697
+ entry.operatorUserId !== cap.operatorUserId ||
4698
+ !isDeepStrictEqual(entry.executor, cap.authority.executor) ||
4699
+ !isDeepStrictEqual(entry.target, pending.sourceTarget) ||
4700
+ !entry.forwardedInputs?.some((input) => input.journalBindingKey === cap.journalBindingKey &&
4701
+ isDeepStrictEqual(input.updateIds, ids)) ||
4702
+ !isDeepStrictEqual(resolvePendingRerouteForwardSource(pending), source))
4703
+ throw new Error("Reroute publication source authority changed.");
4704
+ };
4705
+ try {
4706
+ assertPublicationCurrent();
4707
+ // Bot API requires ascending source IDs. Preserve the source-UID association even if copy IDs reorder them.
4708
+ const order = source.messageIds.map((_, index) => index)
4709
+ .sort((a, b) => source.messageIds[a] - source.messageIds[b]);
4710
+ const messageIds = order.map((index) => source.messageIds[index]);
4711
+ const body = { chat_id: target.chatId, message_thread_id: target.threadId,
4712
+ from_chat_id: source.chatId, disable_notification: true };
4713
+ const options = { maxAttempts: 1, retrySafety: "non-idempotent", assertAuthority: assertPublicationCurrent };
4714
+ const copies = messageIds.length === 1
4715
+ ? [await deps.callApi("forwardMessage", { ...body, message_id: messageIds[0] }, options)]
4716
+ : await deps.callApi("forwardMessages", { ...body, message_ids: messageIds }, options);
4717
+ assertPublicationCurrent();
4718
+ const copyIds = Array.isArray(copies) ? copies.map((copy) => copy?.message_id) : [];
4719
+ if (copyIds.length !== messageIds.length ||
4720
+ !copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
4721
+ new Set(copyIds).size !== copyIds.length)
4722
+ return;
4723
+ pending.rerouteCopies = { target, bySourceUpdateId: new Map(order.map((index, position) => [source.sourceUpdateIds[index], copyIds[position]])) };
4724
+ pending.phase = { kind: "selected" };
4725
+ return assertRecipientCurrent;
4726
+ }
4727
+ catch (error) {
4728
+ if (cap.isCurrent())
4729
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-destination-forward" });
4730
+ return;
4731
+ }
4732
+ };
4535
4733
  const forwardPendingRerouteMessages = async (pending, instanceId, threadId, ctx, assertExecutionCurrent, recordForwardAcceptance) => {
4536
4734
  assertExecutionCurrent?.();
4537
4735
  const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
@@ -4545,11 +4743,14 @@ export function createTelegramInboundRouteRuntime(deps) {
4545
4743
  !liveOwnership.recipientBindingKey)
4546
4744
  return false;
4547
4745
  const ownership = structuredClone(liveOwnership);
4548
- const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
4746
+ const copies = pending.rerouteCopies;
4747
+ const published = !!copies && isDeepStrictEqual(copies.target, target);
4748
+ const messages = cloneTelegramMessagesForThread(pending.messages, threadId, published ? copies.bySourceUpdateId : undefined);
4549
4749
  // Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
4550
4750
  // even after restart. Any refusal or publication failure sends nothing and keeps the input held.
4551
4751
  if (!recordForwardAcceptance &&
4552
4752
  isTemporaryReroute(pending) &&
4753
+ !published &&
4553
4754
  !issueTemporaryThreadForward(pending, ctx)) {
4554
4755
  if (isTemporaryThreadForwardIssued(pending))
4555
4756
  pending.phase = { kind: "forward-unknown" };
@@ -4561,6 +4762,10 @@ export function createTelegramInboundRouteRuntime(deps) {
4561
4762
  message,
4562
4763
  ownership,
4563
4764
  ctx,
4765
+ assertAuthority() {
4766
+ assertExecutionCurrent?.();
4767
+ Updates.assertTelegramUpdateExecutionCurrent(message);
4768
+ },
4564
4769
  })));
4565
4770
  assertExecutionCurrent?.();
4566
4771
  if (!isDeepStrictEqual(deps.getTargetOwnership?.(target), ownership))
@@ -4719,6 +4924,17 @@ export function createTelegramInboundRouteRuntime(deps) {
4719
4924
  if (retry)
4720
4925
  restoreSettlementTasks.add(task);
4721
4926
  runTemporaryThreadCleanup(token, cap, reconcileExpiry)
4927
+ .then(() => {
4928
+ if (!cap.isCurrent())
4929
+ return;
4930
+ const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
4931
+ // A follower ACK can precede recipient receipt disposal. Reobserve a protected, unissued tab;
4932
+ // elapsed time never grants deletion, and an issued/unknown delete can never enter this path.
4933
+ if (retained?.phase === "created" && !retained.cleanupIssued &&
4934
+ Threads.isTelegramTemporaryThreadFullyResolved(retained) &&
4935
+ cap.store.inspectTemporaryThreadTarget(retained, cap.authority)?.kind === "temporary" && cap.isCurrent())
4936
+ queueTemporaryThreadCleanup(token, cap, 1000, reconcileExpiry, true);
4937
+ })
4722
4938
  .catch((error) => {
4723
4939
  deps.recordRuntimeEvent?.("routing", error, {
4724
4940
  phase: "temporary-thread-cleanup",
@@ -5244,7 +5460,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5244
5460
  for (const [id, pending] of pendingUnboundReroutes) {
5245
5461
  if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
5246
5462
  continue;
5247
- movedIntoTab ||= pending.allTabCopiesForwarded === true;
5463
+ movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
5248
5464
  removePendingReroute(id);
5249
5465
  if (signal.aborted ||
5250
5466
  deps.isContextActive?.(ctx) !== true ||
@@ -5983,6 +6199,52 @@ export function createTelegramInboundRouteRuntime(deps) {
5983
6199
  const operationId = selection.coordinator.operationId;
5984
6200
  if (current())
5985
6201
  outcome = await selection.coordinator.advance();
6202
+ const completionTimeoutMs = deps.liveRebindCommandCompletionTimeoutMs ?? 0;
6203
+ if (outcome === "unknown" && !selection.commandCompletionWaited && selection.record.owner?.kind === "leader" &&
6204
+ (selection.helpCommand || selection.menuCommand || selection.abortCommand || selection.nextCommand || selection.stopCommand) &&
6205
+ Number.isFinite(completionTimeoutMs) && completionTimeoutMs > 0 &&
6206
+ selection.coordinator.observeCommandCompletion() !== undefined) {
6207
+ selection.commandCompletionWaited = true;
6208
+ const coordinator = selection.coordinator;
6209
+ const signal = Updates.getTelegramUpdateExecutionFence(query)?.signal;
6210
+ outcome = await new Promise((resolve) => {
6211
+ let settled = false;
6212
+ const subscriptions = [];
6213
+ const finish = (result) => {
6214
+ if (settled)
6215
+ return;
6216
+ settled = true;
6217
+ clearTimeout(timer);
6218
+ for (const unsubscribe of subscriptions)
6219
+ unsubscribe();
6220
+ signal?.removeEventListener("abort", abort);
6221
+ resolve(result);
6222
+ };
6223
+ const abort = () => finish("unknown");
6224
+ const observe = () => {
6225
+ if (settled)
6226
+ return;
6227
+ if (!current()) {
6228
+ finish("unknown");
6229
+ return;
6230
+ }
6231
+ if (coordinator.observeCommandCompletion() === "released")
6232
+ finish("released");
6233
+ };
6234
+ const timer = setTimeout(abort, completionTimeoutMs);
6235
+ timer.unref?.();
6236
+ for (const message of selection.messages) {
6237
+ const unsubscribe = Updates.subscribeTelegramDeferredSourceCompletion(message, observe);
6238
+ if (unsubscribe)
6239
+ subscriptions.push(unsubscribe);
6240
+ }
6241
+ signal?.addEventListener("abort", abort, { once: true });
6242
+ if (signal?.aborted || !current())
6243
+ abort();
6244
+ else if (coordinator.observeCommandCompletion() === "released")
6245
+ finish("released");
6246
+ });
6247
+ }
5986
6248
  // Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
5987
6249
  // still be pending while every attempt samples current work afresh.
5988
6250
  const row = outcome === "protected"
@@ -6047,6 +6309,12 @@ export function createTelegramInboundRouteRuntime(deps) {
6047
6309
  : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
6048
6310
  return true;
6049
6311
  }
6312
+ if (!parsed.useNewSlot &&
6313
+ isTemporaryReroute(pending) &&
6314
+ !resolvePendingRerouteForwardSource(pending)) {
6315
+ await deps.answerCallbackQuery(query.id, "Source copy unavailable; not routing");
6316
+ return true;
6317
+ }
6050
6318
  if (!(await selectPendingDestination()))
6051
6319
  return true;
6052
6320
  if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
@@ -6130,10 +6398,22 @@ export function createTelegramInboundRouteRuntime(deps) {
6130
6398
  await deps.answerCallbackQuery(query.id, delivered ? "Restored" : "Restore unconfirmed; not resending");
6131
6399
  return true;
6132
6400
  }
6401
+ let assertRerouteCurrent = assertExecutionCurrent;
6402
+ if (isTemporaryReroute(pending)) {
6403
+ const confirmed = await publishPendingRerouteCopies(pending, record, ctx, assertExecutionCurrent, !!record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord);
6404
+ if (!confirmed) {
6405
+ await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
6406
+ ? "Publication unconfirmed; not resending"
6407
+ : "Publication unavailable; input kept");
6408
+ return true;
6409
+ }
6410
+ assertRerouteCurrent = confirmed;
6411
+ }
6412
+ assertRerouteCurrent();
6133
6413
  if (record.instanceId &&
6134
6414
  record.instanceId !== currentInstanceId &&
6135
6415
  !isCurrentLeaderRecord) {
6136
- const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertExecutionCurrent);
6416
+ const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertRerouteCurrent);
6137
6417
  if (!allForwarded) {
6138
6418
  await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
6139
6419
  ? "Delivery unconfirmed; not resending"
@@ -6144,11 +6424,16 @@ export function createTelegramInboundRouteRuntime(deps) {
6144
6424
  }
6145
6425
  // Local handlers and queue admission consume the same one-time group grant as follower forwarding.
6146
6426
  if (isTemporaryReroute(pending) &&
6427
+ !pending.rerouteCopies &&
6147
6428
  !issueTemporaryThreadForward(pending, ctx)) {
6148
6429
  await deps.answerCallbackQuery(query.id, "Delivery unconfirmed; not resending");
6149
6430
  return true;
6150
6431
  }
6151
- await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
6432
+ const localMessages = pending.rerouteCopies
6433
+ ? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
6434
+ : reroutedMessages;
6435
+ await dispatchPendingRerouteMessages(pending, localMessages, ctx);
6436
+ assertRerouteCurrent();
6152
6437
  pending.messages = [];
6153
6438
  return completeRoutedReroute();
6154
6439
  };
@@ -7325,6 +7610,14 @@ export function createTelegramInboundRouteRuntime(deps) {
7325
7610
  const tabCopyId = openedNow
7326
7611
  ? await moveAllTabSourceIntoTab(pending, target, current)
7327
7612
  : undefined;
7613
+ if (!current()) {
7614
+ removePendingReroute(rerouteId);
7615
+ throw new Error("Temporary Thread source presentation authority changed.");
7616
+ }
7617
+ if (openedNow &&
7618
+ !pending.allTabCopiesForwarded &&
7619
+ pending.allTabCopies.length === held.length)
7620
+ pending.allTabSourceRetained = true;
7328
7621
  try {
7329
7622
  if (!deps.sendInteractiveMessage)
7330
7623
  throw new Error("Temporary Thread chooser publication is unavailable.");
@@ -6,7 +6,7 @@
6
6
  */
7
7
  /** UI copy is allowlisted; raw exception text belongs only in redacted diagnostics. */
8
8
  export declare function formatTelegramConnectionFailure(error: unknown): string;
9
- export type TelegramStatusQueueLane = "control" | "priority" | "default";
9
+ export type TelegramStatusQueueLane = "control" | "steer" | "priority" | "default";
10
10
  export interface TelegramUsageStats {
11
11
  totalInput: number;
12
12
  totalOutput: number;
@@ -345,7 +345,6 @@ export interface TelegramBridgeStatusRuntimeDeps<TQueueItem extends {
345
345
  hasPendingModelSwitch: () => boolean;
346
346
  getQueuedItems: () => TQueueItem[];
347
347
  getQueuedItemCount?: (items: TQueueItem[]) => number;
348
- formatQueuedStatus: (items: TQueueItem[]) => string;
349
348
  getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
350
349
  getRuntimeLockState?: () => string;
351
350
  getBusRole?: () => TelegramBridgeBusRole | undefined;
@@ -605,9 +605,7 @@ function getTelegramStatusBarLabel(state) {
605
605
  }
606
606
  export function buildTelegramStatusBarText(theme, state) {
607
607
  const label = theme.fg("accent", getTelegramStatusBarLabel(state));
608
- const queued = state.queuedStatus
609
- ? theme.fg("warning", state.queuedStatus)
610
- : "";
608
+ const queued = state.queuedStatus ? theme.fg("warning", state.queuedStatus) : "";
611
609
  if (!state.hasBotToken)
612
610
  return `${label} ${theme.fg("muted", "not configured")}${queued}`;
613
611
  if (state.pollingStopReason === "persistent-conflict" &&
@@ -949,6 +947,7 @@ function buildTelegramInboundWorkerDiagnosticLines(worker) {
949
947
  function countTelegramQueueLanes(items) {
950
948
  const counts = {
951
949
  control: 0,
950
+ steer: 0,
952
951
  priority: 0,
953
952
  default: 0,
954
953
  };
@@ -957,9 +956,9 @@ function countTelegramQueueLanes(items) {
957
956
  return counts;
958
957
  }
959
958
  function buildTelegramBridgeCompactStatusLines(state) {
960
- const { control: controlQueueCount, priority: priorityQueueCount, default: defaultQueueCount, } = countTelegramQueueLanes(state.queuedItems);
959
+ const { control: controlQueueCount, priority: priorityQueueCount, default: defaultQueueCount, steer: steerQueueCount, } = countTelegramQueueLanes(state.queuedItems);
961
960
  const queueLine = `- queued turns: ${state.queuedItems.length}${state.queuedItems.length > 0
962
- ? ` (control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
961
+ ? ` (control=${controlQueueCount}${steerQueueCount > 0 ? `, steer=${steerQueueCount}` : ""}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
963
962
  : ""}`;
964
963
  const executionState = state.pendingDispatch
965
964
  ? "pending dispatch"
@@ -1017,7 +1016,7 @@ export function buildTelegramBridgeStatusLines(state, options = {}) {
1017
1016
  return buildTelegramBridgeCompactStatusLines(state);
1018
1017
  }
1019
1018
  function buildTelegramBridgeDiagnosticStatusLines(state) {
1020
- const { control: controlQueueCount, priority: priorityQueueCount, default: defaultQueueCount, } = countTelegramQueueLanes(state.queuedItems);
1019
+ const { control: controlQueueCount, priority: priorityQueueCount, default: defaultQueueCount, steer: steerQueueCount, } = countTelegramQueueLanes(state.queuedItems);
1021
1020
  return [
1022
1021
  "connection:",
1023
1022
  `- bot: ${formatTelegramBridgeBotStatus(state)}`,
@@ -1063,7 +1062,7 @@ function buildTelegramBridgeDiagnosticStatusLines(state) {
1063
1062
  "",
1064
1063
  "queue:",
1065
1064
  `- queued turns: ${state.queuedItems.length}`,
1066
- `- lanes: control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
1065
+ `- lanes: control=${controlQueueCount}${steerQueueCount > 0 ? `, steer=${steerQueueCount}` : ""}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
1067
1066
  ...buildTelegramBusFollowerLines(state),
1068
1067
  ...buildTelegramLocalBusLines(state, { verbose: true }),
1069
1068
  ...buildTelegramTopicTargetDiagnosticLines(state),
@@ -6,7 +6,7 @@
6
6
  import { getTelegramTargetKey } from "./target.js";
7
7
  import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError, } from "./telegram-api.js";
8
8
  import * as ThreadReconciler from "./thread-reconciler.js";
9
- import { commitTelegramWorkspaceProvisionBinding, createTelegramCleanupTargetProtection, getTelegramTargetFromApiBody, getTelegramThreadOwnerKey, isSameTelegramProcessInstance, isTelegramTopicTargetStaleError, provisionOwnBusTopic, } from "./threads.js";
9
+ import { commitTelegramWorkspaceProvisionBinding, createTelegramCleanupTargetProtection, getTelegramTargetFromApiBody, getTelegramThreadOwnerKey, isSameTelegramProcessInstance, isTelegramTopicTargetStaleError, provisionOwnBusTopic, resetTelegramConnectionProvisioning, } from "./threads.js";
10
10
  import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
11
11
  import { normalizeTelegramWorkspacePath } from "./workspace-identity.js";
12
12
  import { TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.js";
@@ -463,11 +463,21 @@ export async function ensureTelegramLeaderThreadBinding(deps) {
463
463
  ...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
464
464
  };
465
465
  const leaderProfileKey = getTelegramThreadOwnerKey(leaderOwner);
466
+ if (deps.forceFreshUnnamed) {
467
+ await resetTelegramConnectionProvisioning({
468
+ store: deps.topicTargetStore, owner: "leader", instanceId: deps.instanceId,
469
+ profileKey: leaderProfileKey, isCurrent: () => {
470
+ assertLeaderEpoch("connect-recovery");
471
+ return true;
472
+ }, recordEvent: deps.recordEvent,
473
+ });
474
+ }
466
475
  const legacyLeaderRecord = deps.topicTargetStore.getByProfileKey(leaderProfileKey);
467
476
  let capacityUnavailable = false;
468
477
  const workspaceIdentity = normalizedLeaderCwd
469
478
  ? deps.topicTargetStore.claimWorkspaceIdentity(normalizedLeaderCwd, deps.instanceId, legacyLeaderRecord?.instanceId, {
470
479
  sessionId: deps.sessionId,
480
+ provisionProfileKey: leaderProfileKey,
471
481
  onCapacityUnavailable() {
472
482
  capacityUnavailable = true;
473
483
  },
@@ -495,6 +495,7 @@ export interface TelegramTopicTargetStore {
495
495
  } | undefined;
496
496
  listReservations: () => TelegramThreadReservation[];
497
497
  listPendingProvisions: () => TelegramThreadPendingProvision[];
498
+ resetPendingProvisionForConnect: (expected: TelegramThreadPendingProvision, isCurrent: () => boolean) => Promise<boolean>;
498
499
  listPendingCleanups: () => TelegramThreadCleanupIntent[];
499
500
  listSyncObservations: () => TelegramTopicSyncObservation[];
500
501
  reserveThread: (reservation: TelegramThreadReservation) => void;
@@ -559,6 +560,7 @@ export interface TelegramTopicTargetStore {
559
560
  claimWorkspaceIdentity: (cwd: string, instanceId: string, previousInstanceId?: string, options?: {
560
561
  existingBindingOnly?: boolean;
561
562
  sessionId?: string;
563
+ provisionProfileKey?: string;
562
564
  onCapacityUnavailable?: () => void;
563
565
  }) => TelegramWorkspaceBindingIdentity | undefined;
564
566
  releaseWorkspaceClaim: (instanceId: string) => boolean;
@@ -957,4 +959,13 @@ export declare function getTelegramTargetFromApiBody(body: unknown): (TelegramTa
957
959
  export declare function isTelegramTopicTargetStaleError(error: unknown): boolean;
958
960
  export declare function isTelegramTopicModeUnavailableError(error: unknown): boolean;
959
961
  export declare function createTelegramTopicTargetRenamer(deps: TelegramTopicTargetRenamerDeps): (request: TelegramTopicTargetRenameRequest) => Promise<TelegramTopicTargetRecord | undefined>;
962
+ /** Caller holds profile Workspace admission; only an explicit connect grants a fresh creation attempt. */
963
+ export declare function resetTelegramConnectionProvisioning(input: {
964
+ store: TelegramTopicTargetStore;
965
+ owner: TelegramThreadPendingProvision["owner"];
966
+ instanceId: string;
967
+ profileKey: string;
968
+ isCurrent: () => boolean;
969
+ recordEvent: (category: string, message: unknown, details?: Record<string, unknown>) => void;
970
+ }): Promise<void>;
960
971
  export declare function createTelegramTopicTargetProvisioner(deps: TelegramTopicTargetProvisionerDeps): (request: TelegramTopicTargetProvisionRequest) => Promise<TelegramTopicTargetProvisionResult>;