@llblab/pi-telegram 0.55.1 → 0.56.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENTS.md +5 -4
  2. package/BACKLOG.md +6 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +28 -17
  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 +10 -5
  9. package/dist/lib/bus-leader.d.ts +2 -0
  10. package/dist/lib/bus-leader.js +34 -20
  11. package/dist/lib/bus.d.ts +8 -0
  12. package/dist/lib/bus.js +21 -7
  13. package/dist/lib/commands.d.ts +1 -1
  14. package/dist/lib/commands.js +33 -28
  15. package/dist/lib/extension.js +18 -3
  16. package/dist/lib/lifecycle.d.ts +2 -1
  17. package/dist/lib/lifecycle.js +28 -20
  18. package/dist/lib/locks.d.ts +6 -2
  19. package/dist/lib/locks.js +3 -3
  20. package/dist/lib/menu-queue.d.ts +1 -0
  21. package/dist/lib/menu-queue.js +102 -99
  22. package/dist/lib/pi.d.ts +3 -2
  23. package/dist/lib/pi.js +3 -0
  24. package/dist/lib/polling.d.ts +6 -2
  25. package/dist/lib/polling.js +5 -3
  26. package/dist/lib/queue.d.ts +57 -5
  27. package/dist/lib/queue.js +313 -25
  28. package/dist/lib/routing.d.ts +4 -0
  29. package/dist/lib/routing.js +62 -1
  30. package/dist/lib/status.d.ts +1 -2
  31. package/dist/lib/status.js +6 -7
  32. package/dist/lib/sync.js +11 -1
  33. package/dist/lib/threads.d.ts +11 -0
  34. package/dist/lib/threads.js +57 -6
  35. package/dist/lib/updates.d.ts +12 -7
  36. package/dist/lib/updates.js +44 -9
  37. package/dist/package.json +1 -1
  38. package/docs/README.md +1 -1
  39. package/docs/architecture.md +24 -13
  40. package/docs/callback-namespaces.md +4 -0
  41. package/docs/multi-instance-bus.md +6 -4
  42. package/docs/ui-style.md +14 -10
  43. package/lib/bindings.ts +52 -3
  44. package/lib/bus-follower.ts +13 -5
  45. package/lib/bus-leader.ts +37 -23
  46. package/lib/bus.ts +23 -7
  47. package/lib/commands.ts +52 -47
  48. package/lib/extension.ts +18 -2
  49. package/lib/lifecycle.ts +34 -28
  50. package/lib/locks.ts +5 -2
  51. package/lib/menu-queue.ts +116 -162
  52. package/lib/pi.ts +8 -0
  53. package/lib/polling.ts +7 -2
  54. package/lib/queue.ts +328 -33
  55. package/lib/routing.ts +51 -1
  56. package/lib/status.ts +7 -7
  57. package/lib/sync.ts +11 -0
  58. package/lib/threads.ts +57 -5
  59. package/lib/updates.ts +44 -15
  60. package/package.json +1 -1
