@llblab/pi-telegram 0.54.3 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +5 -3
  5. package/dist/lib/activity.d.ts +5 -1
  6. package/dist/lib/activity.js +31 -2
  7. package/dist/lib/bindings.d.ts +5 -1
  8. package/dist/lib/bindings.js +9 -2
  9. package/dist/lib/bus-api.d.ts +2 -2
  10. package/dist/lib/bus-api.js +7 -2
  11. package/dist/lib/bus-follower.d.ts +49 -3
  12. package/dist/lib/bus-follower.js +313 -146
  13. package/dist/lib/bus-leader.d.ts +3 -0
  14. package/dist/lib/bus-leader.js +99 -18
  15. package/dist/lib/bus.d.ts +5 -3
  16. package/dist/lib/bus.js +13 -10
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +56 -6
  20. package/dist/lib/lifecycle.d.ts +68 -0
  21. package/dist/lib/lifecycle.js +178 -58
  22. package/dist/lib/machine.d.ts +23 -0
  23. package/dist/lib/machine.js +37 -0
  24. package/dist/lib/media.d.ts +114 -28
  25. package/dist/lib/media.js +292 -119
  26. package/dist/lib/model.d.ts +54 -15
  27. package/dist/lib/model.js +98 -53
  28. package/dist/lib/polling.js +2 -0
  29. package/dist/lib/preview.d.ts +128 -113
  30. package/dist/lib/preview.js +448 -366
  31. package/dist/lib/queue.d.ts +2 -2
  32. package/dist/lib/queue.js +3 -3
  33. package/dist/lib/routing.d.ts +14 -2
  34. package/dist/lib/routing.js +546 -207
  35. package/dist/lib/runtime.d.ts +77 -11
  36. package/dist/lib/runtime.js +174 -92
  37. package/dist/lib/sections.js +0 -1
  38. package/dist/lib/telegram-api.d.ts +11 -9
  39. package/dist/lib/telegram-api.js +53 -4
  40. package/dist/lib/text-groups.d.ts +0 -26
  41. package/dist/lib/text-groups.js +51 -98
  42. package/dist/lib/thread-display.js +14 -6
  43. package/dist/lib/threads.d.ts +20 -5
  44. package/dist/lib/threads.js +111 -32
  45. package/dist/lib/turns.d.ts +2 -0
  46. package/dist/lib/turns.js +6 -1
  47. package/dist/lib/updates.d.ts +11 -4
  48. package/dist/lib/updates.js +54 -19
  49. package/dist/package.json +1 -1
  50. package/docs/activity.md +1 -1
  51. package/docs/architecture.md +16 -15
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +18 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +8 -6
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +13 -1
  58. package/lib/bus-api.ts +11 -3
  59. package/lib/bus-follower.ts +417 -203
  60. package/lib/bus-leader.ts +88 -14
  61. package/lib/bus.ts +17 -15
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +59 -9
  64. package/lib/lifecycle.ts +229 -55
  65. package/lib/machine.ts +60 -0
  66. package/lib/media.ts +438 -172
  67. package/lib/model.ts +169 -74
  68. package/lib/polling.ts +2 -0
  69. package/lib/preview.ts +715 -566
  70. package/lib/queue.ts +5 -5
  71. package/lib/routing.ts +644 -283
  72. package/lib/runtime.ts +260 -104
  73. package/lib/sections.ts +0 -2
  74. package/lib/telegram-api.ts +61 -8
  75. package/lib/text-groups.ts +75 -136
  76. package/lib/thread-display.ts +15 -4
  77. package/lib/threads.ts +130 -38
  78. package/lib/turns.ts +8 -1
  79. package/lib/updates.ts +58 -21
  80. package/package.json +1 -1
package/lib/bus-leader.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY,
27
27
  TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
28
28
  TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
29
+ TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND,
29
30
  TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT,
30
31
  TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE,
31
32
  TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
@@ -158,6 +159,8 @@ export interface TelegramBusLeaderTargetProvisionerDeps<TContext> {
158
159
 
159
160
  export interface TelegramBusFollowerTargetProvisionerDeps {
160
161
  getAllowedUserId: () => number | undefined;
162
+ getLiveFollowers?: () => readonly TelegramBusFollowerView[];
163
+ isFollowerProcessAlive?: (pid: number) => boolean;
161
164
  topicTargetStore: Threads.TelegramTopicTargetStore;
162
165
  callApi: <TResponse>(
163
166
  method: string,
@@ -569,12 +572,15 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
569
572
  });
570
573
  });
571
574
  };
