@llblab/pi-telegram 0.55.0 → 0.56.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 (50) hide show
  1. package/AGENTS.md +3 -2
  2. package/BACKLOG.md +4 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +27 -18
  5. package/dist/lib/bindings.d.ts +8 -4
  6. package/dist/lib/bindings.js +41 -1
  7. package/dist/lib/bus-follower.d.ts +1 -0
  8. package/dist/lib/bus-follower.js +5 -1
  9. package/dist/lib/bus-leader.js +9 -1
  10. package/dist/lib/bus.d.ts +5 -0
  11. package/dist/lib/bus.js +17 -6
  12. package/dist/lib/extension.js +14 -2
  13. package/dist/lib/lifecycle.d.ts +2 -1
  14. package/dist/lib/lifecycle.js +28 -20
  15. package/dist/lib/menu-queue.d.ts +1 -0
  16. package/dist/lib/menu-queue.js +102 -99
  17. package/dist/lib/pi.d.ts +3 -2
  18. package/dist/lib/pi.js +3 -0
  19. package/dist/lib/polling.js +3 -1
  20. package/dist/lib/queue.d.ts +57 -5
  21. package/dist/lib/queue.js +313 -25
  22. package/dist/lib/routing.d.ts +1 -1
  23. package/dist/lib/routing.js +261 -29
  24. package/dist/lib/status.d.ts +1 -2
  25. package/dist/lib/status.js +6 -7
  26. package/dist/lib/turns.d.ts +4 -0
  27. package/dist/lib/turns.js +6 -0
  28. package/dist/lib/updates.d.ts +10 -6
  29. package/dist/lib/updates.js +20 -4
  30. package/dist/package.json +1 -1
  31. package/docs/README.md +1 -1
  32. package/docs/architecture.md +17 -14
  33. package/docs/callback-namespaces.md +4 -0
  34. package/docs/multi-instance-bus.md +4 -2
  35. package/docs/ui-style.md +14 -10
  36. package/lib/bindings.ts +52 -3
  37. package/lib/bus-follower.ts +5 -0
  38. package/lib/bus-leader.ts +9 -0
  39. package/lib/bus.ts +20 -3
  40. package/lib/extension.ts +14 -1
  41. package/lib/lifecycle.ts +34 -28
  42. package/lib/menu-queue.ts +116 -162
  43. package/lib/pi.ts +8 -0
  44. package/lib/polling.ts +3 -1
  45. package/lib/queue.ts +328 -33
  46. package/lib/routing.ts +299 -29
  47. package/lib/status.ts +7 -7
  48. package/lib/turns.ts +7 -0
  49. package/lib/updates.ts +24 -10
  50. package/package.json +1 -1
@@ -209,7 +209,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
209
209
  canUsePreDispatchRebind?: (recipient?: Threads.TelegramWorkspaceRestoreRecipient) => boolean;
210
210
  /** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
211
211
  inspectTemporaryThreadSources?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[], ownInputs?: readonly Threads.TelegramTemporaryThreadInput[]) => readonly number[] | undefined;
212
- /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 1000 ms. */
212
+ /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
213
213
  temporaryThreadCleanupDelayMs?: number;
