@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/menu-queue.ts CHANGED
@@ -37,7 +37,12 @@ interface TelegramQueueMenuItem {
37
37
  queueOrder: number;
38
38
  isGuest: boolean;
39
39
  queuePosition: number;
40
- isPriority: boolean;
40
+ queueLane: Queue.TelegramQueueLane;
41
+ canSelectLane: boolean;
42
+ canSelectDisposition: boolean;
43
+ dispatchProtected: boolean;
44
+ attemptFrozen: boolean;
45
+ exceedsIdleCapacity: boolean;
41
46
  priorityEmoji?: string;
42
47
  reactionSuppressionEmoji?: string;
43
48
  hasAttachments: boolean;
@@ -60,15 +65,26 @@ function getTelegramQueueItemPromptText<Context>(
60
65
 
61
66
  function toTelegramQueueMenuItems<Context>(
62
67
  items: readonly Queue.TelegramQueueItem<Context>[],
68
+ pendingDispatch = false,
63
69
  ): TelegramQueueMenuItem[] {
70
+ const head = items[0];
64
71
  return items.map((item, index) => {
72
+ const dispatchProtected = pendingDispatch && (item === head ||
73
+ (head?.kind === "prompt" && head.steerPacketTail?.includes(item as Queue.PendingTelegramTurn) === true));
74
+ const attemptFrozen = item.kind === "prompt" && item.steerAttempted === true && !dispatchProtected;
75
+ const editable = item.kind === "prompt" && item.guestQueryId === undefined && item.guestInlineMessageId === undefined && !dispatchProtected && !attemptFrozen;
65
76
  return {
66
77
  chatId: item.chatId,
67
78
  replyToMessageId: item.replyToMessageId,
68
79
  queueOrder: item.queueOrder,
69
80
  isGuest: item.kind === "prompt" && item.guestQueryId !== undefined,
70
81
  queuePosition: index + 1,
71
- isPriority: item.queueLane === "priority",
82
+ queueLane: item.queueLane,
83
+ canSelectLane: editable && item.queueLane !== "control",
84
+ canSelectDisposition: editable && (item.queueLane !== "control" || item.sourceMessageIds.includes(item.replyToMessageId)),
85
+ dispatchProtected, attemptFrozen,
86
+ exceedsIdleCapacity: item.kind === "prompt" && item.queueLane === "steer" && !item.steerAttempted &&
87
+ !Queue.isTelegramSteerItemWithinIdleCapacity(item),
72
88
  priorityEmoji: item.kind === "prompt" ? item.priorityEmoji : undefined,
73
89
  reactionSuppressionEmoji:
74
90
  item.kind === "prompt" ? item.reactionSuppressionEmoji : undefined,
@@ -98,18 +114,16 @@ function buildTelegramQueueMenuReplyMarkup(
98
114
  const refreshRow = [{ text: "🔄 Refresh", callback_data: refreshData }];
99
115
  if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] };
100
116
  const rows = items.map((item) => {
101
- const prefix = item.reactionSuppressionEmoji
102
- ? `${item.reactionSuppressionEmoji} `
103
- : item.isPriority
104
- ? `${item.priorityEmoji ?? "⚡"} `
105
- : item.hasAttachments
106
- ? "📎 "
107
- : "";
117
+ const badge = getTelegramQueueMenuLaneBadge(item);
118
+ const prefix = item.reactionSuppressionEmoji ? `${item.reactionSuppressionEmoji} ` :
119
+ badge ? `${badge} ` : item.hasAttachments ? "📎 " : "";
108
120
  const position = item.reactionSuppressionEmoji
109
121
  ? formatSkippedTelegramQueuePosition(item.queuePosition)
110
122
  : String(item.queuePosition);
111
123
  const ordinalSeparator = item.reactionSuppressionEmoji ? "\u200A" : "";
112
- const label = `${position}${ordinalSeparator}. ${prefix}${item.statusSummary}`;
124
+ const state = item.attemptFrozen ? " · frozen" : item.dispatchProtected ? " · dispatch protected" :
125
+ item.exceedsIdleCapacity ? " · idle capacity" : "";
126
+ const label = `${position}${ordinalSeparator}. ${prefix}${item.statusSummary}${state}`;
113
127
  return [
114
128
  {
115
129
  text: label,
@@ -165,18 +179,27 @@ function escapeTelegramQueueMenuHtmlPreview(text: string): string {
165
179
  return truncated ? escaped + suffix : escaped;
166
180
  }
167
181
 
182
+ function getTelegramQueueMenuLaneBadge(item: Pick<TelegramQueueMenuItem, "queueLane" | "priorityEmoji">): string {
183
+ if (item.queueLane === "control") return "⚡";
184
+ if (item.queueLane === "steer") return "🔥";
185
+ if (item.queueLane !== "priority") return "";
186
+ const emoji = item.priorityEmoji?.replace(/\uFE0F/g, "");
187
+ return emoji === "❤" || emoji === "🕊" ? emoji : "👍";
188
+ }
189
+
168
190
  function getTelegramQueueMenuItemText(item: TelegramQueueMenuItem): string {
169
- const badge = item.reactionSuppressionEmoji
170
- ? ` ${item.reactionSuppressionEmoji}`
171
- : item.isPriority
172
- ? ` ${item.priorityEmoji ?? "⚡"}`
173
- : "";
191
+ const marker = item.reactionSuppressionEmoji ?? getTelegramQueueMenuLaneBadge(item);
192
+ const badge = marker ? ` ${marker}` : "";
174
193
  const position = item.reactionSuppressionEmoji
175
194
  ? `<s>${item.queuePosition}</s>.`
176
195
  : `<b>${item.queuePosition}.</b>`;
177
196
  const heading = `${position}${badge}`;
178
197
  const preview = `<pre>${escapeTelegramQueueMenuHtmlPreview(item.promptText)}</pre>`;
179
- return `${heading}\n${preview}`;
198
+ const note = item.attemptFrozen ? "\n<b>⚠️ Attempt frozen.</b>\nSDK/model use is unconfirmed; not editable or automatically retried." :
199
+ item.dispatchProtected ? "\n<b>⏳ Dispatch protected.</b>\nWaiting for agent_start; SDK/model use is unconfirmed." :
200
+ item.exceedsIdleCapacity ? "\n<b>⚠️ Idle Steer capacity exceeded.</b>\nEdit the source or choose Priority/Normal; source groups are never split." :
201
+ item.queueLane === "control" ? "\nControl lane is producer-owned." : "";
202
+ return `${heading}\n${preview}${note}`;
180
203
  }
181
204
 
182
205
  function buildTelegramGuestQueueItemSubmenuReplyMarkup(
@@ -195,37 +218,18 @@ function buildTelegramGuestQueueItemSubmenuReplyMarkup(
195
218
  };
196
219
  }
197
220
 
198
- function buildTelegramQueueItemSubmenuReplyMarkup(
199
- chatId: number,
200
- replyToMessageId: number,
201
- isPriority: boolean,
202
- isSkipped: boolean,
203
- ): TelegramQueueMenuReplyMarkup {
204
- return {
205
- inline_keyboard: [
206
- [{ text: "⬆️ Back", callback_data: "queue:list" }],
207
- [
208
- {
209
- text: isPriority ? "🟡 Priority" : "⚫️ Priority",
210
- callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:priority`,
211
- },
212
- {
213
- text: isPriority ? "⚫️ Normal" : "🔵 Normal",
214
- callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:normal`,
215
- },
216
- ],
217
- [
218
- {
219
- text: isSkipped ? "⚫️ Keep" : "🟢 Keep",
220
- callback_data: `queue:skip-set:${chatId}:${replyToMessageId}:keep`,
221
- },
222
- {
223
- text: isSkipped ? "🔴 Skip" : "⚫️ Skip",
224
- callback_data: `queue:skip-set:${chatId}:${replyToMessageId}:skip`,
225
- },
226
- ],
227
- ],
228
- };
221
+ function buildTelegramQueueItemSubmenuReplyMarkup(item: TelegramQueueMenuItem): TelegramQueueMenuReplyMarkup {
222
+ const rows: TelegramQueueMenuReplyMarkup["inline_keyboard"] = [[{ text: "⬆️ Back", callback_data: "queue:list" }]];
223
+ if (item.canSelectLane) rows.push([
224
+ { text: item.queueLane === "default" ? "🔵 Normal" : "⚫️ Normal", callback_data: `queue:lane-set:${item.chatId}:${item.replyToMessageId}:normal` },
225
+ { text: item.queueLane === "priority" ? "🟣 Priority" : "⚫️ Priority", callback_data: `queue:lane-set:${item.chatId}:${item.replyToMessageId}:priority` },
226
+ { text: item.queueLane === "steer" ? "🟡 Steer" : "⚫️ Steer", callback_data: `queue:lane-set:${item.chatId}:${item.replyToMessageId}:steer` },
227
+ ]);
228
+ if (item.canSelectDisposition) rows.push([
229
+ { text: item.reactionSuppressionEmoji ? "⚫️ Keep" : "🟢 Keep", callback_data: `queue:skip-set:${item.chatId}:${item.replyToMessageId}:keep` },
230
+ { text: item.reactionSuppressionEmoji ? "🔴 Skip" : "⚫️ Skip", callback_data: `queue:skip-set:${item.chatId}:${item.replyToMessageId}:skip` },
231
+ ]);
232
+ return { inline_keyboard: rows };
229
233
  }
230
234
 
231
235
  interface TelegramQueueMenuCallbackDeps<Context = unknown> {
@@ -236,12 +240,7 @@ interface TelegramQueueMenuCallbackDeps<Context = unknown> {
236
240
  ) => TelegramQueueMenuItem | undefined;
237
241
  findGuestItem: (queueOrder: number) => TelegramQueueMenuItem | undefined;
238
242
  skipGuest: (queueOrder: number, ctx: Context) => Promise<boolean>;
239
- togglePriority: (chatId: number, replyToMessageId: number) => boolean;
240
- setPriority: (
241
- chatId: number,
242
- replyToMessageId: number,
243
- enabled: boolean,
244
- ) => boolean;
243
+ setLane: (chatId: number, replyToMessageId: number, lane: Exclude<Queue.TelegramQueueLane, "control">) => boolean;
245
244
  setSkipped: (
246
245
  chatId: number,
247
246
  replyToMessageId: number,
@@ -307,7 +306,13 @@ async function handleTelegramQueueMenuCallback<Context>(
307
306
  }
308
307
  const guestSkipMatch = data.match(/^queue:guest-skip:(\d+)$/);
309
308
  if (guestSkipMatch) {
310
- const skipped = await deps.skipGuest(Number(guestSkipMatch[1]), ctx);
309
+ const queueOrder = Number(guestSkipMatch[1]);
310
+ const item = deps.findGuestItem(queueOrder);
311
+ if (item?.dispatchProtected || item?.attemptFrozen) {
312
+ await deps.answerCallbackQuery(callbackQueryId, "Queue item is protected");
313
+ return true;
314
+ }
315
+ const skipped = await deps.skipGuest(queueOrder, ctx);
311
316
  if (!skipped) {
312
317
  await refreshStaleTelegramQueueMenuItem(
313
318
  callbackQueryId,
@@ -339,16 +344,16 @@ async function handleTelegramQueueMenuCallback<Context>(
339
344
  return true;
340
345
  }
341
346
  const prioSetMatch = data.match(
342
- /^queue:prio-set:(\d+):(\d+):(priority|normal)$/,
347
+ /^queue:(?:lane-set|prio-set):(\d+):(\d+):(steer|priority|normal)$/,
343
348
  );
344
349
  if (prioSetMatch) {
345
- await handleTelegramQueueMenuPrioritySet(
350
+ await handleTelegramQueueMenuLaneSet(
346
351
  callbackQueryId,
347
352
  replyChatId,
348
353
  replyMessageId,
349
354
  Number(prioSetMatch[1]),
350
355
  Number(prioSetMatch[2]),
351
- prioSetMatch[3] === "priority",
356
+ prioSetMatch[3] === "normal" ? "default" : prioSetMatch[3] as "steer" | "priority",
352
357
  ctx,
353
358
  deps,
354
359
  );
@@ -448,7 +453,9 @@ async function handleTelegramGuestQueueMenuPick<Context>(
448
453
  replyChatId,
449
454
  replyMessageId,
450
455
  getTelegramQueueMenuItemText(item),
451
- buildTelegramGuestQueueItemSubmenuReplyMarkup(queueOrder),
456
+ item.dispatchProtected || item.attemptFrozen
457
+ ? buildTelegramQueueItemSubmenuReplyMarkup(item)
458
+ : buildTelegramGuestQueueItemSubmenuReplyMarkup(queueOrder),
452
459
  );
453
460
  await deps.answerCallbackQuery(callbackQueryId);
454
461
  }
@@ -474,12 +481,7 @@ async function handleTelegramQueueMenuPick<Context>(
474
481
  replyChatId,
475
482
  replyMessageId,
476
483
  getTelegramQueueMenuItemText(item),
477
- buildTelegramQueueItemSubmenuReplyMarkup(
478
- chatId,
479
- msgId,
480
- item.isPriority,
481
- item.reactionSuppressionEmoji !== undefined,
482
- ),
484
+ buildTelegramQueueItemSubmenuReplyMarkup(item),
483
485
  );
484
486
  await deps.answerCallbackQuery(callbackQueryId);
485
487
  }
@@ -502,25 +504,25 @@ async function handleTelegramQueueMenuPriority<Context>(
502
504
  deps,
503
505
  );
504
506
  }
505
- await updateTelegramQueueMenuPriority(
507
+ await updateTelegramQueueMenuLane(
506
508
  callbackQueryId,
507
509
  replyChatId,
508
510
  replyMessageId,
509
511
  chatId,
510
512
  msgId,
511
- !item.isPriority,
513
+ item.queueLane === "priority" ? "default" : "priority",
512
514
  ctx,
513
515
  deps,
514
516
  );
515
517
  }
516
518
 
517
- async function handleTelegramQueueMenuPrioritySet<Context>(
519
+ async function handleTelegramQueueMenuLaneSet<Context>(
518
520
  callbackQueryId: string,
519
521
  replyChatId: number,
520
522
  replyMessageId: number,
521
523
  chatId: number,
522
524
  msgId: number,
523
- enabled: boolean,
525
+ lane: Exclude<Queue.TelegramQueueLane, "control">,
524
526
  ctx: Context,
525
527
  deps: TelegramQueueMenuCallbackDeps<Context>,
526
528
  ): Promise<void> {
@@ -533,29 +535,32 @@ async function handleTelegramQueueMenuPrioritySet<Context>(
533
535
  deps,
534
536
  );
535
537
  }
536
- await updateTelegramQueueMenuPriority(
538
+ await updateTelegramQueueMenuLane(
537
539
  callbackQueryId,
538
540
  replyChatId,
539
541
  replyMessageId,
540
542
  chatId,
541
543
  msgId,
542
- enabled,
544
+ lane,
543
545
  ctx,
544
546
  deps,
545
547
  );
546
548
  }
547
549
 
548
- async function updateTelegramQueueMenuPriority<Context>(
550
+ async function updateTelegramQueueMenuLane<Context>(
549
551
  callbackQueryId: string,
550
552
  replyChatId: number,
551
553
  replyMessageId: number,
552
554
  chatId: number,
553
555
  msgId: number,
554
- enabled: boolean,
556
+ lane: Exclude<Queue.TelegramQueueLane, "control">,
555
557
  ctx: Context,
556
558
  deps: TelegramQueueMenuCallbackDeps<Context>,
557
559
  ): Promise<void> {
558
- deps.setPriority(chatId, msgId, enabled);
560
+ if (!deps.setLane(chatId, msgId, lane)) {
561
+ await deps.answerCallbackQuery(callbackQueryId, "Queue item is protected");
562
+ return;
563
+ }
559
564
  deps.updateStatus(ctx);
560
565
  const updated = deps.findItem(chatId, msgId);
561
566
  if (!updated) {
@@ -570,16 +575,11 @@ async function updateTelegramQueueMenuPriority<Context>(
570
575
  replyChatId,
571
576
  replyMessageId,
572
577
  getTelegramQueueMenuItemText(updated),
573
- buildTelegramQueueItemSubmenuReplyMarkup(
574
- chatId,
575
- msgId,
576
- updated.isPriority,
577
- updated.reactionSuppressionEmoji !== undefined,
578
- ),
578
+ buildTelegramQueueItemSubmenuReplyMarkup(updated),
579
579
  );
580
580
  await deps.answerCallbackQuery(
581
581
  callbackQueryId,
582
- updated.isPriority ? "Prioritized" : "Normal priority",
582
+ updated.queueLane === "steer" ? "Steer selected" : updated.queueLane === "priority" ? "Prioritized" : "Normal priority",
583
583
  );
584
584
  }
585
585
 
@@ -602,7 +602,10 @@ async function handleTelegramQueueMenuSkipSet<Context>(
602
602
  deps,
603
603
  );
604
604
  }
605
- deps.setSkipped(chatId, msgId, skipped);
605
+ if (!deps.setSkipped(chatId, msgId, skipped)) {
606
+ await deps.answerCallbackQuery(callbackQueryId, "Queue item is protected");
607
+ return;
608
+ }
606
609
  deps.updateStatus(ctx);
607
610
  const updated = deps.findItem(chatId, msgId);
608
611
  if (!updated) {
@@ -617,12 +620,7 @@ async function handleTelegramQueueMenuSkipSet<Context>(
617
620
  replyChatId,
618
621
  replyMessageId,
619
622
  getTelegramQueueMenuItemText(updated),
620
- buildTelegramQueueItemSubmenuReplyMarkup(
621
- chatId,
622
- msgId,
623
- updated.isPriority,
624
- updated.reactionSuppressionEmoji !== undefined,
625
- ),
623
+ buildTelegramQueueItemSubmenuReplyMarkup(updated),
626
624
  );
627
625
  await deps.answerCallbackQuery(callbackQueryId);
628
626
  }
@@ -649,6 +647,7 @@ export function createTelegramQueueMenuRuntime<
649
647
  Context,
650
648
  TModel extends MenuModel = MenuModel,
651
649
  >(deps: {
650
+ hasPendingDispatch?: () => boolean;
652
651
  telegramQueueStore: Queue.TelegramQueueStateStore<Context>;
653
652
  queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
654
653
  sendInteractiveMessage: TelegramMenuMessageRuntimeDeps["sendInteractiveMessage"];
@@ -695,6 +694,7 @@ export function createTelegramQueueMenuRuntime<
695
694
  );
696
695
  return createOpenQueueMenu<Context, TModel>({
697
696
  getQueuedItems: deps.telegramQueueStore.getQueuedItems,
697
+ hasPendingDispatch: deps.hasPendingDispatch,
698
698
  getModelMenuState: (chat, context) =>
699
699
  deps.getModelMenuState(chat, context, threadId),
700
700
  storeModelMenuState: delivery.storeModelMenuState,
@@ -704,6 +704,7 @@ export function createTelegramQueueMenuRuntime<
704
704
  })(chatId, replyToMessageId, ctx);
705
705
  },
706
706
  handleCallbackQuery: createQueueMenuCallbackHandler<Context, TModel>({
707
+ hasPendingDispatch: deps.hasPendingDispatch,
707
708
  telegramQueueStore: deps.telegramQueueStore,
708
709
  queueMutationRuntime: deps.queueMutationRuntime,
709
710
  editInteractiveMessage: editQueueMenuMessage,
@@ -721,6 +722,7 @@ function createOpenQueueMenu<
721
722
  TModel extends MenuModel = MenuModel,
722
723
  >(deps: {
723
724
  getQueuedItems: () => Queue.TelegramQueueItem<Context>[];
725
+ hasPendingDispatch?: () => boolean;
724
726
  getModelMenuState: (
725
727
  chatId: number,
726
728
  ctx: Context,
@@ -739,7 +741,7 @@ function createOpenQueueMenu<
739
741
  ctx: Context,
740
742
  ): Promise<void> => {
741
743
  const state = await deps.getModelMenuState(chatId, ctx);
742
- const menuItems = toTelegramQueueMenuItems(deps.getQueuedItems());
744
+ const menuItems = toTelegramQueueMenuItems(deps.getQueuedItems(), deps.hasPendingDispatch?.());
743
745
  const text = getTelegramQueueMenuListText(menuItems);
744
746
  const messageId = await deps.sendInteractiveMessage(
745
747
  chatId,
@@ -758,6 +760,7 @@ function createQueueMenuCallbackHandler<
758
760
  Context,
759
761
  TModel extends MenuModel = MenuModel,
760
762
  >(deps: {
763
+ hasPendingDispatch?: () => boolean;
761
764
  telegramQueueStore: Queue.TelegramQueueStateStore<Context>;
762
765
  queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
763
766
  editInteractiveMessage: (
@@ -797,7 +800,7 @@ function createQueueMenuCallbackHandler<
797
800
  return true;
798
801
  }
799
802
  const menuItems = toTelegramQueueMenuItems(
800
- deps.telegramQueueStore.getQueuedItems(),
803
+ deps.telegramQueueStore.getQueuedItems(), deps.hasPendingDispatch?.(),
801
804
  );
802
805
  await deps.editInteractiveMessage(
803
806
  chatId,
@@ -814,7 +817,7 @@ function createQueueMenuCallbackHandler<
814
817
  return deps.telegramQueueStore.getQueuedItems();
815
818
  };
816
819
  const toMenuItems = () => {
817
- return toTelegramQueueMenuItems(getQueueSnapshot());
820
+ return toTelegramQueueMenuItems(getQueueSnapshot(), deps.hasPendingDispatch?.());
818
821
  };
819
822
  const findItem = (cId: number, rId: number) => {
820
823
  return findTelegramQueueItem(toMenuItems(), cId, rId);
@@ -827,6 +830,7 @@ function createQueueMenuCallbackHandler<
827
830
  const flagDeps = {
828
831
  getQueueSnapshot,
829
832
  queueMutationRuntime: deps.queueMutationRuntime,
833
+ hasPendingDispatch: deps.hasPendingDispatch,
830
834
  };
831
835
  return handleTelegramQueueMenuCallback(
832
836
  query.id,
@@ -845,34 +849,10 @@ function createQueueMenuCallbackHandler<
845
849
  dismissGuestPlaceholder: deps.dismissGuestPlaceholder,
846
850
  });
847
851
  },
848
- togglePriority: (cId, rId) =>
849
- applyQueuedTelegramPromptMenuFlags(
850
- cId,
851
- rId,
852
- ctx,
853
- flagDeps,
854
- (item) => ({
855
- priority: item.queueLane !== "priority",
856
- skipped: isQueuedTelegramPromptSkipped(item),
857
- }),
858
- ),
859
- setPriority: (cId, rId, enabled) =>
860
- applyQueuedTelegramPromptMenuFlags(
861
- cId,
862
- rId,
863
- ctx,
864
- flagDeps,
865
- (item) => ({
866
- priority: enabled,
867
- skipped: isQueuedTelegramPromptSkipped(item),
868
- }),
869
- ),
852
+ setLane: (cId, rId, lane) =>
853
+ applyQueuedTelegramPromptMenuFlags(cId, rId, ctx, flagDeps, () => ({ lane })),
870
854
  setSkipped: (cId, rId, skipped) =>
871
- applyQueuedTelegramPromptMenuFlags(cId, rId, ctx, flagDeps, (item) =>
872
- item.kind === "prompt"
873
- ? { priority: item.queueLane === "priority", skipped }
874
- : undefined,
875
- ),
855
+ applyQueuedTelegramPromptMenuFlags(cId, rId, ctx, flagDeps, () => ({ skipped })),
876
856
  updateQueueMessage: deps.editInteractiveMessage,
877
857
  answerCallbackQuery: deps.answerCallbackQuery,
878
858
  updateStatus: deps.updateStatus,
@@ -911,43 +891,12 @@ async function skipQueuedTelegramGuestPrompt<Context>(
911
891
  return true;
912
892
  }
913
893
 
914
- function getQueueMenuReactionDisposition<Context>(
915
- item: Queue.TelegramQueueItem<Context>,
916
- priority: boolean,
917
- skipped: boolean,
918
- ): Queue.TelegramQueueReactionDisposition {
919
- if (priority && skipped) {
920
- return {
921
- kind: "priority-suppressed",
922
- priorityEmoji:
923
- item.kind === "prompt" ? (item.priorityEmoji ?? "⚡") : "⚡",
924
- suppressionEmoji:
925
- item.kind === "prompt" ? (item.reactionSuppressionEmoji ?? "👎") : "👎",
926
- };
927
- }
928
- if (priority) {
929
- return {
930
- kind: "priority",
931
- emoji: item.kind === "prompt" ? (item.priorityEmoji ?? "⚡") : "⚡",
932
- };
933
- }
934
- if (skipped) {
935
- return {
936
- kind: "suppressed",
937
- emoji:
938
- item.kind === "prompt" ? (item.reactionSuppressionEmoji ?? "👎") : "👎",
939
- };
940
- }
941
- return { kind: "default" };
942
- }
943
-
944
- function isQueuedTelegramPromptSkipped<Context>(
945
- item: Queue.TelegramQueueItem<Context>,
946
- ): boolean {
947
- return item.kind === "prompt" && item.reactionSuppressionEmoji !== undefined;
894
+ interface TelegramQueueMenuFlags {
895
+ lane?: Exclude<Queue.TelegramQueueLane, "control">;
896
+ skipped?: boolean;
948
897
  }
949
898
 
950
- /** Applies a menu Priority/Skip choice to one queued item through the reaction disposition path. */
899
+ /** Each menu selection changes one dimension through the existing Queue owner. */
951
900
  function applyQueuedTelegramPromptMenuFlags<Context>(
952
901
  chatId: number,
953
902
  replyToMessageId: number,
@@ -955,10 +904,9 @@ function applyQueuedTelegramPromptMenuFlags<Context>(
955
904
  deps: {
956
905
  getQueueSnapshot: () => Queue.TelegramQueueItem<Context>[];
957
906
  queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
907
+ hasPendingDispatch?: () => boolean;
958
908
  },
959
- chooseFlags: (
960
- item: Queue.TelegramQueueItem<Context>,
961
- ) => { priority: boolean; skipped: boolean } | undefined,
909
+ chooseFlags: (item: Queue.TelegramQueueItem<Context>) => TelegramQueueMenuFlags | undefined,
962
910
  ): boolean {
963
911
  const item = findTelegramQueueItem(
964
912
  deps.getQueueSnapshot(),
@@ -966,13 +914,19 @@ function applyQueuedTelegramPromptMenuFlags<Context>(
966
914
  replyToMessageId,
967
915
  );
968
916
  const flags = item ? chooseFlags(item) : undefined;
969
- if (!item || !flags) return false;
970
- deps.queueMutationRuntime.applyReactionByMessageId(
971
- replyToMessageId,
972
- getQueueMenuReactionDisposition(item, flags.priority, flags.skipped),
973
- ctx,
974
- );
975
- return true;
917
+ if (!item || item.kind !== "prompt" || !flags) return false;
918
+ const view = findTelegramQueueItem(toTelegramQueueMenuItems(deps.getQueueSnapshot(), deps.hasPendingDispatch?.()), chatId, replyToMessageId);
919
+ if (!view || (flags.lane !== undefined && !view.canSelectLane) || (flags.skipped !== undefined && !view.canSelectDisposition)) return false;
920
+ const disposition: Extract<Queue.TelegramQueueReactionDisposition, { kind: "reaction-transition" }> = { kind: "reaction-transition" };
921
+ if (flags.lane !== undefined) {
922
+ disposition.promptLane = flags.lane;
923
+ disposition.priorityEmoji = flags.lane === "steer" ? "🔥" : flags.lane === "priority" ?
924
+ getTelegramQueueMenuLaneBadge({ queueLane: "priority", priorityEmoji: item.queueLane === "priority" ? item.priorityEmoji : undefined }) : null;
925
+ }
926
+ if (flags.skipped !== undefined) disposition.suppressionEmoji = flags.skipped ? (item.reactionSuppressionEmoji ?? "👎") : null;
927
+ return deps.queueMutationRuntime.applyReactionByMessageId(replyToMessageId, disposition, ctx) ||
928
+ ((flags.lane === undefined || flags.lane === item.queueLane) &&
929
+ (flags.skipped === undefined || flags.skipped === (item.reactionSuppressionEmoji !== undefined)));
976
930
  }
977
931
 
978
932
  function createQueueMenuSendMessageAdapter(
package/lib/pi.ts CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  type SessionShutdownEvent,
21
21
  type SessionStartEvent,
22
22
  type SlashCommandInfo,
23
+ type TurnEndEvent,
24
+ type TurnEndEventResult,
23
25
  type UIPromptEndEvent,
24
26
  type UIPromptStartEvent,
25
27
  SettingsManager,
@@ -41,6 +43,8 @@ export type {
41
43
  SessionShutdownEvent,
42
44
  SessionStartEvent,
43
45
  SlashCommandInfo,
46
+ TurnEndEvent,
47
+ TurnEndEventResult,
44
48
  UIPromptEndEvent,
45
49
  UIPromptStartEvent,
46
50
  };
@@ -294,6 +298,10 @@ export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
294
298
  return ctx.isIdle();
295
299
  }
296
300
 
301
+ export function isExtensionContextAbortRequested(ctx: ExtensionContext): boolean {
302
+ return ctx.signal?.aborted === true;
303
+ }
304
+
297
305
  export function hasExtensionContextPendingMessages(
298
306
  ctx: ExtensionContext,
299
307
  ): boolean {
package/lib/polling.ts CHANGED
@@ -757,6 +757,7 @@ export interface TelegramThreadAwarePollingPorts<TContext, TOwner> {
757
757
  registerFollowerWithOwner: (
758
758
  ctx: TContext,
759
759
  owner: TOwner,
760
+ options?: { recoverConnection?: boolean },
760
761
  ) => Promise<boolean | undefined>;
761
762
  restoreFollowerWithOwner: (
762
763
  ctx: TContext,
@@ -776,6 +777,7 @@ export interface TelegramThreadAwarePollingControls<TContext, TOwner> {
776
777
  registerFollowerWithLeader: (
777
778
  ctx: TContext,
778
779
  owner: TOwner,
780
+ options?: { recoverConnection?: boolean },
779
781
  ) => Promise<boolean | undefined>;
780
782
  restoreFollowerWithLeader?: (
781
783
  ctx: TContext,
@@ -1180,9 +1182,10 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
1180
1182
  const registerFollowerWithOwner = async (
1181
1183
  ctx: TContext,
1182
1184
  owner: TOwner,
1185
+ options?: { recoverConnection?: boolean },
1183
1186
  ): Promise<boolean | undefined> => {
1184
1187
  if (!(await refreshFollowerState())) return undefined;
1185
- return deps.registerFollowerWithLeader(ctx, owner);
1188
+ return deps.registerFollowerWithLeader(ctx, owner, options);
1186
1189
  };
1187
1190
  const restoreFollowerWithOwner = async (
1188
1191
  ctx: TContext,
@@ -1303,7 +1306,9 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1303
1306
  if (threadModeEnabled) consecutiveDisabledProbes = 0;
1304
1307
  const botState = deps.topicTargetStore.getBotState();
1305
1308
  const current = botState.threadMode;
1306
- if (threadModeEnabled && current === "enabled") return;
1309
+ // Persisted capability is not proof that this owner started its local bus.
1310
+ if (threadModeEnabled && current === "enabled" &&
1311
+ (deps.getPollingStartedWithTelegramBus() || !deps.ownsLock(ctx))) return;
1307
1312
  if (!threadModeEnabled && current === "disabled") {
1308
1313
  if (
1309
1314
  !deps.ownsLock(ctx) ||