package/lib/bus-leader.ts CHANGED
@@ -23,6 +23,8 @@ import {
23
23
  TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
24
24
  TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION,
25
25
  TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF,
26
+ TELEGRAM_BUS_CAPABILITY_CONNECT_RECOVERY,
27
+ hasTelegramBusSteerQueueHandoffCapability,
26
28
  TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY,
27
29
  TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
28
30
  TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
@@ -1528,7 +1530,7 @@ export function createTelegramBusFollowerTargetProvisioner(
1528
1530
  deps: TelegramBusFollowerTargetProvisionerDeps,
1529
1531
  ): (
1530
1532
  registration: TelegramBusInstanceRegistration,
1531
- options?: { existingWorkspaceBindingOnly?: boolean },
1533
+ options?: { existingWorkspaceBindingOnly?: boolean; recoverConnection?: boolean },
1532
1534
  ) => Promise<
1533
1535
  (TelegramTarget & { slot?: string; threadName?: string }) | undefined
1534
1536
  > {
@@ -1547,6 +1549,16 @@ export function createTelegramBusFollowerTargetProvisioner(
1547
1549
  const chatId = deps.getAllowedUserId();
1548
1550
  if (typeof chatId !== "number") return registration.target;
1549
1551
  await deps.topicTargetStore.load();
1552
+ if (options?.recoverConnection && !options.existingWorkspaceBindingOnly) {
1553
+ const epoch = deps.getCurrentLeaderEpoch?.();
1554
+ await Threads.resetTelegramConnectionProvisioning({
1555
+ store: deps.topicTargetStore, owner: "manual-follower", instanceId: registration.instanceId,
1556
+ profileKey: registration.profileKey ?? `manual:${registration.instanceId}`,
1557
+ isCurrent: () => !deps.getCurrentLeaderEpoch ||
1558
+ (epoch !== undefined && deps.getCurrentLeaderEpoch() === epoch),
1559
+ recordEvent: deps.recordRuntimeEvent,
1560
+ });
1561
+ }
1550
1562
  let restoredPredecessor: Threads.TelegramTopicTargetRecord | undefined;
1551
1563
  if (options?.existingWorkspaceBindingOnly && registration.cwd && registration.sessionId &&
1552
1564
  !registration.previousInstanceId && deps.getLiveFollowers && deps.isFollowerProcessAlive) {
@@ -1579,6 +1591,7 @@ export function createTelegramBusFollowerTargetProvisioner(
1579
1591
  {
1580
1592
  existingBindingOnly: options?.existingWorkspaceBindingOnly === true,
1581
1593
  sessionId: registration.sessionId,
1594
+ provisionProfileKey: registration.profileKey ?? `manual:${registration.instanceId}`,
1582
1595
  onCapacityUnavailable() {
1583
1596
  capacityUnavailable = true;
1584
1597
  },
@@ -1841,12 +1854,6 @@ export function createTelegramBusFollowerTargetProvisioner(
1841
1854
  };
1842
1855
  }
1843
1856
  const probeRequiredRecord = reconnectRecord?.status === "probe-required";
1844
- const exactSessionHandoff =
1845
- crossSessionReuse &&
1846
- !!requestedTarget &&
1847
- registration.previousInstanceId === reconnectRecord?.instanceId &&
1848
- requestedTarget.chatId === reconnectRecord.target.chatId &&
1849
- requestedTarget.threadId === reconnectRecord.target.threadId;
1850
1857
  const requiresVisibilityProbe =
1851
1858
  crossSessionReuse ||
1852
1859
  probeRequiredRecord ||
@@ -1868,19 +1875,14 @@ export function createTelegramBusFollowerTargetProvisioner(
1868
1875
  try {
1869
1876
  assertRestoreRegistration();
1870
1877
  await deps.callApi(
1871
- exactSessionHandoff ? "sendChatAction" : "sendMessage",
1872
- exactSessionHandoff
1873
- ? {
1874
- chat_id: connectedAnnouncement.target.chatId,
1875
- message_thread_id: connectedAnnouncement.target.threadId,
1876
- action: "typing",
1877
- }
1878
- : {
1879
- chat_id: connectedAnnouncement.target.chatId,
1880
- message_thread_id: connectedAnnouncement.target.threadId,
1881
- text: connectedAnnouncement.text,
1882
- parse_mode: connectedAnnouncement.parseMode,
1883
- },
1878
+ // Typing is presence, not evidence that a retained Thread still accepts messages.
1879
+ "sendMessage",
1880
+ {
1881
+ chat_id: connectedAnnouncement.target.chatId,
1882
+ message_thread_id: connectedAnnouncement.target.threadId,
1883
+ text: connectedAnnouncement.text,
1884
+ parse_mode: connectedAnnouncement.parseMode,
1885
+ },
1884
1886
  );
1885
1887
  assertRestoreRegistration();
1886
1888
  if (recoverableTarget || probeRequiredRecord) {
@@ -2943,7 +2945,7 @@ export interface TelegramBusLeaderEnvelopeHandlerDeps {
2943
2945
  ) => Promise<unknown> | unknown;
2944
2946
  provisionFollowerTarget?: (
2945
2947
  registration: TelegramBusInstanceRegistration,
2946
- options?: { existingWorkspaceBindingOnly?: boolean },
2948
+ options?: { existingWorkspaceBindingOnly?: boolean; recoverConnection?: boolean },
2947
2949
  ) =>
2948
2950
  | Promise<
2949
2951
  (TelegramTarget & { slot?: string; threadName?: string }) | undefined
@@ -3273,6 +3275,10 @@ export function createTelegramBusLeaderEnvelopeHandler(
3273
3275
  "Telegram queue handoff capability was not negotiated.",
3274
3276
  );
3275
3277
  }
3278
+ if (envelope.payload.queueLane === "steer" &&
3279
+ ![deps.protocolIdentity, donor.protocol, recipient?.protocol].every(hasTelegramBusSteerQueueHandoffCapability)) {
3280
+ return rejectTelegramBusRequest(envelope.requestId, "Steer queue handoff capability was not negotiated.");
3281
+ }
3276
3282
  if (
3277
3283
  !recipient?.registrationGeneration ||
3278
3284
  envelope.recipientRegistrationGeneration !==
@@ -3448,6 +3454,9 @@ export function createTelegramBusLeaderEnvelopeHandler(
3448
3454
  ));
3449
3455
  const restoringWorkspace =
3450
3456
  envelope.kind === "follower.restoreWorkspace";
3457
+ const recoverConnection = envelope.kind === "follower.register" && envelope.recoverConnection === true;
3458
+ const supportsConnectionRecovery = [deps.protocolIdentity, envelope.registration.protocol]
3459
+ .every(identity => hasTelegramBusCapability(identity, TELEGRAM_BUS_CAPABILITY_CONNECT_RECOVERY));
3451
3460
  const supportsWorkspaceAutoConnect =
3452
3461
  hasTelegramBusCapability(
3453
3462
  deps.protocolIdentity,
@@ -3463,7 +3472,8 @@ export function createTelegramBusLeaderEnvelopeHandler(
3463
3472
  !readerCompatible() ||
3464
3473
  (envelope.registration.cwd !== undefined &&
3465
3474
  envelope.registration.sessionId === undefined) ||
3466
- (restoringWorkspace && !supportsWorkspaceAutoConnect)
3475
+ (restoringWorkspace && !supportsWorkspaceAutoConnect) ||
3476
+ (recoverConnection && !supportsConnectionRecovery)
3467
3477
  ) {
3468
3478
  return {
3469
3479
  kind: "bus.ack" as const,
@@ -3521,7 +3531,7 @@ export function createTelegramBusLeaderEnvelopeHandler(
3521
3531
  );
3522
3532
  const target = await deps.provisionFollowerTarget?.(
3523
3533
  envelope.registration,
3524
- { existingWorkspaceBindingOnly: restoringWorkspace },
3534
+ { existingWorkspaceBindingOnly: restoringWorkspace, ...(recoverConnection ? { recoverConnection: true } : {}) },
3525
3535
  );
3526
3536
  if (restoringWorkspace && !target) {
3527
3537
  return {
@@ -4477,6 +4487,10 @@ export function createTelegramBusLeaderRuntime<TContext>(
4477
4487
  "Telegram queue handoff capability was not negotiated.",
4478
4488
  );
4479
4489
  }
4490
+ if (input.payload.queueLane === "steer" &&
4491
+ ![deps.protocolIdentity, recipient?.protocol].every(hasTelegramBusSteerQueueHandoffCapability)) {
4492
+ return rejectTelegramBusRequest(input.requestId, "Steer queue handoff capability was not negotiated.");
4493
+ }
4480
4494
  if (
4481
4495
  !recipient?.registrationGeneration ||
4482
4496
  input.recipientRegistrationGeneration !== recipient.registrationGeneration
package/lib/bus.ts CHANGED
@@ -142,6 +142,9 @@ export const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION =
142
142
  "durable-follower-admission-v1" as const;
143
143
  export const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF =
144
144
  "queue-handoff-v1" as const;
145
+ /** Unspent Steer lane codec; independent from baseline receipt handoff support. */
146
+ export const TELEGRAM_BUS_CAPABILITY_QUEUE_STEER = "queue-steer-v1" as const;
147
+ export const TELEGRAM_BUS_CAPABILITY_CONNECT_RECOVERY = "explicit-connect-recovery-v1" as const;
145
148
  export const TELEGRAM_BUS_CAPABILITY_INPUT_CUSTODY_REFERENCE =
146
149
  "input-custody-reference-v1" as const;
147
150
  export const TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME =
@@ -182,6 +185,14 @@ export const TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES = [
182
185
  TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY,
183
186
  ] as const;
184
187
 
188
+ /** Start/help are restricted menu aliases, negotiated separately from the original held-command set. */
189
+ export const TELEGRAM_BUS_CAPABILITY_HELD_MENU_ALIASES = "held-menu-aliases-v1" as const;
190
+ export function getTelegramBusHeldCommandCapabilities(name: string): readonly string[] {
191
+ return name === "start" || name === "help"
192
+ ? [...TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES, TELEGRAM_BUS_CAPABILITY_HELD_MENU_ALIASES]
193
+ : TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES;
194
+ }
195
+
185
196
  export interface TelegramBusSelectedCommandInput {
186
197
  name: string;
187
198
  target: TelegramTarget & { threadId: number };
@@ -461,6 +472,11 @@ export function hasTelegramBusCapability(
461
472
  return identity?.capabilities.includes(capability) ?? false;
462
473
  }
463
474
 
475
+ export function hasTelegramBusSteerQueueHandoffCapability(identity: TelegramBusProtocolIdentity | undefined): boolean {
476
+ return hasTelegramBusCapability(identity, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF) &&
477
+ hasTelegramBusCapability(identity, TELEGRAM_BUS_CAPABILITY_QUEUE_STEER);
478
+ }
479
+
464
480
  export function getTelegramInputCustodyPeerReadiness(
465
481
  followers: readonly Pick<
466
482
  TelegramBusFollowerView,
@@ -1002,6 +1018,7 @@ export type TelegramBusEnvelope = (
1002
1018
  | {
1003
1019
  kind: "follower.register";
1004
1020
  requestId: string;
1021
+ recoverConnection?: true;
1005
1022
  registration: TelegramBusInstanceRegistration;
1006
1023
  }
1007
1024
  | {
@@ -2532,12 +2549,7 @@ export function createTelegramBusLiveRebindController(deps: {
2532
2549
  : []),
2533
2550
  ...(settling || held ? [TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE] : []),
2534
2551
  ...(observingHeld ? [TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION] : []),
2535
- ...(held
2536
- ? [
2537
- TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET,
2538
- TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY,
2539
- ]
2540
- : []),
2552
+ ...(held ? getTelegramBusHeldCommandCapabilities(held.name) : []),
2541
2553
  ];
2542
2554
  const follower = deps.getFollower(expected.instanceId),
2543
2555
  secret = deps.getAuthSecret();
@@ -3538,8 +3550,12 @@ function parseRegisterEnvelope(
3538
3550
  requestId: string,
3539
3551
  kind: "follower.register" | "follower.restoreWorkspace",
3540
3552
  ): TelegramBusEnvelope | undefined {
3553
+ if (value.recoverConnection !== undefined &&
3554
+ (kind !== "follower.register" || value.recoverConnection !== true)) return undefined;
3541
3555
  const registration = parseRegistration(value.registration);
3542
- return registration ? { kind, requestId, registration } : undefined;
3556
+ return registration ? { kind, requestId, registration,
3557
+ ...(kind === "follower.register" && value.recoverConnection === true ? { recoverConnection: true as const } : {}),
3558
+ } : undefined;
3543
3559
  }
3544
3560
 
3545
3561
  function parseFollowerPresenceEnvelope(
package/lib/commands.ts CHANGED
@@ -53,7 +53,7 @@ import {
53
53
  } from "./updates.ts";
54
54
 
55
55
  export type TelegramHeldCommandName =
56
- "status" | "abort" | "stop" | "next" | "continue";
56
+ "status" | "start" | "help" | "abort" | "stop" | "next" | "continue";
57
57
 
58
58
  /** Commands-owned no-fold admission over the ordinary recipient queue; source reporting stays separate from removal. */
59
59
  export interface TelegramHeldTurnAdmission {
@@ -2773,53 +2773,58 @@ export function createTelegramCommandHandler<
2773
2773
  },
2774
2774
  };
2775
2775
  };
2776
- const heldPlans: Record<TelegramHeldCommandName, HeldPlan> = {
2777
- status: {
2778
- completion: "before-effect",
2779
- effect: "showStatus",
2780
- prepare(ctx, effects) {
2781
- const scopedStatus = effects?.showStatus;
2782
- const captured = {
2783
- ...deps,
2784
- showStatus: scopedStatus
2785
- ? (
2786
- message: TMessage,
2787
- context: TContext,
2788
- options?: Pick<TelegramApiCallOptions, "assertAuthority">,
2789
- ) => {
2790
- const address = getTelegramCommandMessageTarget(message);
2791
- return scopedStatus.call(
2792
- effects,
2793
- address.chatId,
2794
- address.replyToMessageId,
2795
- context,
2796
- address.threadId,
2797
- options,
2798
- );
2799
- }
2800
- : deps.showStatus,
2801
- };
2802
- return {
2803
- captured,
2804
- portsCurrent: () => !effects || effects.showStatus === scopedStatus,
2805
- async run({ message, command, assertRecipient }) {
2806
- const recipientOptions = { assertAuthority: assertRecipient };
2807
- return handleTelegramCommandRuntime(
2808
- command.name,
2809
- message,
2810
- ctx,
2811
- {
2812
- ...captured,
2813
- assertExecutionCurrent: assertRecipient,
2814
- showStatus: (value: TMessage, context: TContext) =>
2815
- captured.showStatus(value, context, recipientOptions),
2816
- },
2817
- command.args,
2818
- );
2819
- },
2820
- };
2821
- },
2776
+ // Held followers are already paired and bound. Start/help open the same restricted menu as status;
2777
+ // they cannot pair an operator, bootstrap another Thread or invoke unrestricted Bot API effects.
2778
+ const heldMenuPlan: HeldPlan = {
2779
+ completion: "before-effect",
2780
+ effect: "showStatus",
2781
+ prepare(ctx, effects) {
2782
+ const scopedStatus = effects?.showStatus;
2783
+ const captured = {
2784
+ ...deps,
2785
+ showStatus: scopedStatus
2786
+ ? (
2787
+ message: TMessage,
2788
+ context: TContext,
2789
+ options?: Pick<TelegramApiCallOptions, "assertAuthority">,
2790
+ ) => {
2791
+ const address = getTelegramCommandMessageTarget(message);
2792
+ return scopedStatus.call(
2793
+ effects,
2794
+ address.chatId,
2795
+ address.replyToMessageId,
2796
+ context,
2797
+ address.threadId,
2798
+ options,
2799
+ );
2800
+ }
2801
+ : deps.showStatus,
2802
+ };
2803
+ return {
2804
+ captured,
2805
+ portsCurrent: () => !effects || effects.showStatus === scopedStatus,
2806
+ async run({ message, command, assertRecipient }) {
2807
+ const recipientOptions = { assertAuthority: assertRecipient };
2808
+ return handleTelegramCommandRuntime(
2809
+ "status",
2810
+ message,
2811
+ ctx,
2812
+ {
2813
+ ...captured,
2814
+ assertExecutionCurrent: assertRecipient,
2815
+ showStatus: (value: TMessage, context: TContext) =>
2816
+ captured.showStatus(value, context, recipientOptions),
2817
+ },
2818
+ command.args,
2819
+ );
2820
+ },
2821
+ };
2822
2822
  },
2823
+ };
2824
+ const heldPlans: Record<TelegramHeldCommandName, HeldPlan> = {
2825
+ status: heldMenuPlan,
2826
+ start: heldMenuPlan,
2827
+ help: heldMenuPlan,
2823
2828
  abort: {
2824
2829
  completion: "after-semantics",
2825
2830
  effect: "sendTextReply",
package/lib/extension.ts CHANGED
@@ -64,6 +64,9 @@ const telegramBusProtocolIdentity =
64
64
  Bus.createTelegramCurrentBusProtocolIdentity([
65
65
  Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION,
66
66
  Bus.TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF,
67
+ Bus.TELEGRAM_BUS_CAPABILITY_QUEUE_STEER,
68
+ Bus.TELEGRAM_BUS_CAPABILITY_CONNECT_RECOVERY,
69
+ Bus.TELEGRAM_BUS_CAPABILITY_HELD_MENU_ALIASES,
67
70
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT,
68
71
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
69
72
  Bus.TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
@@ -717,7 +720,6 @@ export default function (pi: Pi.ExtensionAPI) {
717
720
  },
718
721
  getQueuedItems: telegramQueueStore.getQueuedItems,
719
722
  getQueuedItemCount: Queue.countExecutableTelegramQueueItems,
720
- formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
721
723
  getRecentRuntimeEvents: runtimeEvents.getEvents,
722
724
  getRuntimeLockState: lockRuntime.getStatusLabel,
723
725
  ...threadStatusProjectionRuntime,
@@ -1026,6 +1028,7 @@ export default function (pi: Pi.ExtensionAPI) {
1026
1028
  requestNextDispatchAnnouncement,
1027
1029
  cancelNextDispatchAnnouncement,
1028
1030
  watchdog: queueDispatchWatchdogRuntime,
1031
+ onTurnEnd: onSteerTurnEnd,
1029
1032
  } = Bindings.createTelegramQueueBindingRuntime({
1030
1033
  store: telegramQueueStore,
1031
1034
  queue,
@@ -1037,6 +1040,8 @@ export default function (pi: Pi.ExtensionAPI) {
1037
1040
  promptDispatch: promptDispatchRuntime,
1038
1041
  isIdle,
1039
1042
  hasPendingMessages,
1043
+ isContextCurrent: telegramSessionContextStore.isCurrent,
1044
+ isContextAborted: Pi.isExtensionContextAbortRequested,
1040
1045
  updateStatus,
1041
1046
  sendTextReply,
1042
1047
  sendUserMessage,
@@ -1123,6 +1128,7 @@ export default function (pi: Pi.ExtensionAPI) {
1123
1128
 
1124
1129
  // --- Queue And Settings Menus ---
1125
1130
  const queueMenuRuntime = MenuQueue.createTelegramQueueMenuRuntime({
1131
+ hasPendingDispatch: lifecycle.hasDispatchPending,
1126
1132
  telegramQueueStore,
1127
1133
  queueMutationRuntime,
1128
1134
  sendInteractiveMessage,
@@ -1234,7 +1240,7 @@ export default function (pi: Pi.ExtensionAPI) {
1234
1240
  follower.protocol,
1235
1241
  [
1236
1242
  ...Bus.TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES,
1237
- ...Bus.TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES,
1243
+ ...Bus.getTelegramBusHeldCommandCapabilities(name),
1238
1244
  ],
1239
1245
  ) &&
1240
1246
  typeof selectedMenuDelivery === "function" &&
@@ -1555,6 +1561,7 @@ export default function (pi: Pi.ExtensionAPI) {
1555
1561
  threadStore,
1556
1562
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1557
1563
  liveRebindCleanupSchedule: Routing.TELEGRAM_LIVE_REBIND_CLEANUP_SCHEDULE,
1564
+ liveRebindCommandCompletionTimeoutMs: 8_000,
1558
1565
  updateStatus,
1559
1566
  isContextActive: telegramSessionContextStore.isCurrent,
1560
1567
  dispatchNextQueuedTelegramTurn,
@@ -2331,6 +2338,14 @@ export default function (pi: Pi.ExtensionAPI) {
2331
2338
  });
2332
2339
  queueHandoffReconciliationBinding.set(
2333
2340
  Updates.createTelegramQueueHandoffReconciliationRuntimeAssembly({
2341
+ hasPendingDispatch: lifecycle.hasDispatchPending,
2342
+ canHandoffSteerWithRecipient(recipient) {
2343
+ const live = telegramBusFollowerRegistry.get(recipient.instanceId);
2344
+ return live?.registrationGeneration === recipient.registrationGeneration &&
2345
+ [telegramBusProtocolIdentity, live?.protocol,
2346
+ ...(telegramBusFollowerRegistrationState.isRegistered() ? [telegramBusFollowerRegistrationState.getLeaderProtocol()] : [])]
2347
+ .every(Bus.hasTelegramBusSteerQueueHandoffCapability);
2348
+ },
2334
2349
  ownsDirect: lockRuntime.owns,
2335
2350
  isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
2336
2351
  isBusEnabled: isTelegramBusRuntimeEnabled,
@@ -3046,6 +3061,7 @@ export default function (pi: Pi.ExtensionAPI) {
3046
3061
  sendMarkdownReply,
3047
3062
  sendTextReply,
3048
3063
  dispatchNextQueuedTelegramTurn,
3064
+ onSteerTurnEnd,
3049
3065
  onPromptHandedOff(turn, ctx) {
3050
3066
  updateAdmissionRuntimeBinding
3051
3067
  .getSettlement()
package/lib/lifecycle.ts CHANGED
@@ -26,6 +26,8 @@ import type {
26
26
  ToolExecutionEndEvent,
27
27
  ToolExecutionStartEvent,
28
28
  ToolExecutionUpdateEvent,
29
+ TurnEndEvent,
30
+ TurnEndEventResult,
29
31
  UIPromptEndEvent,
30
32
  UIPromptStartEvent,
31
33
  } from "./pi.ts";
@@ -100,6 +102,7 @@ export interface TelegramLifecycleRegistrationDeps {
100
102
  event: { model: TelegramLifecycleModel },
101
103
  ctx: ExtensionContext,
102
104
  ) => Promise<void> | void;
105
+ onTurnEnd?: (event: TurnEndEvent, ctx: ExtensionContext) => TurnEndEventResult | undefined;
103
106
  onAgentStart: (
104
107
  event: AgentStartEvent,
105
108
  ctx: ExtensionContext,
@@ -575,7 +578,6 @@ export function createTelegramBridgeSessionLifecycleAssembly<
575
578
  >(
576
579
  deps: TelegramBridgeSessionLifecycleAssemblyDeps<TQueueItem, TModel>,
577
580
  ): TelegramSessionLifecycleHooks {
578
- const isSessionActive = deps.contextStore.isCurrent;
579
581
  const suspendForReplacement =
580
582
  BusFollower.createTelegramBusFollowerSessionReplacementSuspender({
581
583
  registrationState: deps.follower.registrationState,
@@ -583,32 +585,44 @@ export function createTelegramBridgeSessionLifecycleAssembly<
583
585
  suspendPolling: deps.follower.suspendPolling,
584
586
  recordRuntimeEvent: deps.follower.recordRuntimeEvent,
585
587
  });
586
- let preserveTarget = true;
587
- const queueLifecycle = Queue.createTelegramSessionLifecycleRuntime({
588
- ...deps.queue,
589
- isSessionActive,
590
- stopPolling: () => suspendForReplacement(preserveTarget),
591
- clearPendingMediaGroups: deps.services.suspendGroupedInput,
592
- });
588
+ // A reused context can be current for a successor; inner awaits need this invocation's generation too.
589
+ const queueLifecycle = (ctx: ExtensionContext, generation: number, preserveTarget = true) =>
590
+ Queue.createTelegramSessionLifecycleRuntime({
591
+ ...deps.queue,
592
+ isSessionActive: () => deps.contextStore.isCurrent(ctx, generation),
593
+ stopPolling: () => suspendForReplacement(preserveTarget),
594
+ clearPendingMediaGroups: deps.services.suspendGroupedInput,
595
+ });
593
596
  const servicesLifecycle: TelegramSessionLifecycleHooks = {
594
597
  async onSessionStart(event, ctx) {
598
+ const generation = deps.contextStore.getGeneration();
599
+ const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
595
600
  const resumeConnection = deps.services.connection?.prepare(event, ctx);
596
- await queueLifecycle.onSessionStart(event, ctx);
597
- if (!isSessionActive(ctx)) return;
601
+ await queueLifecycle(ctx, generation).onSessionStart(event, ctx);
602
+ if (!isCurrent()) return;
598
603
  deps.services.resumeGroupedInput(ctx);
599
604
  await deps.services.delivery.onSessionStart();
600
- if (!isSessionActive(ctx)) return;
605
+ if (!isCurrent()) return;
601
606
  if (resumeConnection) resumeConnection();
602
607
  else await deps.services.polling.onSessionStart(event, ctx);
608
+ if (!isCurrent()) return;
603
609
  deps.services.capabilityMonitor.start(ctx);
604
610
  deps.services.queueWatchdog.start(ctx);
611
+ await BusFollower.createTelegramBusFollowerSessionRefreshHook({
612
+ registrationState: deps.follower.registrationState,
613
+ registrationRuntime: deps.follower.registrationRuntime,
614
+ getLeaderState: deps.follower.getLeaderState,
615
+ isSessionActive: isCurrent,
616
+ updateStatus: deps.follower.updateStatus,
617
+ recordRuntimeEvent: deps.follower.recordRuntimeEvent,
618
+ })(event, ctx);
605
619
  },
606
620
  async onSessionShutdown(event, ctx) {
607
621
  const generation = deps.contextStore.getGeneration();
608
622
  const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
609
623
  if (!isCurrent()) return;
610
624
  deps.services.connection?.onSessionShutdown(event, ctx);
611
- preserveTarget = event.reason !== "resume";
625
+ const preserveTarget = event.reason !== "resume";
612
626
  let preserveThread: (() => Promise<void>) | undefined;
613
627
  if (event.reason === "quit") {
614
628
  try {
@@ -625,7 +639,7 @@ export function createTelegramBridgeSessionLifecycleAssembly<
625
639
  if (!isCurrent()) return;
626
640
  deps.services.queueWatchdog.stop();
627
641
  deps.services.capabilityMonitor.stop();
628
- await queueLifecycle.onSessionShutdown(event, ctx);
642
+ await queueLifecycle(ctx, generation, preserveTarget).onSessionShutdown(event, ctx);
629
643
  if (!isCurrent()) return;
630
644
  await deps.services.inboundWorker.onSessionShutdown();
631
645
  if (!isCurrent()) return;
@@ -638,23 +652,9 @@ export function createTelegramBridgeSessionLifecycleAssembly<
638
652
  }
639
653
  },
640
654
  };
641
- const followerLifecycle = appendTelegramLifecycleHooks(
642
- servicesLifecycle,
643
- {
644
- onSessionStart: BusFollower.createTelegramBusFollowerSessionRefreshHook({
645
- registrationState: deps.follower.registrationState,
646
- registrationRuntime: deps.follower.registrationRuntime,
647
- getLeaderState: deps.follower.getLeaderState,
648
- isSessionActive,
649
- updateStatus: deps.follower.updateStatus,
650
- recordRuntimeEvent: deps.follower.recordRuntimeEvent,
651
- }),
652
- },
653
- isSessionActive,
654
- );
655
655
  return createTelegramSessionGenerationFence(
656
656
  deps.contextStore,
657
- followerLifecycle,
657
+ servicesLifecycle,
658
658
  );
659
659
  }
660
660
 
@@ -1042,6 +1042,12 @@ export function registerTelegramLifecycleHooks(
1042
1042
  if (!isActive(ctx)) return;
1043
1043
  await deps.onAgentStart(event, ctx);
1044
1044
  });
1045
+ if (deps.onTurnEnd) {
1046
+ pi.on("turn_end", (event, ctx) => {
1047
+ if (!isActive(ctx)) return;
1048
+ return deps.onTurnEnd?.(event, ctx);
1049
+ });
1050
+ }
1045
1051
  pi.on("tool_execution_start", async (event, ctx) => {
1046
1052
  if (!isActive(ctx)) return;
1047
1053
  await deps.onToolExecutionStart(event, ctx);
package/lib/locks.ts CHANGED
@@ -1981,6 +1981,7 @@ export interface TelegramLockedPollingRuntime<
1981
1981
  registerFollowerWithOwner?: (
1982
1982
  ctx: TContext,
1983
1983
  owner: TelegramLockEntry,
1984
+ options?: { recoverConnection?: boolean },
1984
1985
  ) => boolean | undefined | Promise<boolean | undefined>;
1985
1986
  restoreFollowerWithOwner?: (
1986
1987
  ctx: TContext,
@@ -2008,6 +2009,7 @@ interface TelegramLockedPollingRuntimeDeps<
2008
2009
  registerFollowerWithOwner?: (
2009
2010
  ctx: TContext,
2010
2011
  owner: TelegramLockEntry,
2012
+ options?: { recoverConnection?: boolean },
2011
2013
  ) => boolean | undefined | Promise<boolean | undefined>;
2012
2014
  restoreFollowerWithOwner?: (
2013
2015
  ctx: TContext,
@@ -2294,6 +2296,7 @@ export function createTelegramLockedPollingRuntime<
2294
2296
  const registered = await deps.registerFollowerWithOwner(
2295
2297
  ctx,
2296
2298
  acquired.lock,
2299
+ options.forceFreshLeaderThread ? { recoverConnection: true } : undefined,
2297
2300
  );
2298
2301
  if (!isCurrent()) return cancelled;
2299
2302
  if (registered) {
@@ -2518,8 +2521,8 @@ export function createTelegramLockedPollingRuntime<
2518
2521
  sessionAutoStartRun = run;
2519
2522
  },
2520
2523
  registerFollowerWithOwner: deps.registerFollowerWithOwner
2521
- ? async (ctx, owner) => {
2522
- const registered = await deps.registerFollowerWithOwner?.(ctx, owner);
2524
+ ? async (ctx, owner, options) => {
2525
+ const registered = await deps.registerFollowerWithOwner?.(ctx, owner, options);
2523
2526
  if (registered) deps.updateStatus(ctx);
2524
2527
  return registered === true;
2525
2528
  }