214
214
  /** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
215
215
  liveRebindCleanupSchedule?: {
@@ -1331,13 +1331,20 @@ export function createTelegramInboundRouteRuntime(deps) {
1331
1331
  requestDispatchNextQueuedTelegramTurn(ctx);
1332
1332
  },
1333
1333
  });
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
- }));
1334
+ const cloneTelegramMessagesForThread = (messages, threadId, copies) => {
1335
+ return messages.map((message) => {
1336
+ const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
1337
+ const copyId = copies && ids.length === 1 ? copies.get(ids[0]) : undefined;
1338
+ if (copies && (!Number.isSafeInteger(copyId) || copyId <= 0))
1339
+ throw new Error("Reroute destination copy identity is unavailable.");
1340
+ const routed = {
1341
+ ...message,
1342
+ message_id: copyId ?? 0,
1343
+ message_thread_id: threadId,
1344
+ reply_to_message: undefined,
1345
+ };
1346
+ return Updates.carryTelegramUpdateExecutionFence(message, copies ? Turns.withTelegramTurnReplyAnchor(routed) : routed);
1347
+ });
1341
1348
  };
1342
1349
  /** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
1343
1350
  const hasPendingRerouteForTarget = (target, own) => [...pendingUnboundReroutes.values()].some((pending) => pending.sourceTarget.chatId === target.chatId &&
@@ -1603,10 +1610,12 @@ export function createTelegramInboundRouteRuntime(deps) {
1603
1610
  return !acceptedWorkClear;
1604
1611
  };
1605
1612
  /** 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) => {
1613
+ const deleteAllTabMessages = async (chatId, messageIds, isCurrent) => {
1607
1614
  if (!deps.deleteMessage)
1608
1615
  return;
1609
1616
  for (const messageId of messageIds) {
1617
+ if (isCurrent?.() === false)
1618
+ return;
1610
1619
  try {
1611
1620
  await deps.deleteMessage(chatId, messageId);
1612
1621
  }
@@ -1620,13 +1629,14 @@ export function createTelegramInboundRouteRuntime(deps) {
1620
1629
  }
1621
1630
  };
1622
1631
  /** 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) ?? []);
1632
+ const deleteAllTabCopies = (pending, isCurrent) => deleteAllTabMessages(pending.sourceTarget.chatId, pending.allTabCopies?.splice(0) ?? [], isCurrent);
1624
1633
  /**
1625
1634
  * Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
1626
1635
  * holds its forwarded copy. Without one it stays where the operator can still read it.
1627
1636
  */
1628
1637
  const releaseAllTabCopiesIntoSurvivingTab = (pending) => {
1629
- if (pending.allTabCopiesForwarded)
1638
+ if (pending.allTabCopiesForwarded &&
1639
+ isDeepStrictEqual(pending.allTabCopiesForwarded.target, pending.sourceTarget))
1630
1640
  return deleteAllTabCopies(pending);
1631
1641
  pending.allTabCopies?.splice(0);
1632
1642
  return Promise.resolve();
@@ -1640,39 +1650,109 @@ export function createTelegramInboundRouteRuntime(deps) {
1640
1650
  const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
1641
1651
  if (messageIds.length === 0 || !deps.callApi || !isCurrent())
1642
1652
  return;
1653
+ const copyTarget = { ...target };
1643
1654
  const route = {
1644
- chat_id: target.chatId,
1645
- message_thread_id: target.threadId,
1655
+ chat_id: copyTarget.chatId,
1656
+ message_thread_id: copyTarget.threadId,
1646
1657
  from_chat_id: pending.sourceTarget.chatId,
1647
1658
  disable_notification: true,
1648
1659
  };
1660
+ const options = {
1661
+ maxAttempts: 1,
1662
+ retrySafety: "non-idempotent",
1663
+ assertAuthority() {
1664
+ if (!isCurrent())
1665
+ throw new Error("Temporary Thread source forwarding authority changed.");
1666
+ },
1667
+ };
1649
1668
  try {
1650
1669
  // An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
1651
1670
  const copies = messageIds.length === 1
1652
1671
  ? [
1653
- await deps.callApi("forwardMessage", {
1654
- ...route,
1655
- message_id: messageIds[0],
1656
- }),
1672
+ await deps.callApi("forwardMessage", { ...route, message_id: messageIds[0] }, options),
1657
1673
  ]
1658
- : await deps.callApi("forwardMessages", { ...route, message_ids: messageIds });
1674
+ : await deps.callApi("forwardMessages", { ...route, message_ids: messageIds }, options);
1675
+ if (!isCurrent())
1676
+ return;
1659
1677
  const copyIds = Array.isArray(copies)
1660
1678
  ? copies.map((copy) => copy?.message_id)
1661
1679
  : [];
1662
1680
  if (copyIds.length !== messageIds.length ||
1663
- !copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id)))
1681
+ !copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
1682
+ new Set(copyIds).size !== copyIds.length)
1664
1683
  return;