572
- const beginStartupDisplayStabilization = () => {
575
+ /** Returns the stabilization this startup owns; a stop or a newer startup meanwhile makes it inert. */
576
+ const beginStartupDisplayStabilization = (): number => {
573
577
  cancelStartupDisplayStabilization();
574
578
  startupDisplaySettled = false;
579
+ return startupDisplayGeneration;
575
580
  };
576
- const armStartupDisplayReconciliation = () => {
577
- const generation = startupDisplayGeneration;
581
+ const armStartupDisplayReconciliation = (generation: number) => {
582
+ if (startupDisplayGeneration !== generation) return;
583
+ if (startupDisplayTimer) clearTimeout(startupDisplayTimer);
578
584
  const delayMs =
579
585
  deps.runtime.followerStaleAfterMs ?? TELEGRAM_BUS_FOLLOWER_STALE_AFTER_MS;
580
586
  startupDisplayTimer = setTimeout(() => {
@@ -673,6 +679,8 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
673
679
  });
674
680
  const provisionFollowerTarget = createTelegramBusFollowerTargetProvisioner({
675
681
  ...provisionerPorts,
682
+ getLiveFollowers: deps.runtime.followerRegistry.list,
683
+ isFollowerProcessAlive: deps.runtime.isFollowerProcessAlive,
676
684
  });
677
685
  const renameLeaderThreadAdmitted = async (
678
686
  threadName: string,
@@ -1388,14 +1396,18 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
1388
1396
  resetThreadNameAdmitted,
1389
1397
  captureWorkspaceExternalProtection: deps.captureWorkspaceExternalProtection,
1390
1398
  async startPolling(ctx: TContext) {
1391
- if (display) beginStartupDisplayStabilization();
1399
+ const stabilization = display
1400
+ ? beginStartupDisplayStabilization()
1401
+ : undefined;
1392
1402
  try {
1393
1403
  await runtime.startPolling(ctx);
1394
1404
  } catch (error) {
1395
- cancelStartupDisplayStabilization();
1405
+ if (stabilization === startupDisplayGeneration)
1406
+ cancelStartupDisplayStabilization();
1396
1407
  throw error;
1397
1408
  }
1398
- if (display) armStartupDisplayReconciliation();
1409
+ if (stabilization !== undefined)
1410
+ armStartupDisplayReconciliation(stabilization);
1399
1411
  },
1400
1412
  async stopPolling() {
1401
1413
  cancelStartupDisplayStabilization();
@@ -1535,12 +1547,35 @@ export function createTelegramBusFollowerTargetProvisioner(
1535
1547
  const chatId = deps.getAllowedUserId();
1536
1548
  if (typeof chatId !== "number") return registration.target;
1537
1549
  await deps.topicTargetStore.load();
1550
+ let restoredPredecessor: Threads.TelegramTopicTargetRecord | undefined;
1551
+ if (options?.existingWorkspaceBindingOnly && registration.cwd && registration.sessionId &&
1552
+ !registration.previousInstanceId && deps.getLiveFollowers && deps.isFollowerProcessAlive) {
1553
+ const identity = Threads.createTelegramWorkspaceBindingIdentity(registration.cwd, 0, registration.sessionId);
1554
+ const records = deps.topicTargetStore.list(), liveFollowers = deps.getLiveFollowers();
1555
+ const candidates = deps.topicTargetStore.listWorkspaceBindings()
1556
+ .filter(binding => identity && binding.cwd === identity.cwd && binding.sessionId === identity.sessionId &&
1557
+ (!registration.target || (binding.target.chatId === registration.target.chatId && binding.target.threadId === registration.target.threadId)))
1558
+ .flatMap(binding => {
1559
+ const matches = records.filter(record => record.status === "active" && record.owner?.kind === "manual-follower" &&
1560
+ record.target.chatId === binding.target.chatId && record.target.threadId === binding.target.threadId);
1561
+ return matches.length === 1 ? matches : [];
1562
+ })
1563
+ .filter(record => {
1564
+ if (liveFollowers.some(peer => peer.instanceId === record.instanceId ||
1565
+ (peer.target?.chatId === record.target.chatId && peer.target.threadId === record.target.threadId))) return false;
1566
+ // A retained status is not current execution proof. Only an absent actual runtime PID grants this claim handoff.
1567
+ const match = /^([1-9][0-9]*):[0-9]+$/.exec(record.instanceId ?? ""), pid = match ? Number(match[1]) : undefined;
1568
+ if (pid === undefined || !Number.isSafeInteger(pid)) return false;
1569
+ try { return deps.isFollowerProcessAlive!(pid) === false; } catch { return false; }
1570
+ });
1571
+ if (candidates.length === 1) restoredPredecessor = candidates[0];
1572
+ }
1538
1573
  let capacityUnavailable = false;
1539
1574
  const workspaceIdentity = registration.cwd
1540
1575
  ? deps.topicTargetStore.claimWorkspaceIdentity(
1541
1576
  registration.cwd,
1542
1577
  registration.instanceId,
1543
- registration.previousInstanceId,
1578
+ registration.previousInstanceId ?? restoredPredecessor?.instanceId,
1544
1579
  {
1545
1580
  existingBindingOnly: options?.existingWorkspaceBindingOnly === true,
1546
1581
  sessionId: registration.sessionId,
@@ -1603,6 +1638,7 @@ export function createTelegramBusFollowerTargetProvisioner(
1603
1638
  record.owner?.kind === "manual-follower" &&
1604
1639
  ((matchesCurrentIdentity && matchesRequestedTarget) ||
1605
1640
  matchesSessionHandoff ||
1641
+ (record.instanceId === restoredPredecessor?.instanceId && matchesRequestedTarget) ||
1606
1642
  matchesWorkspaceRecovery)
1607
1643
  );
1608
1644
  });
@@ -1815,16 +1851,14 @@ export function createTelegramBusFollowerTargetProvisioner(
1815
1851
  crossSessionReuse ||
1816
1852
  probeRequiredRecord ||
1817
1853
  recoverableTarget !== undefined;
1854
+ const retainedDisplayBinding = deps.topicTargetStore.getWorkspaceBindingByTarget(result.target, registration.sessionId);
1818
1855
  let connectedAnnouncement =
1819
1856
  !result.reused || requiresVisibilityProbe
1820
1857
  ? createTelegramBusInstanceLifecycleAnnouncement({
1821
1858
  target: result.target,
1822
1859
  threadName:
1823
1860
  result.displayTitle ??
1824
- (workspaceBinding?.target.chatId === result.target.chatId &&
1825
- workspaceBinding.target.threadId === result.target.threadId
1826
- ? workspaceBinding.displayTitle
1827
- : undefined) ??
1861
+ (retainedDisplayBinding?.cwd === workspaceIdentity?.cwd ? retainedDisplayBinding?.displayTitle : undefined) ??
1828
1862
  result.record.threadName,
1829
1863
  slot: result.record.slot,
1830
1864
  state: "connected",
@@ -1972,6 +2006,16 @@ export function createTelegramBusFollowerTargetProvisioner(
1972
2006
  },
1973
2007
  },
1974
2008
  );
2009
+ // Same-target reuse can retain a different slot than the initial claim.
2010
+ if (result.record.slot !== workspaceCommit.slot) {
2011
+ result = {
2012
+ ...result,
2013
+ record: deps.topicTargetStore.upsert({
2014
+ ...result.record,
2015
+ slot: workspaceCommit.slot,
2016
+ }),
2017
+ };
2018
+ }
1975
2019
  if (connectedAnnouncement && workspaceCommit.displayTitle) {
1976
2020
  connectedAnnouncement =
1977
2021
  createTelegramBusInstanceLifecycleAnnouncement({
@@ -2604,8 +2648,7 @@ export function createTelegramBusSelectedMenuDeliveryHandler(deps: {
2604
2648
  .find((value) => value.request.operationId === expected.operationId);
2605
2649
  if (
2606
2650
  !retained ||
2607
- retained.phase !== "released" ||
2608
- retained.cleanup ||
2651
+ !Threads.isTelegramLiveRebindInputAuthorized(retained) ||
2609
2652
  retained.request.source.updateIds.length !== 1 ||
2610
2653
  retained.recipient.kind !== "follower" ||
2611
2654
  !isDeepStrictEqual(retained.executor, executor) ||
@@ -2695,8 +2738,13 @@ export function createTelegramBusSelectedMenuDeliveryHandler(deps: {
2695
2738
  resolveJournal.call(workspace, live) !== recipient.journalBindingKey
2696
2739
  )
2697
2740
  throw new Error("Selected-menu authority changed.");
2741
+ const latest = store.listLiveRebindings().find(value => value.request.operationId === intent.request.operationId);
2742
+ if (!latest || !Threads.isTelegramLiveRebindInputAuthorized(latest) ||
2743
+ !isDeepStrictEqual({ ...latest, ...(intent.inputRelease ? { inputRelease: intent.inputRelease } : {}), revision: intent.revision, updatedAtMs: intent.updatedAtMs }, intent) ||
2744
+ (latest.inputRelease !== intent.inputRelease && !(intent.inputRelease === "issued" && latest.inputRelease === "accepted")))
2745
+ throw new Error("Selected-menu input grant changed.");
2698
2746
  let confirmed = false;
2699
- snapshot.call(threads, intent, (view) => {
2747
+ snapshot.call(threads, latest, (view) => {
2700
2748
  const bindings = (view.workspaceBindings ?? []).filter(
2701
2749
  (value) => value.bindingKey === intent.request.binding.bindingKey,
2702
2750
  );
@@ -2936,6 +2984,7 @@ export interface TelegramBusLeaderEnvelopeHandlerDeps {
2936
2984
  isCurrent: () => boolean,
2937
2985
  ) => Promise<void>;
2938
2986
  getThreadDisplayMode?: () => TelegramThreadDisplayMode;
2987
+ requiresPreDispatchRebindReader?: () => boolean;
2939
2988
  getCurrentLeaderEpoch?: () => number | string | undefined;
2940
2989
  getTelegramProfile?: () => string | undefined;
2941
2990
  getAllowedUserId?: () => number | undefined;
@@ -3385,6 +3434,8 @@ export function createTelegramBusLeaderEnvelopeHandler(
3385
3434
  local: deps.protocolIdentity,
3386
3435
  remote: envelope.registration.protocol,
3387
3436
  });
3437
+ const readerCompatible = () => deps.requiresPreDispatchRebindReader?.() !== true ||
3438
+ hasTelegramBusCapability(envelope.registration.protocol, TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND);
3388
3439
  const displayCompatible = () =>
3389
3440
  (deps.getThreadDisplayMode?.() ?? "names") === "names" ||
3390
3441
  (hasTelegramBusCapability(
@@ -3409,6 +3460,7 @@ export function createTelegramBusLeaderEnvelopeHandler(
3409
3460
  if (
3410
3461
  !compatibility.compatible ||
3411
3462
  !displayCompatible() ||
3463
+ !readerCompatible() ||
3412
3464
  (envelope.registration.cwd !== undefined &&
3413
3465
  envelope.registration.sessionId === undefined) ||
3414
3466
  (restoringWorkspace && !supportsWorkspaceAutoConnect)
@@ -3445,6 +3497,7 @@ export function createTelegramBusLeaderEnvelopeHandler(
3445
3497
  const telegramProfile = deps.getTelegramProfile?.();
3446
3498
  const operatorUserId = deps.getAllowedUserId?.();
3447
3499
  const registrationCurrent = (): boolean =>
3500
+ readerCompatible() &&
3448
3501
  (!deps.getCurrentLeaderEpoch ||
3449
3502
  deps.getCurrentLeaderEpoch() === leaderEpoch) &&
3450
3503
  (!deps.getTelegramProfile ||
@@ -4395,6 +4448,7 @@ export function createTelegramBusLeaderRuntime<TContext>(
4395
4448
  recordRuntimeEvent: deps.recordRuntimeEvent,
4396
4449
  applyThreadDisplayMode: deps.applyThreadDisplayMode,
4397
4450
  getThreadDisplayMode: deps.getThreadDisplayMode,
4451
+ requiresPreDispatchRebindReader: deps.requiresPreDispatchRebindReader,
4398
4452
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
4399
4453
  getTelegramProfile: deps.getTelegramProfile,
4400
4454
  getAllowedUserId: deps.getAllowedUserId,
@@ -4489,23 +4543,43 @@ export function createTelegramBusLeaderRuntime<TContext>(
4489
4543
  },
4490
4544
  handleEnvelope,
4491
4545
  });
4546
+ // The start that owns the endpoint and pruning. A stop clears it; a start that loses it mid-await undoes only what
4547
+ // nobody newer owns, so a stop during startup never leaves a stopped leader's endpoint listening.
4548
+ let activeStart: object | undefined;
4549
+ const releaseSupersededStart = async (): Promise<void> => {
4550
+ if (activeStart !== undefined) return;
4551
+ stopPruning();
4552
+ await localServer
4553
+ .stop()
4554
+ .catch((error) =>
4555
+ deps.recordRuntimeEvent?.("bus", error, { phase: "stop" }),
4556
+ );
4557
+ };
4492
4558
  return {
4493
4559
  routeQueueHandoff: (envelope) => routeQueueHandoffEnvelope(envelope),
4494
4560
  startPolling: async (ctx) => {
4561
+ const start = {};
4562
+ activeStart = start;
4495
4563
  // Replay durable cleanup before publishing the follower endpoint so a
4496
4564
  // replacement registration cannot reclaim a target while it is deleted.
4497
4565
  await deps.provisionLeaderTarget?.(ctx);
4566
+ if (activeStart !== start) return;
4498
4567
  await localServer.start();
4568
+ if (activeStart !== start) return releaseSupersededStart();
4499
4569
  startPruning();
4500
4570
  try {
4501
4571
  await deps.startPolling(ctx);
4502
4572
  } catch (error) {
4573
+ if (activeStart !== start) return releaseSupersededStart();
4574
+ activeStart = undefined;
4503
4575
  stopPruning();
4504
4576
  await localServer.stop();
4505
4577
  throw error;
4506
4578
  }
4579
+ if (activeStart !== start) return releaseSupersededStart();
4507
4580
  },
4508
4581
  stopPolling: async () => {
4582
+ activeStart = undefined;
4509
4583
  stopPruning();
4510
4584
  try {
4511
4585
  await deps.stopPolling();
package/lib/bus.ts CHANGED
@@ -156,6 +156,9 @@ export const TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT =
156
156
  "session-replacement-intent-v1" as const;
157
157
  export const TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE =
158
158
  "workspace-restore-v1" as const;
159
+ /** Shared Workspace readers understand independent pre-dispatch input/cleanup state. */
160
+ export const TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND = "live-rebind-pre-dispatch-reader-v2" as const;
161
+ export const TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION = "live-rebind-pre-dispatch-v1" as const;
159
162
  export const TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE =
160
163
  "live-thread-rebind-save-v1" as const;
161
164
  export const TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY =
@@ -665,12 +668,6 @@ function hasTelegramBusDeliveryMarker(
665
668
  );
666
669
  }
667
670
 
668
- export function markTelegramBusAggregateDelivery<
669
- T extends Record<string, unknown>,
670
- >(body: T): T {
671
- return { ...body, [TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD]: true };
672
- }
673
-
674
671
  export function markTelegramBusCrossTargetDelivery<
675
672
  T extends Record<string, unknown>,
676
673
  >(body: T): T {
@@ -787,7 +784,7 @@ export function isTelegramFollowerApiCallAllowed(input: {
787
784
  if (apiMethod === "setMyCommands")
788
785
  return isBotCommandRegistration(input.args[1]);
789
786
  if (apiMethod === "sendChatAction")
790
- return isTargetChatScoped(input.args[1]);
787
+ return isTargetScoped(input.args[1]);
791
788
  if (
792
789
  apiMethod === "sendMessage" &&
793
790
  hasTelegramBusDeliveryMarker(
@@ -795,8 +792,7 @@ export function isTelegramFollowerApiCallAllowed(input: {
795
792
  TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD,
796
793
  )
797
794
  ) {
798
- const body = input.args[1] as Record<string, unknown>;
799
- return body.message_thread_id === undefined && isTargetChatScoped(body);
795
+ return false;
800
796
  }
801
797
  if (
802
798
  (apiMethod === "sendMessage" || apiMethod === "sendRichMessage") &&
@@ -1156,7 +1152,7 @@ export type TelegramBusEnvelope = (
1156
1152
  recipientBindingKey: string;
1157
1153
  operationId: string;
1158
1154
  sourceUpdateIds: number[];
1159
- mode: "release" | "discard" | "observe" | "observe-command";
1155
+ mode: "release" | "discard" | "observe" | "observe-held" | "observe-command";
1160
1156
  oldTarget?: TelegramTarget & { threadId: number };
1161
1157
  preparedSource?: TelegramBusPreparedCommandSource;
1162
1158
  selectedCommand?: TelegramBusSelectedCommandInput;
@@ -1492,7 +1488,8 @@ export function parseTelegramBusEnvelope(
1492
1488
  ]) ||
1493
1489
  (Object.hasOwn(value, "preparedSource") && !selectedCommand) ||
1494
1490
  (value.mode === "observe-command" &&
1495
- (!selectedCommand || Object.hasOwn(value, "oldTarget")))
1491
+ (!selectedCommand || Object.hasOwn(value, "oldTarget"))) ||
1492
+ (value.mode === "observe-held" && (parseThreadTarget(value.oldTarget)?.threadId ?? 0) <= 0)
1496
1493
  )
1497
1494
  break;
1498
1495
  if (
@@ -1513,7 +1510,7 @@ export function parseTelegramBusEnvelope(
1513
1510
  : value.mode === "release" ||
1514
1511
  value.mode === "discard" ||
1515
1512
  value.mode === "observe-command" ||
1516
- (value.mode === "observe" &&
1513
+ ((value.mode === "observe" || value.mode === "observe-held") &&
1517
1514
  !!parseThreadTarget(value.oldTarget))) &&
1518
1515
  isUniqueSourceUpdateIds(value.sourceUpdateIds) &&
1519
1516
  typeof value.sentAtMs === "number" &&
@@ -1530,9 +1527,10 @@ export function parseTelegramBusEnvelope(
1530
1527
  | "release"
1531
1528
  | "discard"
1532
1529
  | "observe"
1530
+ | "observe-held"
1533
1531
  | "observe-command",
1534
1532
  sentAtMs: value.sentAtMs,
1535
- ...(value.mode === "observe"
1533
+ ...((value.mode === "observe" || value.mode === "observe-held")
1536
1534
  ? { oldTarget: parseThreadTarget(value.oldTarget)! }
1537
1535
  : {}),
1538
1536
  ...(selectedCommand ? { selectedCommand, preparedSource } : {}),
@@ -2417,6 +2415,7 @@ export function createTelegramBusLiveRebindController(deps: {
2417
2415
  | "release"
2418
2416
  | "discard"
2419
2417
  | "observe"
2418
+ | "observe-held"
2420
2419
  | "observe-command";
2421
2420
  sourceUpdateIds: number[];
2422
2421
  slot: string;
@@ -2460,6 +2459,7 @@ export function createTelegramBusLiveRebindController(deps: {
2460
2459
  "release",
2461
2460
  "discard",
2462
2461
  "observe",
2462
+ "observe-held",
2463
2463
  "observe-command",
2464
2464
  ].includes(input.mode) ||
2465
2465
  (input.mode === "observe-command" && !selectedCommand)))
@@ -2505,7 +2505,8 @@ export function createTelegramBusLiveRebindController(deps: {
2505
2505
  }
2506
2506
  : { updates: input.updates }),
2507
2507
  });
2508
- const observing = "mode" in expected && expected.mode === "observe";
2508
+ const observing = "mode" in expected && (expected.mode === "observe" || expected.mode === "observe-held");
2509
+ const observingHeld = "mode" in expected && expected.mode === "observe-held";
2509
2510
  const commandObserving =
2510
2511
  "mode" in expected && expected.mode === "observe-command";
2511
2512
  const settling =
@@ -2520,6 +2521,7 @@ export function createTelegramBusLiveRebindController(deps: {
2520
2521
  ? [TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY]
2521
2522
  : []),
2522
2523
  ...(settling || held ? [TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE] : []),
2524
+ ...(observingHeld ? [TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION] : []),
2523
2525
  ...(held
2524
2526
  ? [
2525
2527
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET,
@@ -2635,7 +2637,7 @@ export function createTelegramBusLiveRebindController(deps: {
2635
2637
  ? {
2636
2638
  kind: "leader.settleLiveRebind" as const,
2637
2639
  mode: expected.mode as
2638
- "release" | "discard" | "observe" | "observe-command",
2640
+ "release" | "discard" | "observe" | "observe-held" | "observe-command",
2639
2641
  sourceUpdateIds: expected.sourceUpdateIds!,
2640
2642
  ...(observing ? { oldTarget: expected.oldTarget } : {}),
2641
2643
  }
package/lib/delivery.ts CHANGED
@@ -4,7 +4,6 @@
4
4
  * Owns the public extension delivery contract, authorized scope resolution, operational rendering adapter, per-target serialization, chunk reconciliation, generation-fenced logical handles, and process-local runtime membrane; composes the established reply renderer with bus-aware Telegram API ports and excludes bot clients, Pi contexts, and consumer-extension policy
5
5
  */
6
6
 
7
- import { markTelegramBusAggregateDelivery } from "./bus.ts";
8
7
  import {
9
8
  assertTelegramInlineKeyboardCallbackData,
10
9
  type TelegramInlineKeyboardMarkup,
@@ -134,6 +133,7 @@ export interface TelegramDeliveryTransportOptions {
134
133
  /** @internal */
135
134
  export interface TelegramDeliveryRuntimeDeps extends TelegramDeliveryTargetResolverDeps {
136
135
  generation: string;
136
+ captureDrain?: () => (() => void) | undefined;
137
137
  renderView: (
138
138
  view: TelegramDeliveryView,
139
139
  ) => readonly TelegramDeliveryRenderedChunk[];
@@ -166,6 +166,7 @@ export interface TelegramDeliveryRuntimeDeps extends TelegramDeliveryTargetResol
166
166
  /** @internal */
167
167
  export interface TelegramBridgeDeliveryRuntimeDeps {
168
168
  generation: string;
169
+ captureDrain?: () => (() => void) | undefined;
169
170
  getTargetPolicyView: () => TelegramDeliveryTargetPolicyView;
170
171
  getActiveTurnTarget: () => TelegramDeliveryTarget | undefined;
171
172
  isTransportActive?: () => boolean;
@@ -295,6 +296,7 @@ function classifyTelegramDeliveryTransportError(error: unknown): {
295
296
  export interface TelegramDeliveryTargetPolicyView {
296
297
  canDeliver: boolean;
297
298
  ownsDirect: boolean;
299
+ threaded?: boolean;
298
300
  allowedChatId?: number;
299
301
  followerTarget?: TelegramDeliveryTarget;
300
302
  leaderTarget?: TelegramDeliveryTarget;
@@ -310,6 +312,7 @@ export interface TelegramDeliveryTargetPolicyRuntime {
310
312
  /** @internal */
311
313
  export function createTelegramDeliveryTargetPolicyRuntime(deps: {
312
314
  ownsDirect(): boolean;
315
+ isThreadedMode?(): boolean;
313
316
  isFollowerRegistered(): boolean;
314
317
  getAllowedChatId(): number | undefined;
315
318
  getFollowerTarget(): TelegramDeliveryTarget | undefined;
@@ -324,6 +327,7 @@ export function createTelegramDeliveryTargetPolicyRuntime(deps: {
324
327
  return {
325
328
  canDeliver: ownsDirect || deps.isFollowerRegistered(),
326
329
  ownsDirect,
330
+ threaded: deps.isThreadedMode?.() === true,
327
331
  allowedChatId: deps.getAllowedChatId(),
328
332
  followerTarget: deps.getFollowerTarget(),
329
333
  leaderTarget: deps.getLeaderTarget(),
@@ -338,25 +342,25 @@ export function createTelegramDeliveryTargetPolicyRuntime(deps: {
338
342
  };
339
343
  }
340
344
 
345
+ function isThreadedDelivery(view: TelegramDeliveryTargetPolicyView): boolean {
346
+ return view.threaded === true || view.followerTarget?.threadId !== undefined || view.leaderTarget?.threadId !== undefined;
347
+ }
348
+
341
349
  /** @internal */
342
350
  export function resolveTelegramDeliveryInstanceTarget(
343
351
  view: TelegramDeliveryTargetPolicyView,
344
352
  ): TelegramDeliveryTarget | undefined {
345
353
  if (!view.canDeliver) return undefined;
346
- return (
347
- view.followerTarget ??
348
- view.leaderTarget ??
349
- (view.allowedChatId === undefined
350
- ? undefined
351
- : { chatId: view.allowedChatId })
352
- );
354
+ const target = view.followerTarget ?? view.leaderTarget ??
355
+ (view.allowedChatId === undefined ? undefined : { chatId: view.allowedChatId });
356
+ return target && (!isThreadedDelivery(view) || target.threadId !== undefined) ? target : undefined;
353
357
  }
354
358
 
355
359
  /** @internal */
356
360
  export function resolveTelegramDeliveryAggregateTarget(
357
361
  view: TelegramDeliveryTargetPolicyView,
358
362
  ): TelegramDeliveryTarget | undefined {
359
- return !view.canDeliver || view.allowedChatId === undefined
363
+ return !view.canDeliver || view.allowedChatId === undefined || isThreadedDelivery(view)
360
364
  ? undefined
361
365
  : { chatId: view.allowedChatId };
362
366
  }
@@ -373,7 +377,7 @@ export function isTelegramDeliveryExplicitTargetAuthorized(
373
377
  ) {
374
378
  return false;
375
379
  }
376
- if (candidate.threadId === undefined) return true;
380
+ if (candidate.threadId === undefined) return !isThreadedDelivery(view);
377
381
  if (view.followerTarget) {
378
382
  return areDeliveryTargetsEqual(candidate, view.followerTarget);
379
383
  }
@@ -427,13 +431,27 @@ function resolveTelegramDeliveryTarget(
427
431
  return { ok: true, value: cloneTarget(target) };
428
432
  }
429
433
 
430
- function createTelegramDeliveryTargetQueue() {
434
+ /** Observation cannot turn failed delivery into success or borrow a newer queue lifetime. */
435
+ function captureDeliveryDrainObserver(capture?: () => (() => void) | undefined): () => void {
436
+ try {
437
+ const notify = capture?.();
438
+ return () => {
439
+ try {
440
+ const emitted: unknown = notify?.();
441
+ if (emitted) void Promise.resolve(emitted).catch(() => undefined);
442
+ } catch { /* Observer failure is inert. */ }
443
+ };
444
+ } catch { return () => {}; }
445
+ }
446
+
447
+ function createTelegramDeliveryTargetQueue(captureDrain?: () => (() => void) | undefined) {
431
448
  const queues = new Map<string, Promise<void>>();
432
449
  async function run<T>(
433
450
  target: TelegramDeliveryTarget,
434
451
  operation: () => Promise<T>,
435
452
  ): Promise<T> {
436
453
  const key = targetKey(target);
454
+ const notifyDrained = captureDeliveryDrainObserver(captureDrain);
437
455
  const previous = queues.get(key) ?? Promise.resolve();
438
456
  const current = previous.then(operation, operation);
439
457
  const settled = current.then(
@@ -444,7 +462,7 @@ function createTelegramDeliveryTargetQueue() {
444
462
  try {
445
463
  return await current;
446
464
  } finally {
447
- if (queues.get(key) === settled) queues.delete(key);
465
+ if (queues.get(key) === settled) { queues.delete(key); notifyDrained(); }
448
466
  }
449
467
  }
450
468
  return {
@@ -482,7 +500,10 @@ export function createTelegramDeliveryRuntime(
482
500
  TelegramDeliveryHandle,
483
501
  { target: TelegramDeliveryTarget; messageIds: readonly number[] }
484
502
  >();
485
- const targetQueue = createTelegramDeliveryTargetQueue();
503
+ const targetQueue = createTelegramDeliveryTargetQueue(() => {
504
+ const notify = deps.captureDrain?.();
505
+ return () => active ? notify?.() : undefined;
506
+ });
486
507
  const runForTarget = targetQueue.run;
487
508
  const render = (
488
509
  view: TelegramDeliveryView,
@@ -735,9 +756,17 @@ export function createTelegramBridgeDeliveryRuntime(
735
756
  throw new TelegramDeliveryTransportGenerationError();
736
757
  }
737
758
  };
759
+ const assertOutputTarget = (target: TelegramDeliveryTarget): void => {
760
+ if (isThreadedDelivery(getPolicyView()) && target.threadId === undefined)
761
+ throw new Error("Telegram All is input-only in Threaded Mode.");
762
+ };
738
763
  return createTelegramDeliveryRuntime({
739
764
  generation: deps.generation,
740
- getActiveTurnTarget: deps.getActiveTurnTarget,
765
+ captureDrain: deps.captureDrain,
766
+ getActiveTurnTarget() {
767
+ const target = deps.getActiveTurnTarget();
768
+ return target && (!isThreadedDelivery(getPolicyView()) || target.threadId !== undefined) ? target : undefined;
769
+ },
741
770
  getInstanceTarget() {
742
771
  return resolveTelegramDeliveryInstanceTarget(getPolicyView());
743
772
  },
@@ -763,6 +792,7 @@ export function createTelegramBridgeDeliveryRuntime(
763
792
  },
764
793
  async sendChunk(target, chunk, options) {
765
794
  assertTransportActive();
795
+ assertOutputTarget(target);
766
796
  const sent = await withTelegramReplyParameters(
767
797
  target.chatId,
768
798
  options.replyToMessageId,
@@ -780,11 +810,7 @@ export function createTelegramBridgeDeliveryRuntime(
780
810
  ? { reply_markup: options.replyMarkup }
781
811
  : {}),
782
812
  };
783
- return deps.api.sendMessage(
784
- target.threadId === undefined
785
- ? markTelegramBusAggregateDelivery(body)
786
- : body,
787
- );
813
+ return deps.api.sendMessage(body);
788
814
  },
789
815
  );
790
816
  assertTransportActive();
@@ -797,6 +823,7 @@ export function createTelegramBridgeDeliveryRuntime(
797
823
  },
798
824
  async editChunk(target, messageId, chunk, options) {
799
825
  assertTransportActive();
826
+ assertOutputTarget(target);
800
827
  await deps.api.editMessageText({
801
828
  chat_id: target.chatId,
802
829
  message_id: messageId,
@@ -814,6 +841,7 @@ export function createTelegramBridgeDeliveryRuntime(
814
841
  },
815
842
  async sendChatAction(target, action) {
816
843
  assertTransportActive();
844
+ assertOutputTarget(target);
817
845
  await deps.api.sendChatAction(target.chatId, action, {
818
846
  message_thread_id: target.threadId,
819
847
  });