@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
@@ -265,36 +265,47 @@ export function createTelegramBridgeSessionLifecycleDeps(ports) {
265
265
  };
266
266
  }
267
267
  export function createTelegramBridgeSessionLifecycleAssembly(deps) {
268
- const isSessionActive = deps.contextStore.isCurrent;
269
268
  const suspendForReplacement = BusFollower.createTelegramBusFollowerSessionReplacementSuspender({
270
269
  registrationState: deps.follower.registrationState,
271
270
  instanceId: deps.follower.instanceId,
272
271
  suspendPolling: deps.follower.suspendPolling,
273
272
  recordRuntimeEvent: deps.follower.recordRuntimeEvent,
274
273
  });
275
- let preserveTarget = true;
276
- const queueLifecycle = Queue.createTelegramSessionLifecycleRuntime({
274
+ // A reused context can be current for a successor; inner awaits need this invocation's generation too.
275
+ const queueLifecycle = (ctx, generation, preserveTarget = true) => Queue.createTelegramSessionLifecycleRuntime({
277
276
  ...deps.queue,
278
- isSessionActive,
277
+ isSessionActive: () => deps.contextStore.isCurrent(ctx, generation),
279
278
  stopPolling: () => suspendForReplacement(preserveTarget),
280
279
  clearPendingMediaGroups: deps.services.suspendGroupedInput,
281
280
  });
282
281
  const servicesLifecycle = {
283
282
  async onSessionStart(event, ctx) {
283
+ const generation = deps.contextStore.getGeneration();
284
+ const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
284
285
  const resumeConnection = deps.services.connection?.prepare(event, ctx);
285
- await queueLifecycle.onSessionStart(event, ctx);
286
- if (!isSessionActive(ctx))
286
+ await queueLifecycle(ctx, generation).onSessionStart(event, ctx);
287
+ if (!isCurrent())
287
288
  return;
288
289
  deps.services.resumeGroupedInput(ctx);
289
290
  await deps.services.delivery.onSessionStart();
290
- if (!isSessionActive(ctx))
291
+ if (!isCurrent())
291
292
  return;
292
293
  if (resumeConnection)
293
294
  resumeConnection();
294
295
  else
295
296
  await deps.services.polling.onSessionStart(event, ctx);
297
+ if (!isCurrent())
298
+ return;
296
299
  deps.services.capabilityMonitor.start(ctx);
297
300
  deps.services.queueWatchdog.start(ctx);
301
+ await BusFollower.createTelegramBusFollowerSessionRefreshHook({
302
+ registrationState: deps.follower.registrationState,
303
+ registrationRuntime: deps.follower.registrationRuntime,
304
+ getLeaderState: deps.follower.getLeaderState,
305
+ isSessionActive: isCurrent,
306
+ updateStatus: deps.follower.updateStatus,
307
+ recordRuntimeEvent: deps.follower.recordRuntimeEvent,
308
+ })(event, ctx);
298
309
  },
299
310
  async onSessionShutdown(event, ctx) {
300
311
  const generation = deps.contextStore.getGeneration();
@@ -302,7 +313,7 @@ export function createTelegramBridgeSessionLifecycleAssembly(deps) {
302
313
  if (!isCurrent())
303
314
  return;
304
315
  deps.services.connection?.onSessionShutdown(event, ctx);
305
- preserveTarget = event.reason !== "resume";
316
+ const preserveTarget = event.reason !== "resume";
306
317
  let preserveThread;
307
318
  if (event.reason === "quit") {
308
319
  try {
@@ -321,7 +332,7 @@ export function createTelegramBridgeSessionLifecycleAssembly(deps) {
321
332
  return;
322
333
  deps.services.queueWatchdog.stop();
323
334
  deps.services.capabilityMonitor.stop();
324
- await queueLifecycle.onSessionShutdown(event, ctx);
335
+ await queueLifecycle(ctx, generation, preserveTarget).onSessionShutdown(event, ctx);
325
336
  if (!isCurrent())
326
337
  return;
327
338
  await deps.services.inboundWorker.onSessionShutdown();
@@ -337,17 +348,7 @@ export function createTelegramBridgeSessionLifecycleAssembly(deps) {
337
348
  }
338
349
  },
339
350
  };
340
- const followerLifecycle = appendTelegramLifecycleHooks(servicesLifecycle, {
341
- onSessionStart: BusFollower.createTelegramBusFollowerSessionRefreshHook({
342
- registrationState: deps.follower.registrationState,
343
- registrationRuntime: deps.follower.registrationRuntime,
344
- getLeaderState: deps.follower.getLeaderState,
345
- isSessionActive,
346
- updateStatus: deps.follower.updateStatus,
347
- recordRuntimeEvent: deps.follower.recordRuntimeEvent,
348
- }),
349
- }, isSessionActive);
350
- return createTelegramSessionGenerationFence(deps.contextStore, followerLifecycle);
351
+ return createTelegramSessionGenerationFence(deps.contextStore, servicesLifecycle);
351
352
  }
352
353
  function unrefTelegramLifecycleTimer(timer) {
353
354
  if (!timer || typeof timer !== "object")
@@ -605,6 +606,13 @@ export function registerTelegramLifecycleHooks(pi, deps) {
605
606
  return;
606
607
  await deps.onAgentStart(event, ctx);
607
608
  });
609
+ if (deps.onTurnEnd) {
610
+ pi.on("turn_end", (event, ctx) => {
611
+ if (!isActive(ctx))
612
+ return;
613
+ return deps.onTurnEnd?.(event, ctx);
614
+ });
615
+ }
608
616
  pi.on("tool_execution_start", async (event, ctx) => {
609
617
  if (!isActive(ctx))
610
618
  return;
@@ -24,6 +24,7 @@ interface TelegramQueueMenuRuntime<Context> {
24
24
  handleCallbackQuery: (query: TelegramQueueMenuCallbackQuery, ctx: Context) => Promise<boolean>;
25
25
  }
26
26
  export declare function createTelegramQueueMenuRuntime<Context, TModel extends MenuModel = MenuModel>(deps: {
27
+ hasPendingDispatch?: () => boolean;
27
28
  telegramQueueStore: Queue.TelegramQueueStateStore<Context>;
28
29
  queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
29
30
  sendInteractiveMessage: TelegramMenuMessageRuntimeDeps["sendInteractiveMessage"];
@@ -4,6 +4,7 @@
4
4
  * Owns queue-menu rendering, queue item callbacks, and queue-menu runtime adapters while core queue mechanics stay in queue
5
5
  */
6
6
  import { createTelegramMenuDelivery, } from "./menu-model.js";
7
+ import * as Queue from "./queue.js";
7
8
  const QUEUE_ITEM_PROMPT_HTML_LIMIT = 3600;
8
9
  const QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX = "\n… [truncated]";
9
10
  const EMPTY_QUEUE_REFRESH_TITLES = [
@@ -29,15 +30,25 @@ function getTelegramQueueItemPromptText(item) {
29
30
  .join("\n")
30
31
  .trim() || item.statusSummary);
31
32
  }
32
- function toTelegramQueueMenuItems(items) {
33
+ function toTelegramQueueMenuItems(items, pendingDispatch = false) {
34
+ const head = items[0];
33
35
  return items.map((item, index) => {
36
+ const dispatchProtected = pendingDispatch && (item === head ||
37
+ (head?.kind === "prompt" && head.steerPacketTail?.includes(item) === true));
38
+ const attemptFrozen = item.kind === "prompt" && item.steerAttempted === true && !dispatchProtected;
39
+ const editable = item.kind === "prompt" && item.guestQueryId === undefined && item.guestInlineMessageId === undefined && !dispatchProtected && !attemptFrozen;
34
40
  return {
35
41
  chatId: item.chatId,
36
42
  replyToMessageId: item.replyToMessageId,
37
43
  queueOrder: item.queueOrder,
38
44
  isGuest: item.kind === "prompt" && item.guestQueryId !== undefined,
39
45
  queuePosition: index + 1,
40
- isPriority: item.queueLane === "priority",
46
+ queueLane: item.queueLane,
47
+ canSelectLane: editable && item.queueLane !== "control",
48
+ canSelectDisposition: editable && (item.queueLane !== "control" || item.sourceMessageIds.includes(item.replyToMessageId)),
49
+ dispatchProtected, attemptFrozen,
50
+ exceedsIdleCapacity: item.kind === "prompt" && item.queueLane === "steer" && !item.steerAttempted &&
51
+ !Queue.isTelegramSteerItemWithinIdleCapacity(item),
41
52
  priorityEmoji: item.kind === "prompt" ? item.priorityEmoji : undefined,
42
53
  reactionSuppressionEmoji: item.kind === "prompt" ? item.reactionSuppressionEmoji : undefined,
43
54
  hasAttachments: item.kind === "prompt" && item.queuedAttachments.length > 0,
@@ -59,18 +70,16 @@ function buildTelegramQueueMenuReplyMarkup(items, emptyRefreshIndex = 0) {
59
70
  if (items.length === 0)
60
71
  return { inline_keyboard: [backRow, refreshRow] };
61
72
  const rows = items.map((item) => {
62
- const prefix = item.reactionSuppressionEmoji
63
- ? `${item.reactionSuppressionEmoji} `
64
- : item.isPriority
65
- ? `${item.priorityEmoji ?? "⚡"} `
66
- : item.hasAttachments
67
- ? "📎 "
68
- : "";
73
+ const badge = getTelegramQueueMenuLaneBadge(item);
74
+ const prefix = item.reactionSuppressionEmoji ? `${item.reactionSuppressionEmoji} ` :
75
+ badge ? `${badge} ` : item.hasAttachments ? "📎 " : "";
69
76
  const position = item.reactionSuppressionEmoji
70
77
  ? formatSkippedTelegramQueuePosition(item.queuePosition)
71
78
  : String(item.queuePosition);
72
79
  const ordinalSeparator = item.reactionSuppressionEmoji ? "\u200A" : "";
73
- const label = `${position}${ordinalSeparator}. ${prefix}${item.statusSummary}`;
80
+ const state = item.attemptFrozen ? " · frozen" : item.dispatchProtected ? " · dispatch protected" :
81
+ item.exceedsIdleCapacity ? " · idle capacity" : "";
82
+ const label = `${position}${ordinalSeparator}. ${prefix}${item.statusSummary}${state}`;
74
83
  return [
75
84
  {
76
85
  text: label,
@@ -114,18 +123,29 @@ function escapeTelegramQueueMenuHtmlPreview(text) {
114
123
  }
115
124
  return truncated ? escaped + suffix : escaped;
116
125
  }
126
+ function getTelegramQueueMenuLaneBadge(item) {
127
+ if (item.queueLane === "control")
128
+ return "⚡";
129
+ if (item.queueLane === "steer")
130
+ return "🔥";
131
+ if (item.queueLane !== "priority")
132
+ return "";
133
+ const emoji = item.priorityEmoji?.replace(/\uFE0F/g, "");
134
+ return emoji === "❤" || emoji === "🕊" ? emoji : "👍";
135
+ }
117
136
  function getTelegramQueueMenuItemText(item) {
118
- const badge = item.reactionSuppressionEmoji
119
- ? ` ${item.reactionSuppressionEmoji}`
120
- : item.isPriority
121
- ? ` ${item.priorityEmoji ?? "⚡"}`
122
- : "";
137
+ const marker = item.reactionSuppressionEmoji ?? getTelegramQueueMenuLaneBadge(item);
138
+ const badge = marker ? ` ${marker}` : "";
123
139
  const position = item.reactionSuppressionEmoji
124
140
  ? `<s>${item.queuePosition}</s>.`
125
141
  : `<b>${item.queuePosition}.</b>`;
126
142
  const heading = `${position}${badge}`;
127
143
  const preview = `<pre>${escapeTelegramQueueMenuHtmlPreview(item.promptText)}</pre>`;
128
- return `${heading}\n${preview}`;
144
+ const note = item.attemptFrozen ? "\n<b>⚠️ Attempt frozen.</b>\nSDK/model use is unconfirmed; not editable or automatically retried." :
145
+ item.dispatchProtected ? "\n<b>⏳ Dispatch protected.</b>\nWaiting for agent_start; SDK/model use is unconfirmed." :
146
+ item.exceedsIdleCapacity ? "\n<b>⚠️ Idle Steer capacity exceeded.</b>\nEdit the source or choose Priority/Normal; source groups are never split." :
147
+ item.queueLane === "control" ? "\nControl lane is producer-owned." : "";
148
+ return `${heading}\n${preview}${note}`;
129
149
  }
130
150
  function buildTelegramGuestQueueItemSubmenuReplyMarkup(queueOrder) {
131
151
  return {
@@ -140,32 +160,20 @@ function buildTelegramGuestQueueItemSubmenuReplyMarkup(queueOrder) {
140
160
  ],
141
161
  };
142
162
  }
143
- function buildTelegramQueueItemSubmenuReplyMarkup(chatId, replyToMessageId, isPriority, isSkipped) {
144
- return {
145
- inline_keyboard: [
146
- [{ text: "⬆️ Back", callback_data: "queue:list" }],
147
- [
148
- {
149
- text: isPriority ? "🟡 Priority" : "⚫️ Priority",
150
- callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:priority`,
151
- },
152
- {
153
- text: isPriority ? "⚫️ Normal" : "🔵 Normal",
154
- callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:normal`,
155
- },
156
- ],
157
- [
158
- {
159
- text: isSkipped ? "⚫️ Keep" : "🟢 Keep",
160
- callback_data: `queue:skip-set:${chatId}:${replyToMessageId}:keep`,
161
- },
162
- {
163
- text: isSkipped ? "🔴 Skip" : "⚫️ Skip",
164
- callback_data: `queue:skip-set:${chatId}:${replyToMessageId}:skip`,
165
- },
166
- ],
167
- ],
168
- };
163
+ function buildTelegramQueueItemSubmenuReplyMarkup(item) {
164
+ const rows = [[{ text: "⬆️ Back", callback_data: "queue:list" }]];
165
+ if (item.canSelectLane)
166
+ rows.push([
167
+ { text: item.queueLane === "default" ? "🔵 Normal" : "⚫️ Normal", callback_data: `queue:lane-set:${item.chatId}:${item.replyToMessageId}:normal` },
168
+ { text: item.queueLane === "priority" ? "🟣 Priority" : "⚫️ Priority", callback_data: `queue:lane-set:${item.chatId}:${item.replyToMessageId}:priority` },
169
+ { text: item.queueLane === "steer" ? "🟡 Steer" : "⚫️ Steer", callback_data: `queue:lane-set:${item.chatId}:${item.replyToMessageId}:steer` },
170
+ ]);
171
+ if (item.canSelectDisposition)
172
+ rows.push([
173
+ { text: item.reactionSuppressionEmoji ? "⚫️ Keep" : "🟢 Keep", callback_data: `queue:skip-set:${item.chatId}:${item.replyToMessageId}:keep` },
174
+ { text: item.reactionSuppressionEmoji ? "🔴 Skip" : "⚫️ Skip", callback_data: `queue:skip-set:${item.chatId}:${item.replyToMessageId}:skip` },
175
+ ]);
176
+ return { inline_keyboard: rows };
169
177
  }
170
178
  async function handleTelegramQueueMenuCallback(callbackQueryId, data, replyChatId, replyMessageId, ctx, deps) {
171
179
  if (!data.startsWith("queue:"))
@@ -190,7 +198,13 @@ async function handleTelegramQueueMenuCallback(callbackQueryId, data, replyChatI
190
198
  }
191
199
  const guestSkipMatch = data.match(/^queue:guest-skip:(\d+)$/);
192
200
  if (guestSkipMatch) {
193
- const skipped = await deps.skipGuest(Number(guestSkipMatch[1]), ctx);
201
+ const queueOrder = Number(guestSkipMatch[1]);
202
+ const item = deps.findGuestItem(queueOrder);
203
+ if (item?.dispatchProtected || item?.attemptFrozen) {
204
+ await deps.answerCallbackQuery(callbackQueryId, "Queue item is protected");
205
+ return true;
206
+ }
207
+ const skipped = await deps.skipGuest(queueOrder, ctx);
194
208
  if (!skipped) {
195
209
  await refreshStaleTelegramQueueMenuItem(callbackQueryId, replyChatId, replyMessageId, deps);
196
210
  return true;
@@ -203,9 +217,9 @@ async function handleTelegramQueueMenuCallback(callbackQueryId, data, replyChatI
203
217
  await handleTelegramQueueMenuPick(callbackQueryId, replyChatId, replyMessageId, Number(pickMatch[1]), Number(pickMatch[2]), deps);
204
218
  return true;
205
219
  }
206
- const prioSetMatch = data.match(/^queue:prio-set:(\d+):(\d+):(priority|normal)$/);
220
+ const prioSetMatch = data.match(/^queue:(?:lane-set|prio-set):(\d+):(\d+):(steer|priority|normal)$/);
207
221
  if (prioSetMatch) {
208
- await handleTelegramQueueMenuPrioritySet(callbackQueryId, replyChatId, replyMessageId, Number(prioSetMatch[1]), Number(prioSetMatch[2]), prioSetMatch[3] === "priority", ctx, deps);
222
+ await handleTelegramQueueMenuLaneSet(callbackQueryId, replyChatId, replyMessageId, Number(prioSetMatch[1]), Number(prioSetMatch[2]), prioSetMatch[3] === "normal" ? "default" : prioSetMatch[3], ctx, deps);
209
223
  return true;
210
224
  }
211
225
  const prioMatch = data.match(/^queue:prio:(\d+):(\d+)$/);
@@ -240,7 +254,9 @@ async function handleTelegramGuestQueueMenuPick(callbackQueryId, replyChatId, re
240
254
  if (!item) {
241
255
  return refreshStaleTelegramQueueMenuItem(callbackQueryId, replyChatId, replyMessageId, deps);
242
256
  }
243
- await deps.updateQueueMessage(replyChatId, replyMessageId, getTelegramQueueMenuItemText(item), buildTelegramGuestQueueItemSubmenuReplyMarkup(queueOrder));
257
+ await deps.updateQueueMessage(replyChatId, replyMessageId, getTelegramQueueMenuItemText(item), item.dispatchProtected || item.attemptFrozen
258
+ ? buildTelegramQueueItemSubmenuReplyMarkup(item)
259
+ : buildTelegramGuestQueueItemSubmenuReplyMarkup(queueOrder));
244
260
  await deps.answerCallbackQuery(callbackQueryId);
245
261
  }
246
262
  async function handleTelegramQueueMenuPick(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, deps) {
@@ -248,7 +264,7 @@ async function handleTelegramQueueMenuPick(callbackQueryId, replyChatId, replyMe
248
264
  if (!item) {
249
265
  return refreshStaleTelegramQueueMenuItem(callbackQueryId, replyChatId, replyMessageId, deps);
250
266
  }
251
- await deps.updateQueueMessage(replyChatId, replyMessageId, getTelegramQueueMenuItemText(item), buildTelegramQueueItemSubmenuReplyMarkup(chatId, msgId, item.isPriority, item.reactionSuppressionEmoji !== undefined));
267
+ await deps.updateQueueMessage(replyChatId, replyMessageId, getTelegramQueueMenuItemText(item), buildTelegramQueueItemSubmenuReplyMarkup(item));
252
268
  await deps.answerCallbackQuery(callbackQueryId);
253
269
  }
254
270
  async function handleTelegramQueueMenuPriority(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, ctx, deps) {
@@ -256,37 +272,43 @@ async function handleTelegramQueueMenuPriority(callbackQueryId, replyChatId, rep
256
272
  if (!item) {
257
273
  return refreshStaleTelegramQueueMenuItem(callbackQueryId, replyChatId, replyMessageId, deps);
258
274
  }
259
- await updateTelegramQueueMenuPriority(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, !item.isPriority, ctx, deps);
275
+ await updateTelegramQueueMenuLane(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, item.queueLane === "priority" ? "default" : "priority", ctx, deps);
260
276
  }
261
- async function handleTelegramQueueMenuPrioritySet(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, enabled, ctx, deps) {
277
+ async function handleTelegramQueueMenuLaneSet(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, lane, ctx, deps) {
262
278
  const item = deps.findItem(chatId, msgId);
263
279
  if (!item) {
264
280
  return refreshStaleTelegramQueueMenuItem(callbackQueryId, replyChatId, replyMessageId, deps);
265
281
  }
266
- await updateTelegramQueueMenuPriority(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, enabled, ctx, deps);
282
+ await updateTelegramQueueMenuLane(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, lane, ctx, deps);
267
283
  }
268
- async function updateTelegramQueueMenuPriority(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, enabled, ctx, deps) {
269
- deps.setPriority(chatId, msgId, enabled);
284
+ async function updateTelegramQueueMenuLane(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, lane, ctx, deps) {
285
+ if (!deps.setLane(chatId, msgId, lane)) {
286
+ await deps.answerCallbackQuery(callbackQueryId, "Queue item is protected");
287
+ return;
288
+ }
270
289
  deps.updateStatus(ctx);
271
290
  const updated = deps.findItem(chatId, msgId);
272
291
  if (!updated) {
273
292
  return refreshStaleTelegramQueueMenuItem(callbackQueryId, replyChatId, replyMessageId, deps);
274
293
  }
275
- await deps.updateQueueMessage(replyChatId, replyMessageId, getTelegramQueueMenuItemText(updated), buildTelegramQueueItemSubmenuReplyMarkup(chatId, msgId, updated.isPriority, updated.reactionSuppressionEmoji !== undefined));
276
- await deps.answerCallbackQuery(callbackQueryId, updated.isPriority ? "Prioritized" : "Normal priority");
294
+ await deps.updateQueueMessage(replyChatId, replyMessageId, getTelegramQueueMenuItemText(updated), buildTelegramQueueItemSubmenuReplyMarkup(updated));
295
+ await deps.answerCallbackQuery(callbackQueryId, updated.queueLane === "steer" ? "Steer selected" : updated.queueLane === "priority" ? "Prioritized" : "Normal priority");
277
296
  }
278
297
  async function handleTelegramQueueMenuSkipSet(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, skipped, ctx, deps) {
279
298
  const item = deps.findItem(chatId, msgId);
280
299
  if (!item) {
281
300
  return refreshStaleTelegramQueueMenuItem(callbackQueryId, replyChatId, replyMessageId, deps);
282
301
  }
283
- deps.setSkipped(chatId, msgId, skipped);
302
+ if (!deps.setSkipped(chatId, msgId, skipped)) {
303
+ await deps.answerCallbackQuery(callbackQueryId, "Queue item is protected");
304
+ return;
305
+ }
284
306
  deps.updateStatus(ctx);
285
307
  const updated = deps.findItem(chatId, msgId);
286
308
  if (!updated) {
287
309
  return refreshStaleTelegramQueueMenuItem(callbackQueryId, replyChatId, replyMessageId, deps);
288
310
  }
289
- await deps.updateQueueMessage(replyChatId, replyMessageId, getTelegramQueueMenuItemText(updated), buildTelegramQueueItemSubmenuReplyMarkup(chatId, msgId, updated.isPriority, updated.reactionSuppressionEmoji !== undefined));
311
+ await deps.updateQueueMessage(replyChatId, replyMessageId, getTelegramQueueMenuItemText(updated), buildTelegramQueueItemSubmenuReplyMarkup(updated));
290
312
  await deps.answerCallbackQuery(callbackQueryId);
291
313
  }
292
314
  export function createTelegramQueueMenuRuntime(deps) {
@@ -297,6 +319,7 @@ export function createTelegramQueueMenuRuntime(deps) {
297
319
  const delivery = createTelegramMenuDelivery({ chatId, threadId }, options, deps);
298
320
  return createOpenQueueMenu({
299
321
  getQueuedItems: deps.telegramQueueStore.getQueuedItems,
322
+ hasPendingDispatch: deps.hasPendingDispatch,
300
323
  getModelMenuState: (chat, context) => deps.getModelMenuState(chat, context, threadId),
301
324
  storeModelMenuState: delivery.storeModelMenuState,
302
325
  sendInteractiveMessage: delivery.assertAuthority
@@ -305,6 +328,7 @@ export function createTelegramQueueMenuRuntime(deps) {
305
328
  })(chatId, replyToMessageId, ctx);
306
329
  },
307
330
  handleCallbackQuery: createQueueMenuCallbackHandler({
331
+ hasPendingDispatch: deps.hasPendingDispatch,
308
332
  telegramQueueStore: deps.telegramQueueStore,
309
333
  queueMutationRuntime: deps.queueMutationRuntime,
310
334
  editInteractiveMessage: editQueueMenuMessage,
@@ -319,7 +343,7 @@ export function createTelegramQueueMenuRuntime(deps) {
319
343
  function createOpenQueueMenu(deps) {
320
344
  return async (chatId, replyToMessageId, ctx) => {
321
345
  const state = await deps.getModelMenuState(chatId, ctx);
322
- const menuItems = toTelegramQueueMenuItems(deps.getQueuedItems());
346
+ const menuItems = toTelegramQueueMenuItems(deps.getQueuedItems(), deps.hasPendingDispatch?.());
323
347
  const text = getTelegramQueueMenuListText(menuItems);
324
348
  const messageId = await deps.sendInteractiveMessage(chatId, replyToMessageId, text, buildTelegramQueueMenuReplyMarkup(menuItems));
325
349
  if (messageId === undefined)
@@ -342,7 +366,7 @@ function createQueueMenuCallbackHandler(deps) {
342
366
  await deps.answerCallbackQuery(query.id, "Interactive message expired");
343
367
  return true;
344
368
  }
345
- const menuItems = toTelegramQueueMenuItems(deps.telegramQueueStore.getQueuedItems());
369
+ const menuItems = toTelegramQueueMenuItems(deps.telegramQueueStore.getQueuedItems(), deps.hasPendingDispatch?.());
346
370
  await deps.editInteractiveMessage(chatId, messageId, getTelegramQueueMenuListText(menuItems), buildTelegramQueueMenuReplyMarkup(menuItems));
347
371
  state.mode = "queue";
348
372
  await deps.answerCallbackQuery(query.id);
@@ -354,7 +378,7 @@ function createQueueMenuCallbackHandler(deps) {
354
378
  return deps.telegramQueueStore.getQueuedItems();
355
379
  };
356
380
  const toMenuItems = () => {
357
- return toTelegramQueueMenuItems(getQueueSnapshot());
381
+ return toTelegramQueueMenuItems(getQueueSnapshot(), deps.hasPendingDispatch?.());
358
382
  };
359
383
  const findItem = (cId, rId) => {
360
384
  return findTelegramQueueItem(toMenuItems(), cId, rId);
@@ -367,6 +391,7 @@ function createQueueMenuCallbackHandler(deps) {
367
391
  const flagDeps = {
368
392
  getQueueSnapshot,
369
393
  queueMutationRuntime: deps.queueMutationRuntime,
394
+ hasPendingDispatch: deps.hasPendingDispatch,
370
395
  };
371
396
  return handleTelegramQueueMenuCallback(query.id, data, chatId, messageId, ctx, {
372
397
  getQueuedItems: toMenuItems,
@@ -379,17 +404,8 @@ function createQueueMenuCallbackHandler(deps) {
379
404
  dismissGuestPlaceholder: deps.dismissGuestPlaceholder,
380
405
  });
381
406
  },
382
- togglePriority: (cId, rId) => applyQueuedTelegramPromptMenuFlags(cId, rId, ctx, flagDeps, (item) => ({
383
- priority: item.queueLane !== "priority",
384
- skipped: isQueuedTelegramPromptSkipped(item),
385
- })),
386
- setPriority: (cId, rId, enabled) => applyQueuedTelegramPromptMenuFlags(cId, rId, ctx, flagDeps, (item) => ({
387
- priority: enabled,
388
- skipped: isQueuedTelegramPromptSkipped(item),
389
- })),
390
- setSkipped: (cId, rId, skipped) => applyQueuedTelegramPromptMenuFlags(cId, rId, ctx, flagDeps, (item) => item.kind === "prompt"
391
- ? { priority: item.queueLane === "priority", skipped }
392
- : undefined),
407
+ setLane: (cId, rId, lane) => applyQueuedTelegramPromptMenuFlags(cId, rId, ctx, flagDeps, () => ({ lane })),
408
+ setSkipped: (cId, rId, skipped) => applyQueuedTelegramPromptMenuFlags(cId, rId, ctx, flagDeps, () => ({ skipped })),
393
409
  updateQueueMessage: deps.editInteractiveMessage,
394
410
  answerCallbackQuery: deps.answerCallbackQuery,
395
411
  updateStatus: deps.updateStatus,
@@ -413,39 +429,26 @@ async function skipQueuedTelegramGuestPrompt(queueOrder, ctx, deps) {
413
429
  }
414
430
  return true;
415
431
  }
416
- function getQueueMenuReactionDisposition(item, priority, skipped) {
417
- if (priority && skipped) {
418
- return {
419
- kind: "priority-suppressed",
420
- priorityEmoji: item.kind === "prompt" ? (item.priorityEmoji ?? "⚡") : "⚡",
421
- suppressionEmoji: item.kind === "prompt" ? (item.reactionSuppressionEmoji ?? "👎") : "👎",
422
- };
423
- }
424
- if (priority) {
425
- return {
426
- kind: "priority",
427
- emoji: item.kind === "prompt" ? (item.priorityEmoji ?? "⚡") : "⚡",
428
- };
429
- }
430
- if (skipped) {
431
- return {
432
- kind: "suppressed",
433
- emoji: item.kind === "prompt" ? (item.reactionSuppressionEmoji ?? "👎") : "👎",
434
- };
435
- }
436
- return { kind: "default" };
437
- }
438
- function isQueuedTelegramPromptSkipped(item) {
439
- return item.kind === "prompt" && item.reactionSuppressionEmoji !== undefined;
440
- }
441
- /** Applies a menu Priority/Skip choice to one queued item through the reaction disposition path. */
432
+ /** Each menu selection changes one dimension through the existing Queue owner. */
442
433
  function applyQueuedTelegramPromptMenuFlags(chatId, replyToMessageId, ctx, deps, chooseFlags) {
443
434
  const item = findTelegramQueueItem(deps.getQueueSnapshot(), chatId, replyToMessageId);
444
435
  const flags = item ? chooseFlags(item) : undefined;
445
- if (!item || !flags)
436
+ if (!item || item.kind !== "prompt" || !flags)
446
437
  return false;
447
- deps.queueMutationRuntime.applyReactionByMessageId(replyToMessageId, getQueueMenuReactionDisposition(item, flags.priority, flags.skipped), ctx);
448
- return true;
438
+ const view = findTelegramQueueItem(toTelegramQueueMenuItems(deps.getQueueSnapshot(), deps.hasPendingDispatch?.()), chatId, replyToMessageId);
439
+ if (!view || (flags.lane !== undefined && !view.canSelectLane) || (flags.skipped !== undefined && !view.canSelectDisposition))
440
+ return false;
441
+ const disposition = { kind: "reaction-transition" };
442
+ if (flags.lane !== undefined) {
443
+ disposition.promptLane = flags.lane;
444
+ disposition.priorityEmoji = flags.lane === "steer" ? "🔥" : flags.lane === "priority" ?
445
+ getTelegramQueueMenuLaneBadge({ queueLane: "priority", priorityEmoji: item.queueLane === "priority" ? item.priorityEmoji : undefined }) : null;
446
+ }
447
+ if (flags.skipped !== undefined)
448
+ disposition.suppressionEmoji = flags.skipped ? (item.reactionSuppressionEmoji ?? "👎") : null;
449
+ return deps.queueMutationRuntime.applyReactionByMessageId(replyToMessageId, disposition, ctx) ||
450
+ ((flags.lane === undefined || flags.lane === item.queueLane) &&
451
+ (flags.skipped === undefined || flags.skipped === (item.reactionSuppressionEmoji !== undefined)));
449
452
  }
450
453
  function createQueueMenuSendMessageAdapter(sendInteractiveMessage) {
451
454
  return (chatId, _replyToMessageId, text, replyMarkup) => {
package/dist/lib/pi.d.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  * Owns direct pi SDK imports and exposes narrow bridge-facing helpers/types for the extension composition layer
5
5
  */
6
6
  import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
7
- import { type AgentEndEvent, type AgentSettledEvent, type AgentStartEvent, type BeforeAgentStartEvent, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type InputEvent, type MessageEndEvent, type SessionBeforeCompactEvent, type SessionCompactEvent, type SessionShutdownEvent, type SessionStartEvent, type SlashCommandInfo, type UIPromptEndEvent, type UIPromptStartEvent } from "@earendil-works/pi-coding-agent";
8
- export type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, ExtensionAPI, ExtensionCommandContext, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionShutdownEvent, SessionStartEvent, SlashCommandInfo, UIPromptEndEvent, UIPromptStartEvent, };
7
+ import { type AgentEndEvent, type AgentSettledEvent, type AgentStartEvent, type BeforeAgentStartEvent, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type InputEvent, type MessageEndEvent, type SessionBeforeCompactEvent, type SessionCompactEvent, type SessionShutdownEvent, type SessionStartEvent, type SlashCommandInfo, type TurnEndEvent, type TurnEndEventResult, type UIPromptEndEvent, type UIPromptStartEvent } from "@earendil-works/pi-coding-agent";
8
+ export type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, ExtensionAPI, ExtensionCommandContext, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionShutdownEvent, SessionStartEvent, SlashCommandInfo, TurnEndEvent, TurnEndEventResult, UIPromptEndEvent, UIPromptStartEvent, };
9
9
  export interface SessionCompactFailedEvent {
10
10
  type: "session_compact_failed";
11
11
  reason: "manual" | "threshold" | "overflow";
@@ -72,5 +72,6 @@ export declare function getExtensionContextCwd(ctx: ExtensionContext): string;
72
72
  export declare function getExtensionContextSessionId(ctx: ExtensionContext): string;
73
73
  export declare function getExtensionContextSessionId(ctx: ExtensionContext | undefined): string | undefined;
74
74
  export declare function isExtensionContextIdle(ctx: ExtensionContext): boolean;
75
+ export declare function isExtensionContextAbortRequested(ctx: ExtensionContext): boolean;
75
76
  export declare function hasExtensionContextPendingMessages(ctx: ExtensionContext): boolean;
76
77
  export declare function compactExtensionContext(ctx: ExtensionContext, callbacks: Parameters<ExtensionContext["compact"]>[0]): ReturnType<ExtensionContext["compact"]>;
package/dist/lib/pi.js CHANGED
@@ -122,6 +122,9 @@ export function getExtensionContextSessionId(ctx) {
122
122
  export function isExtensionContextIdle(ctx) {
123
123
  return ctx.isIdle();
124
124
  }
125
+ export function isExtensionContextAbortRequested(ctx) {
126
+ return ctx.signal?.aborted === true;
127
+ }
125
128
  export function hasExtensionContextPendingMessages(ctx) {
126
129
  return ctx.hasPendingMessages();
127
130
  }
@@ -844,7 +844,9 @@ export function createTelegramThreadCapabilityMonitor(deps) {
844
844
  consecutiveDisabledProbes = 0;
845
845
  const botState = deps.topicTargetStore.getBotState();
846
846
  const current = botState.threadMode;
847
- if (threadModeEnabled && current === "enabled")
847
+ // Persisted capability is not proof that this owner started its local bus.
848
+ if (threadModeEnabled && current === "enabled" &&
849
+ (deps.getPollingStartedWithTelegramBus() || !deps.ownsLock(ctx)))
848
850
  return;
849
851
  if (!threadModeEnabled && current === "disabled") {
850
852
  if (!deps.ownsLock(ctx) ||