1665
- pending.allTabCopiesForwarded = true;
1666
- await deleteAllTabCopies(pending);
1667
- return copyIds[0];
1684
+ pending.allTabCopiesForwarded = {
1685
+ target: copyTarget,
1686
+ sourceMessageIds: messageIds,
1687
+ messageIds: copyIds,
1688
+ };
1689
+ await deleteAllTabCopies(pending, isCurrent);
1690
+ if (!isCurrent())
1691
+ return;
1692
+ return pending.allTabCopiesForwarded.messageIds[0];
1668
1693
  }
1669
1694
  catch (error) {
1670
- deps.recordRuntimeEvent?.("routing", error, {
1671
- phase: "temporary-thread-source-forward",
1672
- });
1695
+ if (isCurrent())
1696
+ deps.recordRuntimeEvent?.("routing", error, {
1697
+ phase: "temporary-thread-source-forward",
1698
+ });
1673
1699
  return;
1674
1700
  }
1675
1701
  };
1702
+ /** Read original source addresses, not warm target rewrites; unknown copy provenance never grants a Reroute. */
1703
+ const resolvePendingRerouteForwardSource = (pending) => {
1704
+ try {
1705
+ const originals = pending.messages.map((message) => {
1706
+ const snapshot = Updates.inspectTelegramDeferredSourceSnapshot(message);
1707
+ const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
1708
+ const original = snapshot?.update.message;
1709
+ if (!snapshot ||
1710
+ ids.length !== 1 ||
1711
+ snapshot.source.updateId !== ids[0] ||
1712
+ snapshot.update.update_id !== ids[0] ||
1713
+ Object.keys(snapshot.update).some((key) => key !== "update_id" && key !== "message") ||
1714
+ snapshot.source.journalBindingKey !== deps.getAdmissionJournalBinding?.() ||
1715
+ !original ||
1716
+ original.chat?.type !== "private" ||
1717
+ original.chat.id !== pending.sourceTarget.chatId ||
1718
+ original.from?.id !== deps.configStore.getAllowedUserId() ||
1719
+ original.from?.is_bot !== false ||
1720
+ !Number.isSafeInteger(original.message_id) ||
1721
+ original.message_id <= 0)
1722
+ return undefined;
1723
+ return { message: original, updateId: ids[0] };
1724
+ });
1725
+ if (originals.length === 0 || originals.some((source) => !source))
1726
+ return;
1727
+ originals.sort((a, b) => a.message.message_id - b.message.message_id);
1728
+ const sourceMessageIds = originals.map((source) => source.message.message_id);
1729
+ const sourceUpdateIds = originals.map((source) => source.updateId);
1730
+ if (new Set(sourceMessageIds).size !== sourceMessageIds.length ||
1731
+ new Set(sourceUpdateIds).size !== sourceUpdateIds.length)
1732
+ return;
1733
+ const copied = pending.allTabCopiesForwarded;
1734
+ if (copied) {
1735
+ if (!isDeepStrictEqual(copied.target, pending.sourceTarget) ||
1736
+ !isDeepStrictEqual(copied.sourceMessageIds, sourceMessageIds) ||
1737
+ copied.messageIds.length !== sourceMessageIds.length ||
1738
+ !copied.messageIds.every((id) => Number.isSafeInteger(id) && id > 0) ||
1739
+ new Set(copied.messageIds).size !== copied.messageIds.length)
1740
+ return;
1741
+ return { chatId: copied.target.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...copied.messageIds] };
1742
+ }
1743
+ const nativeOriginals = originals.every((source) => typeof pending.sourceTarget.threadId === "number" &&
1744
+ source.message.message_thread_id === pending.sourceTarget.threadId);
1745
+ const retainedAllOriginals = pending.allTabSourceRetained === true &&
1746
+ originals.every((source) => source.message.message_thread_id === undefined) &&
1747
+ isDeepStrictEqual([...(pending.allTabCopies ?? [])].sort((a, b) => a - b), sourceMessageIds);
1748
+ if (nativeOriginals || retainedAllOriginals)
1749
+ return { chatId: pending.sourceTarget.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...sourceMessageIds] };
1750
+ }
1751
+ catch (error) {
1752
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-source-proof" });
1753
+ }
1754
+ return undefined;
1755
+ };
1676
1756
  const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
1677
1757
  const chatId = query.message?.chat?.id;
1678
1758
  const messageId = query.message?.message_id;
@@ -4076,7 +4156,24 @@ export function createTelegramInboundRouteRuntime(deps) {
4076
4156
  wake(reason);
4077
4157
  };
4078
4158
  const onSessionSettled = (ctx) => wakeLiveRebindCleanup(ctx, "session-settled");
4079
- const onDeliverySettled = (ctx) => wakeLiveRebindCleanup(ctx, "delivery-drained");
4159
+ const onDeliverySettled = (ctx) => {
4160
+ wakeLiveRebindCleanup(ctx, "delivery-drained");
4161
+ const cap = captureTemporaryThreadAuthority(ctx);
4162
+ if (!cap?.isCurrent())
4163
+ return;
4164
+ try {
4165
+ for (const entry of cap.store.listTemporaryThreads()) {
4166
+ if (!cap.isCurrent())
4167
+ return;
4168
+ if (entry.phase === "created" && entry.target && !entry.cleanupIssued &&
4169
+ Threads.isTelegramTemporaryThreadFullyResolved(entry))
4170
+ scheduleTemporaryThreadCleanup(entry.target, ctx);
4171
+ }
4172
+ }
4173
+ catch (error) {
4174
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-drain" });
4175
+ }
4176
+ };
4080
4177
  const restoreWorkspace = deps.workspaceRestoreRecipient
4081
4178
  ? async (input) => {
4082
4179
  const ports = deps.workspaceRestoreRecipient;
@@ -4532,6 +4629,92 @@ export function createTelegramInboundRouteRuntime(deps) {
4532
4629
  return false;
4533
4630
  }
4534
4631
  };
4632
+ /** Publish display copies under the native one-shot grant; positive output is not input/source settlement. */
4633
+ const publishPendingRerouteCopies = async (pending, record, ctx, assertExecutionCurrent, foreign) => {
4634
+ const source = resolvePendingRerouteForwardSource(pending);
4635
+ const target = { ...record.target };
4636
+ const recordIdentity = (value) => value && ({
4637
+ target: value.target, profileKey: value.profileKey, owner: value.owner,
4638
+ instanceId: value.instanceId, slot: value.slot, createdAtMs: value.createdAtMs,
4639
+ });
4640
+ const bindingIdentity = () => deps.threadStore.listWorkspaceBindings()
4641
+ .filter((value) => isDeepStrictEqual(value.target, target))
4642
+ .map((value) => ({ bindingKey: value.bindingKey, sessionId: value.sessionId, sessionKey: value.sessionKey,
4643
+ cwd: value.cwd, workspaceKey: value.workspaceKey, instanceSlot: value.instanceSlot, slot: value.slot,
4644
+ target: value.target }));
4645
+ const expectedRecord = structuredClone(recordIdentity(record));
4646
+ const expectedBindings = structuredClone(bindingIdentity());
4647
+ const ownership = deps.getTargetOwnership?.(target);
4648
+ if (!source || !deps.callApi || expectedBindings.length > 1 ||
4649
+ (foreign && (!ownership || ownership.instanceId !== record.instanceId ||
4650
+ !ownership.ownerGeneration || !ownership.recipientBindingKey)))
4651
+ return;
4652
+ const expectedOwnership = ownership && structuredClone(ownership);
4653
+ const cap = captureTemporaryThreadAuthority(ctx, () => {
4654
+ try {
4655
+ assertExecutionCurrent();
4656
+ const current = getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.())
4657
+ .find((value) => isDeepStrictEqual(value.target, target));
4658
+ return isDeepStrictEqual(recordIdentity(current), expectedRecord) &&
4659
+ isDeepStrictEqual(bindingIdentity(), expectedBindings) &&
4660
+ (!foreign || Bus.isTelegramBusForwardOwnershipCurrent(expectedOwnership, deps.getTargetOwnership?.(target)));
4661
+ }
4662
+ catch {
4663
+ return false;
4664
+ }
4665
+ });
4666
+ if (!cap?.isCurrent())
4667
+ return;
4668
+ const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
4669
+ if (!temporary || !issueTemporaryThreadForward(pending, ctx))
4670
+ return;
4671
+ pending.phase = { kind: "forward-unknown" };
4672
+ const assertRecipientCurrent = () => {
4673
+ assertExecutionCurrent();
4674
+ if (!cap.isCurrent())
4675
+ throw new Error("Reroute publication recipient authority changed.");
4676
+ };
4677
+ const assertPublicationCurrent = () => {
4678
+ assertRecipientCurrent();
4679
+ const entry = cap.store.listTemporaryThreads().find((value) => value.token === temporary.token);
4680
+ const ids = [...source.sourceUpdateIds].sort((a, b) => a - b);
4681
+ if (!entry || entry.phase !== "created" || entry.cleanupIssued ||
4682
+ entry.operatorUserId !== cap.operatorUserId ||
4683
+ !isDeepStrictEqual(entry.executor, cap.authority.executor) ||
4684
+ !isDeepStrictEqual(entry.target, pending.sourceTarget) ||
4685
+ !entry.forwardedInputs?.some((input) => input.journalBindingKey === cap.journalBindingKey &&
4686
+ isDeepStrictEqual(input.updateIds, ids)) ||
4687
+ !isDeepStrictEqual(resolvePendingRerouteForwardSource(pending), source))
4688
+ throw new Error("Reroute publication source authority changed.");
4689
+ };
4690
+ try {
4691
+ assertPublicationCurrent();
4692
+ // Bot API requires ascending source IDs. Preserve the source-UID association even if copy IDs reorder them.
4693
+ const order = source.messageIds.map((_, index) => index)
4694
+ .sort((a, b) => source.messageIds[a] - source.messageIds[b]);
4695
+ const messageIds = order.map((index) => source.messageIds[index]);
4696
+ const body = { chat_id: target.chatId, message_thread_id: target.threadId,
4697
+ from_chat_id: source.chatId, disable_notification: true };
4698
+ const options = { maxAttempts: 1, retrySafety: "non-idempotent", assertAuthority: assertPublicationCurrent };
4699
+ const copies = messageIds.length === 1
4700
+ ? [await deps.callApi("forwardMessage", { ...body, message_id: messageIds[0] }, options)]
4701
+ : await deps.callApi("forwardMessages", { ...body, message_ids: messageIds }, options);
4702
+ assertPublicationCurrent();
4703
+ const copyIds = Array.isArray(copies) ? copies.map((copy) => copy?.message_id) : [];
4704
+ if (copyIds.length !== messageIds.length ||
4705
+ !copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
4706
+ new Set(copyIds).size !== copyIds.length)
4707
+ return;
4708
+ pending.rerouteCopies = { target, bySourceUpdateId: new Map(order.map((index, position) => [source.sourceUpdateIds[index], copyIds[position]])) };
4709
+ pending.phase = { kind: "selected" };
4710
+ return assertRecipientCurrent;
4711
+ }
4712
+ catch (error) {
4713
+ if (cap.isCurrent())
4714
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-destination-forward" });
4715
+ return;
4716
+ }
4717
+ };
4535
4718
  const forwardPendingRerouteMessages = async (pending, instanceId, threadId, ctx, assertExecutionCurrent, recordForwardAcceptance) => {
4536
4719
  assertExecutionCurrent?.();
4537
4720
  const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
@@ -4545,11 +4728,14 @@ export function createTelegramInboundRouteRuntime(deps) {
4545
4728
  !liveOwnership.recipientBindingKey)
4546
4729
  return false;
4547
4730
  const ownership = structuredClone(liveOwnership);
4548
- const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
4731
+ const copies = pending.rerouteCopies;
4732
+ const published = !!copies && isDeepStrictEqual(copies.target, target);
4733
+ const messages = cloneTelegramMessagesForThread(pending.messages, threadId, published ? copies.bySourceUpdateId : undefined);
4549
4734
  // Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
4550
4735
  // even after restart. Any refusal or publication failure sends nothing and keeps the input held.
4551
4736
  if (!recordForwardAcceptance &&
4552
4737
  isTemporaryReroute(pending) &&
4738
+ !published &&
4553
4739
  !issueTemporaryThreadForward(pending, ctx)) {
4554
4740
  if (isTemporaryThreadForwardIssued(pending))
4555
4741
  pending.phase = { kind: "forward-unknown" };
@@ -4561,6 +4747,10 @@ export function createTelegramInboundRouteRuntime(deps) {
4561
4747
  message,
4562
4748
  ownership,
4563
4749
  ctx,
4750
+ assertAuthority() {
4751
+ assertExecutionCurrent?.();
4752
+ Updates.assertTelegramUpdateExecutionCurrent(message);
4753
+ },
4564
4754
  })));
4565
4755
  assertExecutionCurrent?.();
4566
4756
  if (!isDeepStrictEqual(deps.getTargetOwnership?.(target), ownership))
@@ -4719,6 +4909,17 @@ export function createTelegramInboundRouteRuntime(deps) {
4719
4909
  if (retry)
4720
4910
  restoreSettlementTasks.add(task);
4721
4911
  runTemporaryThreadCleanup(token, cap, reconcileExpiry)
4912
+ .then(() => {
4913
+ if (!cap.isCurrent())
4914
+ return;
4915
+ const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
4916
+ // A follower ACK can precede recipient receipt disposal. Reobserve a protected, unissued tab;
4917
+ // elapsed time never grants deletion, and an issued/unknown delete can never enter this path.
4918
+ if (retained?.phase === "created" && !retained.cleanupIssued &&
4919
+ Threads.isTelegramTemporaryThreadFullyResolved(retained) &&
4920
+ cap.store.inspectTemporaryThreadTarget(retained, cap.authority)?.kind === "temporary" && cap.isCurrent())
4921
+ queueTemporaryThreadCleanup(token, cap, 1000, reconcileExpiry, true);
4922
+ })
4722
4923
  .catch((error) => {
4723
4924
  deps.recordRuntimeEvent?.("routing", error, {
4724
4925
  phase: "temporary-thread-cleanup",
@@ -5244,7 +5445,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5244
5445
  for (const [id, pending] of pendingUnboundReroutes) {
5245
5446
  if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
5246
5447
  continue;
5247
- movedIntoTab ||= pending.allTabCopiesForwarded === true;
5448
+ movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
5248
5449
  removePendingReroute(id);
5249
5450
  if (signal.aborted ||
5250
5451
  deps.isContextActive?.(ctx) !== true ||
@@ -6047,6 +6248,12 @@ export function createTelegramInboundRouteRuntime(deps) {
6047
6248
  : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
6048
6249
  return true;
6049
6250
  }
6251
+ if (!parsed.useNewSlot &&
6252
+ isTemporaryReroute(pending) &&
6253
+ !resolvePendingRerouteForwardSource(pending)) {
6254
+ await deps.answerCallbackQuery(query.id, "Source copy unavailable; not routing");
6255
+ return true;
6256
+ }
6050
6257
  if (!(await selectPendingDestination()))
6051
6258
  return true;
6052
6259
  if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
@@ -6130,10 +6337,22 @@ export function createTelegramInboundRouteRuntime(deps) {
6130
6337
  await deps.answerCallbackQuery(query.id, delivered ? "Restored" : "Restore unconfirmed; not resending");
6131
6338
  return true;
6132
6339
  }
6340
+ let assertRerouteCurrent = assertExecutionCurrent;
6341
+ if (isTemporaryReroute(pending)) {
6342
+ const confirmed = await publishPendingRerouteCopies(pending, record, ctx, assertExecutionCurrent, !!record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord);
6343
+ if (!confirmed) {
6344
+ await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
6345
+ ? "Publication unconfirmed; not resending"
6346
+ : "Publication unavailable; input kept");
6347
+ return true;
6348
+ }
6349
+ assertRerouteCurrent = confirmed;
6350
+ }
6351
+ assertRerouteCurrent();
6133
6352
  if (record.instanceId &&
6134
6353
  record.instanceId !== currentInstanceId &&
6135
6354
  !isCurrentLeaderRecord) {
6136
- const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertExecutionCurrent);
6355
+ const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertRerouteCurrent);
6137
6356
  if (!allForwarded) {
6138
6357
  await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
6139
6358
  ? "Delivery unconfirmed; not resending"
@@ -6144,11 +6363,16 @@ export function createTelegramInboundRouteRuntime(deps) {
6144
6363
  }
6145
6364
  // Local handlers and queue admission consume the same one-time group grant as follower forwarding.
6146
6365
  if (isTemporaryReroute(pending) &&
6366
+ !pending.rerouteCopies &&
6147
6367
  !issueTemporaryThreadForward(pending, ctx)) {
6148
6368
  await deps.answerCallbackQuery(query.id, "Delivery unconfirmed; not resending");
6149
6369
  return true;
6150
6370
  }
6151
- await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
6371
+ const localMessages = pending.rerouteCopies
6372
+ ? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
6373
+ : reroutedMessages;
6374
+ await dispatchPendingRerouteMessages(pending, localMessages, ctx);
6375
+ assertRerouteCurrent();
6152
6376
  pending.messages = [];
6153
6377
  return completeRoutedReroute();
6154
6378
  };
@@ -7325,6 +7549,14 @@ export function createTelegramInboundRouteRuntime(deps) {
7325
7549
  const tabCopyId = openedNow
7326
7550
  ? await moveAllTabSourceIntoTab(pending, target, current)
7327
7551
  : undefined;
7552
+ if (!current()) {
7553
+ removePendingReroute(rerouteId);
7554
+ throw new Error("Temporary Thread source presentation authority changed.");
7555
+ }
7556
+ if (openedNow &&
7557
+ !pending.allTabCopiesForwarded &&
7558
+ pending.allTabCopies.length === held.length)
7559
+ pending.allTabSourceRetained = true;
7328
7560
  try {
7329
7561
  if (!deps.sendInteractiveMessage)
7330
7562
  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),
@@ -9,6 +9,10 @@ import { type TelegramVoiceReplyMode } from "./voice.ts";
9
9
  export declare const TELEGRAM_PREFIX = "[telegram]";
10
10
  /** A routed source keeps its unique message/file/group identity, but cannot quote an anchor in its former tab. */
11
11
  export declare function withoutTelegramTurnReplyAnchor<TMessage extends object>(message: TMessage): TMessage;
12
+ /** A confirmed same-target copy may replace the former-tab suppression on a warm input projection. */
13
+ export declare function withTelegramTurnReplyAnchor<TMessage extends {
14
+ message_id: number;
15
+ }>(message: TMessage): TMessage;
12
16
  export declare const TELEGRAM_GUEST_TURN_NOTE = "[guest] delivery: answer quickly with one concise, self-contained reply";
13
17
  export interface TelegramTurnTarget {
14
18
  chatId: number;
package/dist/lib/turns.js CHANGED
@@ -14,6 +14,12 @@ const REPLY_ANCHOR_SUPPRESSED = Symbol.for("@llblab/pi-telegram:reply-anchor-sup
14
14
  export function withoutTelegramTurnReplyAnchor(message) {
15
15
  return { ...message, [REPLY_ANCHOR_SUPPRESSED]: true };
16
16
  }
17
+ /** A confirmed same-target copy may replace the former-tab suppression on a warm input projection. */
18
+ export function withTelegramTurnReplyAnchor(message) {
19
+ if (!Number.isSafeInteger(message.message_id) || message.message_id <= 0)
20
+ throw new Error("Telegram reply anchor requires a positive message ID.");
21
+ return { ...message, [REPLY_ANCHOR_SUPPRESSED]: false };
22
+ }
17
23
  export const TELEGRAM_GUEST_TURN_NOTE = "[guest] delivery: answer quickly with one concise, self-contained reply";
18
24
  function getTelegramTurnTarget(message) {
19
25
  return Number.isInteger(message.message_thread_id)
@@ -22,10 +22,6 @@ export declare const TELEGRAM_PRIORITY_REACTIONS: readonly [{
22
22
  readonly id: 10;
23
23
  readonly name: "like";
24
24
  readonly emoji: "👍";
25
- }, {
26
- readonly id: 11;
27
- readonly name: "lightning";
28
- readonly emoji: "⚡";
29
25
  }, {
30
26
  readonly id: 12;
31
27
  readonly name: "heart";
@@ -34,7 +30,8 @@ export declare const TELEGRAM_PRIORITY_REACTIONS: readonly [{
34
30
  readonly id: 13;
35
31
  readonly name: "dove";
36
32
  readonly emoji: "🕊";
37
- }, {
33
+ }];
34
+ export declare const TELEGRAM_STEER_REACTIONS: readonly [{
38
35
  readonly id: 14;
39
36
  readonly name: "fire";
40
37
  readonly emoji: "🔥";
@@ -60,7 +57,7 @@ export declare const TELEGRAM_REMOVAL_REACTIONS: readonly [{
60
57
  readonly name: "wastebasket";
61
58
  readonly emoji: "🗑";
62
59
  }];
63
- export declare const TELEGRAM_PRIORITY_REACTION_EMOJIS: ("👍" | "⚡" | "❤" | "🕊" | "🔥")[];
60
+ export declare const TELEGRAM_PRIORITY_REACTION_EMOJIS: ("👍" | "❤" | "🕊")[];
64
61
  export declare const TELEGRAM_REMOVAL_REACTION_EMOJIS: ("👎" | "👻" | "💔" | "💩" | "🗑")[];
65
62
  export interface TelegramUpdateDeletion {
66
63
  deleted_business_messages?: {
@@ -141,6 +138,8 @@ export interface TelegramForeignOwnedUpdateForwarder<TContext, TReactionUpdate e
141
138
  message: TMessage;
142
139
  ownership: TelegramTargetOwnershipView;
143
140
  ctx: TContext;
141
+ /** Local issuance/ACK fence; implementations must not serialize it into payloads. */
142
+ assertAuthority?: () => void;
144
143
  }) => Promise<TelegramBusForeignUpdateSettlement> | TelegramBusForeignUpdateSettlement;
145
144
  forwardEditedMessage?: (input: {
146
145
  message: TMessage;
@@ -1317,6 +1316,7 @@ export interface TelegramQueueHandoffControlExecutionDeps<TContext> {
1317
1316
  export declare function createTelegramQueueHandoffControlExecutionFactory<TContext>(deps: TelegramQueueHandoffControlExecutionDeps<TContext>): (payload: TelegramControlQueueHandoffPayload) => PendingTelegramControlItem<TContext>["execute"];
1318
1317
  export interface TelegramQueueHandoffCoordinatorInput<TContext> {
1319
1318
  item: TelegramQueueItem<TContext>;
1319
+ steerSupported?: boolean;
1320
1320
  expectedOwner: TelegramUpdateJournalQueueOwner;
1321
1321
  recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
1322
1322
  handoffToken: string;
@@ -1356,6 +1356,8 @@ export declare function createTelegramQueueHandoffRecipientRuntime<TContext>(dep
1356
1356
  kind: "leader.offerQueueHandoff";
1357
1357
  }>, ctx: TContext) => Promise<TelegramQueueHandoffStageResult>;
1358
1358
  export interface TelegramQueueHandoffReconcilerDeps<TContext> {
1359
+ canHandoffSteerWithRecipient?: (recipient: TelegramBusFollowerView) => boolean;
1360
+ hasPendingDispatch?: () => boolean;
1359
1361
  ownsDirect: () => boolean;
1360
1362
  isFollowerRegistered: () => boolean;
1361
1363
  isBusEnabled: () => boolean;
@@ -1380,6 +1382,8 @@ export interface TelegramQueueHandoffReconcilerDeps<TContext> {
1380
1382
  recordFailure?: (error: unknown, details: Record<string, unknown>) => void;
1381
1383
  }
1382
1384
  export interface TelegramQueueHandoffReconciliationRuntimeAssemblyDeps<TContext> {
1385
+ canHandoffSteerWithRecipient?: (recipient: TelegramBusFollowerView) => boolean;
1386
+ hasPendingDispatch?: () => boolean;
1383
1387
  ownsDirect: () => boolean;
1384
1388
  isFollowerRegistered: () => boolean;
1385
1389
  isBusEnabled: () => boolean;