@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/dist/lib/queue.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Telegram queue core contracts and pure planning helpers
3
3
  * Zones: telegram queue, pi agent lifecycle, scheduling
4
- * Owns queue item contracts, lane admission, pure queue mutations, and dispatch planning
4
+ * Owns queue item contracts, lane admission, mutations, dispatch planning, and protected Steer publication attempts
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
7
  import { isPiStaleContextError } from "./pi.js";
@@ -18,16 +18,22 @@ export const TELEGRAM_QUEUE_LANE_CONTRACTS = [
18
18
  dispatchRank: 0,
19
19
  allowedKinds: ["control", "prompt"],
20
20
  },
21
+ {
22
+ lane: "steer",
23
+ admissionMode: "steer-queue",
24
+ dispatchRank: 1,
25
+ allowedKinds: ["prompt"],
26
+ },
21
27
  {
22
28
  lane: "priority",
23
29
  admissionMode: "priority-queue",
24
- dispatchRank: 1,
30
+ dispatchRank: 2,
25
31
  allowedKinds: ["prompt"],
26
32
  },
27
33
  {
28
34
  lane: "default",
29
35
  admissionMode: "default-queue",
30
- dispatchRank: 2,
36
+ dispatchRank: 3,
31
37
  allowedKinds: ["prompt"],
32
38
  },
33
39
  ];
@@ -59,7 +65,8 @@ function parseQueuePayloadReceipt(value, kind) {
59
65
  /** Data decoder shared by authenticated IPC and cold storage; decoding grants no admission. */
60
66
  export function parseTelegramQueueHandoffPayload(value) {
61
67
  if (!isQueuePayloadRecord(value) ||
62
- (value.kind !== "prompt" && value.kind !== "control"))
68
+ (value.kind !== "prompt" && value.kind !== "control") ||
69
+ value.steerAttempted !== undefined || value.steerPacketTail !== undefined || value.steerPacketSourceOrders !== undefined)
63
70
  return undefined;
64
71
  const target = isQueuePayloadRecord(value.target) &&
65
72
  typeof value.target.chatId === "number"
@@ -88,6 +95,7 @@ export function parseTelegramQueueHandoffPayload(value) {
88
95
  typeof value.guestInlineMessageId !== "string") ||
89
96
  !Number.isSafeInteger(value.queueOrder) ||
90
97
  (value.queueLane !== "control" &&
98
+ value.queueLane !== "steer" &&
91
99
  value.queueLane !== "priority" &&
92
100
  value.queueLane !== "default") ||
93
101
  !Number.isSafeInteger(value.laneOrder) ||
@@ -123,6 +131,7 @@ export function parseTelegramQueueHandoffPayload(value) {
123
131
  : undefined;
124
132
  }
125
133
  if (value.queueLane === "control" ||
134
+ (value.queueLane === "steer" && (value.guestQueryId !== undefined || value.guestInlineMessageId !== undefined)) ||
126
135
  !Array.isArray(value.sourceMessageIds) ||
127
136
  value.sourceMessageIds.some((id) => !Number.isSafeInteger(id)) ||
128
137
  !Array.isArray(value.queuedAttachments) ||
@@ -282,7 +291,8 @@ function isTelegramQueueItemSkipped(item) {
282
291
  return item.kind === "prompt" && Boolean(item.reactionSuppressionEmoji);
283
292
  }
284
293
  export function countExecutableTelegramQueueItems(items) {
285
- return items.filter((item) => !isTelegramQueueItemSkipped(item)).length;
294
+ return items.filter((item) => !isTelegramQueueItemSkipped(item) &&
295
+ !(item.kind === "prompt" && item.steerAttempted)).length;
286
296
  }
287
297
  export function createTelegramQueueItemCountGetter(store) {
288
298
  return () => countExecutableTelegramQueueItems(store.getQueuedItems());
@@ -349,7 +359,7 @@ export function partitionTelegramQueueItemsForHistory(items) {
349
359
  const historyTurns = [];
350
360
  const remainingItems = [];
351
361
  for (const item of items) {
352
- if (isPendingTelegramTurn(item)) {
362
+ if (isPendingTelegramTurn(item) && item.queueLane !== "steer" && !item.steerAttempted) {
353
363
  historyTurns.push(item);
354
364
  continue;
355
365
  }
@@ -417,7 +427,7 @@ export function createTelegramQueueHandoff(input) {
417
427
  }
418
428
  const handoff = {
419
429
  handoffToken: input.handoffToken,
420
- payload: createTelegramQueueHandoffPayload(input.item),
430
+ payload: createTelegramQueueHandoffPayload(input.item, { steerSupported: input.steerSupported }),
421
431
  };
422
432
  if (Buffer.byteLength(JSON.stringify(handoff)) >
423
433
  TELEGRAM_QUEUE_HANDOFF_PAYLOAD_MAX_BYTES) {
@@ -425,8 +435,17 @@ export function createTelegramQueueHandoff(input) {
425
435
  }
426
436
  return handoff;
427
437
  }
428
- export function createTelegramQueueHandoffPayload(item) {
438
+ export function createTelegramQueueHandoffPayload(item, options = {}) {
429
439
  assertTelegramQueueItemAdmissionValid(item);
440
+ if (item.kind === "prompt" && (item.steerAttempted || item.steerPacketTail !== undefined || item.steerPacketSourceOrders !== undefined)) {
441
+ throw new Error("Attempted Steer work cannot be transferred.");
442
+ }
443
+ if (item.queueLane === "steer" && (item.guestQueryId !== undefined || item.guestInlineMessageId !== undefined)) {
444
+ throw new Error("Guest prompts cannot use Steer queue transfer.");
445
+ }
446
+ if (item.queueLane === "steer" && options.steerSupported !== true) {
447
+ throw new Error("Steer queue transfer requires compatible lane support.");
448
+ }
430
449
  if (!item.admissionReceipts?.length) {
431
450
  throw new Error("Telegram queue handoff requires durable admission receipts.");
432
451
  }
@@ -639,6 +658,7 @@ export function removeTelegramQueueItemsByMessageIds(items, messageIds, scope) {
639
658
  const removedItems = [];
640
659
  for (const item of items) {
641
660
  const shouldRemove = isPendingTelegramTurn(item) &&
661
+ !item.steerAttempted &&
642
662
  isTelegramQueueItemInMessageScope(item, scope) &&
643
663
  item.sourceMessageIds.some((messageId) => deletedMessageIds.has(messageId));
644
664
  (shouldRemove ? removedItems : nextItems).push(item);
@@ -665,9 +685,15 @@ function removeTelegramQueuedGuestPromptByOrder(items, queueOrder) {
665
685
  };
666
686
  }
667
687
  export function applyTelegramQueuePromptReactionDisposition(items, messageId, disposition, destinationLaneOrder, scope) {
688
+ if (disposition.kind === "reaction-transition" &&
689
+ disposition.promptLane !== undefined &&
690
+ !["steer", "priority", "default"].includes(disposition.promptLane)) {
691
+ throw new Error("Telegram prompt lane selection is invalid.");
692
+ }
668
693
  let nextItems = items;
669
694
  for (const [index, item] of items.entries()) {
670
695
  if (!isPendingTelegramTurn(item) ||
696
+ item.steerAttempted ||
671
697
  item.queueLane === "control" ||
672
698
  !isTelegramQueueItemInMessageScope(item, scope) ||
673
699
  !item.sourceMessageIds.includes(messageId)) {
@@ -679,7 +705,12 @@ export function applyTelegramQueuePromptReactionDisposition(items, messageId, di
679
705
  : disposition.priorityEmoji !== null
680
706
  : disposition.kind === "priority" ||
681
707
  disposition.kind === "priority-suppressed";
682
- const queueLane = isPriority ? "priority" : "default";
708
+ const queueLane = disposition.kind === "reaction-transition"
709
+ ? (disposition.promptLane ??
710
+ (disposition.priorityEmoji === undefined
711
+ ? item.queueLane
712
+ : isPriority ? "priority" : "default"))
713
+ : isPriority ? "priority" : "default";
683
714
  const laneOrder = item.queueLane === queueLane ? item.laneOrder : destinationLaneOrder;
684
715
  if (laneOrder === undefined) {
685
716
  throw new Error("Telegram destination lane order is unavailable.");
@@ -728,12 +759,13 @@ export function consumeDispatchedTelegramPrompt(items, hasPendingDispatch) {
728
759
  if (!nextItem || !isPendingTelegramTurn(nextItem)) {
729
760
  return { activeTurn: undefined, remainingItems: items };
730
761
  }
762
+ if (nextItem.steerPacketTail !== undefined) {
763
+ const packet = [nextItem, ...nextItem.steerPacketTail];
764
+ const originals = new Set(packet);
765
+ return { activeTurn: projectTelegramSteerPacketTurn(packet), remainingItems: items.filter(item => !originals.has(item)) };
766
+ }
731
767
  return { activeTurn: nextItem, remainingItems: items.slice(1) };
732
768
  }
733
- export function formatQueuedTelegramItemsStatus(items) {
734
- const count = countExecutableTelegramQueueItems(items);
735
- return count === 0 ? "" : ` +${count}`;
736
- }
737
769
  export function truncateTelegramQueueSummary(text, maxWords = 5, maxLength = 40) {
738
770
  const normalized = text.replace(/\s+/g, " ").trim();
739
771
  if (!normalized)
@@ -789,6 +821,170 @@ export function createTelegramControlItemBuilder(deps) {
789
821
  laneOrder: deps.allocateControlOrder(),
790
822
  });
791
823
  }
824
+ /** Plan a fixed ready prefix; publication/settlement remain with the Queue effect shell. */
825
+ export function planTelegramSteerPacket(items, options) {
826
+ if (options.blocked)
827
+ return { kind: "none" };
828
+ const ordered = [...items].sort(compareTelegramQueueItems);
829
+ if (ordered.some((item) => item.queueLane === "control" && options.isItemReady(item))) {
830
+ return { kind: "none" };
831
+ }
832
+ const packet = [];
833
+ for (const item of ordered) {
834
+ if (item.queueLane !== "steer" || !isPendingTelegramTurn(item))
835
+ continue;
836
+ if (item.steerAttempted || !options.isItemReady(item) || item.guestQueryId !== undefined || item.guestInlineMessageId !== undefined)
837
+ break;
838
+ if (item.reactionSuppressionEmoji) {
839
+ if (packet.length === 0)
840
+ return { kind: "skip", item };
841
+ break;
842
+ }
843
+ const first = packet[0];
844
+ if (first && !isSameTelegramPromptNamespace(first, item))
845
+ break;
846
+ if (options.canAppend && !options.canAppend(packet, item))
847
+ break;
848
+ packet.push(item);
849
+ }
850
+ return packet.length > 0 ? { kind: "packet", items: packet } : { kind: "none" };
851
+ }
852
+ function buildTelegramSteerPacketContent(packet, idleTask = false) {
853
+ const content = packet.flatMap((item, index) => [
854
+ ...(index > 0 ? [{ type: "text", text: "\n\n" }] : []), ...item.content,
855
+ ]);
856
+ // Ordinary idle prompts must retain origin detection by before_agent_start.
857
+ return idleTask ? [{ type: "text", text: "[telegram]\n" }, ...content] : content;
858
+ }
859
+ function createTelegramSteerPacketBudget(idleTask = false) {
860
+ let packetBytes = idleTask ? 64 : 0;
861
+ return (packet, item) => {
862
+ const bytes = Buffer.byteLength(JSON.stringify(item.content)) +
863
+ (packet.length > 0 ? Buffer.byteLength("\n\n") : 0) + 64;
864
+ if (packetBytes + bytes > TELEGRAM_QUEUE_HANDOFF_PAYLOAD_MAX_BYTES)
865
+ return false;
866
+ packetBytes += bytes;
867
+ return true;
868
+ };
869
+ }
870
+ export function isTelegramSteerItemWithinIdleCapacity(item) {
871
+ return createTelegramSteerPacketBudget(true)([], item);
872
+ }
873
+ function projectTelegramSteerPacketTurn(packet) {
874
+ const first = packet[0];
875
+ if (!first)
876
+ throw new Error("Steer packet cannot be empty.");
877
+ const projected = {
878
+ ...first,
879
+ content: buildTelegramSteerPacketContent(packet, true),
880
+ historyText: packet.map(item => item.historyText).join("\n\n"),
881
+ sourceMessageIds: [...new Set(packet.flatMap(item => item.sourceMessageIds))],
882
+ queuedAttachments: packet.flatMap(item => item.queuedAttachments),
883
+ admissionReceipts: packet.flatMap(item => item.admissionReceipts ?? []),
884
+ voiceReplyPreferred: packet.some(item => item.voiceReplyPreferred === true),
885
+ voiceReplyRequired: packet.some(item => item.voiceReplyRequired === true),
886
+ steerPacketSourceOrders: packet.map(item => item.queueOrder),
887
+ };
888
+ delete projected.steerPacketTail;
889
+ return projected;
890
+ }
891
+ function isSameTelegramPromptNamespace(left, right) {
892
+ return left.chatId === right.chatId &&
893
+ left.target?.chatId === right.target?.chatId &&
894
+ left.target?.threadId === right.target?.threadId &&
895
+ left.transportStamp?.profile === right.transportStamp?.profile &&
896
+ left.transportStamp?.generation === right.transportStamp?.generation;
897
+ }
898
+ /** One publication attempt; journal ACK is source disposition, never SDK/model-use confirmation. */
899
+ export function createTelegramSteerBoundaryRuntime(deps) {
900
+ const updateStatus = (ctx) => {
901
+ try {
902
+ if (deps.isContextCurrent(ctx))
903
+ deps.updateStatus(ctx);
904
+ }
905
+ catch (error) {
906
+ if (!isPiStaleContextError(error)) {
907
+ try {
908
+ deps.recordRuntimeEvent?.("queue", error, { phase: "steer-status" });
909
+ }
910
+ catch { /* Diagnostic failure cannot replay input. */ }
911
+ }
912
+ }
913
+ };
914
+ const report = (error, phase, ctx) => {
915
+ try {
916
+ deps.recordRuntimeEvent?.("queue", error, { phase });
917
+ }
918
+ catch { /* Diagnostics cannot replay input. */ }
919
+ updateStatus(ctx);
920
+ };
921
+ return {
922
+ onTurnEnd(event, ctx) {
923
+ const generation = deps.getGeneration();
924
+ const current = () => deps.isContextCurrent(ctx) && generation === deps.getGeneration();
925
+ const blocked = () => !current() || event.outcome !== "completed" || deps.isBlocked(ctx);
926
+ try {
927
+ if (blocked())
928
+ return;
929
+ const matchesActive = (item) => {
930
+ const active = deps.getActiveTurn();
931
+ return item.kind !== "prompt" || !active || isSameTelegramPromptNamespace(active, item);
932
+ };
933
+ const ready = (item) => deps.isItemReady(item, ctx) && matchesActive(item);
934
+ // Skipped heads settle under the same exact owner before recapturing a fresh packet.
935
+ const budget = deps.getQueuedItems().length;
936
+ for (let index = 0; index <= budget; index++) {
937
+ const plan = planTelegramSteerPacket(deps.getQueuedItems(), {
938
+ blocked: blocked(), isItemReady: ready, canAppend: createTelegramSteerPacketBudget(),
939
+ });
940
+ if (plan.kind === "none")
941
+ return;
942
+ if (plan.kind === "skip") {
943
+ if (blocked() || !deps.discardItem(plan.item, ctx))
944
+ return;
945
+ if (!current())
946
+ return;
947
+ deps.setQueuedItems(deps.getQueuedItems().filter(item => item !== plan.item));
948
+ continue;
949
+ }
950
+ const packet = plan.items;
951
+ const content = buildTelegramSteerPacketContent(packet);
952
+ const draft = {
953
+ type: "custom_message", customType: "telegram-steer", content, display: true,
954
+ details: { boundaryTurnIndex: event.turnIndex, queueOrders: packet.map(item => item.queueOrder) },
955
+ };
956
+ if (packet.some(item => !ready(item)) ||
957
+ packet.some(item => !deps.getQueuedItems().includes(item)) || blocked())
958
+ return;
959
+ // Keep the same Queue objects/receipts: a refused prefix cannot become a new attempt.
960
+ for (const item of packet)
961
+ item.steerAttempted = true;
962
+ if (!deps.commitPacket(packet, ctx)) {
963
+ report(new Error("Steer packet receipt settlement is unconfirmed."), "steer-settlement-unconfirmed", ctx);
964
+ return;
965
+ }
966
+ if (blocked() || packet.some(item => !matchesActive(item) ||
967
+ !deps.isItemCurrent(item, ctx) || !deps.getQueuedItems().includes(item))) {
968
+ report(new Error("Steer packet authority changed before publication."), "steer-publication-refused", ctx);
969
+ return;
970
+ }
971
+ const selected = new Set(packet);
972
+ deps.setQueuedItems(deps.getQueuedItems().filter(item => !selected.has(item)));
973
+ updateStatus(ctx);
974
+ if (!current() || deps.isBlocked(ctx)) {
975
+ report(new Error("Steer publication was refused after source disposition."), "steer-publication-refused", ctx);
976
+ return;
977
+ }
978
+ return { entries: [draft], continue: true };
979
+ }
980
+ }
981
+ catch (error) {
982
+ report(error, "steer-attempt-failed", ctx);
983
+ }
984
+ return undefined;
985
+ },
986
+ };
987
+ }
792
988
  export function planNextTelegramQueueAction(items, canDispatch) {
793
989
  if (!canDispatch || items.length === 0) {
794
990
  return { kind: "none", remainingItems: items };
@@ -799,6 +995,11 @@ export function planNextTelegramQueueAction(items, canDispatch) {
799
995
  }
800
996
  assertTelegramQueueItemAdmissionValid(firstItem);
801
997
  if (isPendingTelegramTurn(firstItem)) {
998
+ if (firstItem.steerAttempted)
999
+ return { kind: "none", remainingItems: items };
1000
+ // Steer needs whole-packet idle/boundary dispatch, never this single-prompt fallback.
1001
+ if (firstItem.queueLane === "steer")
1002
+ return { kind: "none", remainingItems: items };
802
1003
  return { kind: "prompt", item: firstItem, remainingItems: items };
803
1004
  }
804
1005
  return { kind: "control", item: firstItem, remainingItems };
@@ -1697,7 +1898,12 @@ export function removeTelegramQueueItemsByMessageIdsRuntime(messageIds, deps, sc
1697
1898
  return removedCount;
1698
1899
  }
1699
1900
  function removeTelegramQueuedGuestPromptByOrderRuntime(queueOrder, deps) {
1700
- const { items, removedItems, removedCount } = removeTelegramQueuedGuestPromptByOrder(deps.getQueuedItems(), queueOrder);
1901
+ const queuedItems = deps.getQueuedItems();
1902
+ const head = queuedItems[0];
1903
+ // agent_start must consume the issued guest, not adopt the next waiting reply owner.
1904
+ if (deps.hasPendingDispatch?.() && head?.queueOrder === queueOrder)
1905
+ return false;
1906
+ const { items, removedItems, removedCount } = removeTelegramQueuedGuestPromptByOrder(queuedItems, queueOrder);
1701
1907
  if (removedCount === 0)
1702
1908
  return false;
1703
1909
  // A menu Skip means permanent discard, so settle durable admission before
@@ -1709,6 +1915,10 @@ function removeTelegramQueuedGuestPromptByOrderRuntime(queueOrder, deps) {
1709
1915
  }
1710
1916
  export function applyTelegramQueuePromptReactionDispositionRuntime(messageId, disposition, deps, scope) {
1711
1917
  const queuedItems = deps.getQueuedItems();
1918
+ const head = queuedItems[0];
1919
+ if (deps.hasPendingDispatch?.() && head?.kind === "prompt" &&
1920
+ isTelegramQueueItemInMessageScope(head, scope) && head.sourceMessageIds.includes(messageId))
1921
+ return false;
1712
1922
  const suppression = disposition.kind === "reaction-transition"
1713
1923
  ? disposition.suppressionEmoji
1714
1924
  : disposition.kind === "suppressed"
@@ -1736,17 +1946,16 @@ export function applyTelegramQueuePromptReactionDispositionRuntime(messageId, di
1736
1946
  }
1737
1947
  const changesLane = queuedItems.some((item) => {
1738
1948
  if (!isPendingTelegramTurn(item) ||
1949
+ item.steerAttempted ||
1739
1950
  item.queueLane === "control" ||
1740
1951
  !isTelegramQueueItemInMessageScope(item, scope) ||
1741
1952
  !item.sourceMessageIds.includes(messageId)) {
1742
1953
  return false;
1743
1954
  }
1744
1955
  const queueLane = disposition.kind === "reaction-transition"
1745
- ? disposition.priorityEmoji === undefined
1956
+ ? (disposition.promptLane ?? (disposition.priorityEmoji === undefined
1746
1957
  ? item.queueLane
1747
- : disposition.priorityEmoji === null
1748
- ? "default"
1749
- : "priority"
1958
+ : disposition.priorityEmoji === null ? "default" : "priority"))
1750
1959
  : disposition.kind === "priority" ||
1751
1960
  disposition.kind === "priority-suppressed"
1752
1961
  ? "priority"
@@ -1779,6 +1988,8 @@ export async function enqueueTelegramPromptTurnRuntime(messages, deps) {
1779
1988
  const remainingItems = deps.getQueuedItems().filter((item, index) => {
1780
1989
  if ((pendingDispatch && index === 0) ||
1781
1990
  !isPendingTelegramTurn(item) ||
1991
+ item.steerAttempted ||
1992
+ item.queueLane === "steer" ||
1782
1993
  !historyOrders.has(item.queueOrder))
1783
1994
  return true;
1784
1995
  historyTurns.push(item);
@@ -1957,6 +2168,8 @@ export function executeTelegramQueueDispatchPlan(plan, deps) {
1957
2168
  deps.executeControlItem(plan.item);
1958
2169
  return;
1959
2170
  }
2171
+ if (plan.kind === "steer")
2172
+ throw new Error("Steer packet requires dedicated Queue dispatch.");
1960
2173
  deps.onPromptDispatchStart(plan.item.chatId, plan.item.target);
1961
2174
  try {
1962
2175
  if (deps.commitPromptDispatch && !deps.commitPromptDispatch(plan.item)) {
@@ -1990,6 +2203,7 @@ export function createTelegramQueueDispatchRuntime(deps) {
1990
2203
  sendUserMessage: deps.sendUserMessage,
1991
2204
  onPromptDispatchFailure: deps.onPromptDispatchFailure,
1992
2205
  reconcileNextDispatchAnnouncementReplyOwnership: deps.reconcileNextDispatchAnnouncementReplyOwnership,
2206
+ steerDispatch: deps.steerDispatch,
1993
2207
  isQueueItemTransportActive: deps.isQueueItemTransportActive,
1994
2208
  hasPendingInboundQueueMutationForItem: deps.hasPendingInboundQueueMutationForItem,
1995
2209
  isQueueItemAdmissionReady: deps.isQueueItemAdmissionReady,
@@ -2027,6 +2241,11 @@ export function createTelegramQueueDispatchController(deps) {
2027
2241
  const retainedItems = [];
2028
2242
  let droppedInactiveItemCount = 0;
2029
2243
  for (const item of queuedItems) {
2244
+ if (item.kind === "prompt" && item.steerAttempted) {
2245
+ protectedInactiveItems.push(item);
2246
+ retainedItems.push(item);
2247
+ continue;
2248
+ }
2030
2249
  if (!deps.isQueueItemTransportActive ||
2031
2250
  deps.isQueueItemTransportActive(item)) {
2032
2251
  activeItems.push(item);
@@ -2116,19 +2335,34 @@ export function createTelegramQueueDispatchController(deps) {
2116
2335
  return;
2117
2336
  }
2118
2337
  const dispatchBasisItems = deps.getQueuedItems();
2119
- const dispatchPlan = planNextTelegramQueueAction(dispatchableItems, canDispatch);
2338
+ let dispatchPlan = planNextTelegramQueueAction(dispatchableItems, canDispatch);
2339
+ if (canDispatch && nextItem?.kind === "prompt" && nextItem.queueLane === "steer" &&
2340
+ deps.steerDispatch && deps.steerDispatch.isContextCurrent?.(ctx) !== false &&
2341
+ deps.steerDispatch.canSubmit?.(ctx) !== false) {
2342
+ const packetPlan = planTelegramSteerPacket(deps.getQueuedItems(), {
2343
+ blocked: false,
2344
+ isItemReady: item => deps.isQueueItemTransportActive?.(item) !== false &&
2345
+ deps.hasPendingInboundQueueMutationForItem?.(item) !== true &&
2346
+ deps.isQueueItemAdmissionReady?.(item) !== false,
2347
+ canAppend: createTelegramSteerPacketBudget(true),
2348
+ });
2349
+ if (packetPlan.kind === "packet" && packetPlan.items[0] === nextItem) {
2350
+ dispatchPlan = { kind: "steer", item: nextItem, packet: packetPlan.items, remainingItems: dispatchableItems };
2351
+ }
2352
+ }
2120
2353
  const commitDispatchPlan = () => {
2121
2354
  if (dispatchPlan.kind === "none")
2122
2355
  return true;
2123
2356
  const currentItems = deps.getQueuedItems();
2124
- if (dispatchPlan.kind === "prompt") {
2357
+ if (dispatchPlan.kind === "prompt" || dispatchPlan.kind === "steer") {
2358
+ const inputs = dispatchPlan.kind === "steer" ? dispatchPlan.packet : [dispatchPlan.item];
2125
2359
  const queueDrifted = currentItems.length !== dispatchBasisItems.length ||
2126
2360
  currentItems.some((item, index) => item !== dispatchBasisItems[index]);
2127
2361
  const dispatchEligibilityDrifted = !deps.canDispatch(ctx) ||
2128
- deps.hasPendingInboundQueueMutationForItem?.(dispatchPlan.item) ===
2129
- true ||
2130
- deps.isQueueItemAdmissionReady?.(dispatchPlan.item) === false ||
2131
- deps.isQueueItemTransportActive?.(dispatchPlan.item) === false;
2362
+ (dispatchPlan.kind === "steer" && (deps.steerDispatch?.isContextCurrent?.(ctx) === false ||
2363
+ deps.steerDispatch?.canSubmit?.(ctx) === false)) ||
2364
+ inputs.some(item => deps.hasPendingInboundQueueMutationForItem?.(item) === true ||
2365
+ deps.isQueueItemAdmissionReady?.(item) === false || deps.isQueueItemTransportActive?.(item) === false);
2132
2366
  if (queueDrifted || dispatchEligibilityDrifted) {
2133
2367
  const selectedItemRetained = currentItems.includes(dispatchPlan.item);
2134
2368
  const selectedTransportActive = deps.isQueueItemTransportActive?.(dispatchPlan.item) !== false;
@@ -2156,6 +2390,60 @@ export function createTelegramQueueDispatchController(deps) {
2156
2390
  const executePlan = () => {
2157
2391
  if (!commitDispatchPlan())
2158
2392
  return;
2393
+ if (dispatchPlan.kind === "steer") {
2394
+ const { packet, item: first } = dispatchPlan;
2395
+ const content = buildTelegramSteerPacketContent(packet, true);
2396
+ const generation = deps.getDispatchGeneration?.();
2397
+ const current = () => {
2398
+ try {
2399
+ return (!deps.hasDispatchContext || deps.hasDispatchContext()) &&
2400
+ (generation === undefined || deps.isDispatchGenerationActive?.(generation) !== false) &&
2401
+ deps.steerDispatch?.isContextCurrent?.(ctx) !== false;
2402
+ }
2403
+ catch {
2404
+ return false;
2405
+ }
2406
+ };
2407
+ const refuse = (message) => {
2408
+ deps.recordRuntimeEvent?.("dispatch", new Error(message), { phase: "steer-idle-attempt" });
2409
+ if (current())
2410
+ deps.onPromptDispatchFailure(ctx, message);
2411
+ };
2412
+ controlDispatchPending = true;
2413
+ try {
2414
+ for (const item of packet)
2415
+ item.steerAttempted = true;
2416
+ if (deps.steerDispatch?.commitPacket(packet, ctx) !== true) {
2417
+ refuse("Steer packet receipt settlement is unconfirmed.");
2418
+ return;
2419
+ }
2420
+ const sourceChanged = packet.some(item => !deps.getQueuedItems().includes(item) ||
2421
+ deps.isQueueItemTransportActive?.(item) === false || deps.hasPendingInboundQueueMutationForItem?.(item) === true);
2422
+ const readyControl = deps.getQueuedItems().some(item => item.queueLane === "control" &&
2423
+ deps.isQueueItemTransportActive?.(item) !== false && deps.isQueueItemAdmissionReady?.(item) !== false);
2424
+ if (!current() || deps.steerDispatch?.canSubmit?.(ctx) === false || !deps.canDispatch(ctx) || sourceChanged || readyControl) {
2425
+ refuse("Steer packet lost idle readiness before submission.");
2426
+ return;
2427
+ }
2428
+ first.steerPacketTail = packet.slice(1);
2429
+ // Pin the original head, retaining every original until agent_start consumes the packet.
2430
+ deps.setQueuedItems([first, ...deps.getQueuedItems().filter(item => item !== first)]);
2431
+ deps.onPromptDispatchStart(ctx, first.chatId, first.target);
2432
+ if (!current() || deps.steerDispatch?.canSubmit?.(ctx) === false || packet.some(item => !deps.getQueuedItems().includes(item) ||
2433
+ deps.isQueueItemTransportActive?.(item) === false || deps.hasPendingInboundQueueMutationForItem?.(item) === true)) {
2434
+ refuse("Steer packet authority changed before submission.");
2435
+ return;
2436
+ }
2437
+ deps.sendUserMessage(content);
2438
+ }
2439
+ catch (error) {
2440
+ refuse(getTelegramQueueErrorMessage(error));
2441
+ }
2442
+ finally {
2443
+ controlDispatchPending = false;
2444
+ }
2445
+ return;
2446
+ }
2159
2447
  executeTelegramQueueDispatchPlan(dispatchPlan, {
2160
2448
  executeControlItem: (item) => {
2161
2449
  controlDispatchPending = true;
@@ -2203,7 +2491,7 @@ export function createTelegramQueueDispatchController(deps) {
2203
2491
  },
2204
2492
  });
2205
2493
  };
2206
- if (dispatchPlan.kind === "prompt" && nextDispatchAnnouncementRequested) {
2494
+ if ((dispatchPlan.kind === "prompt" || dispatchPlan.kind === "steer") && nextDispatchAnnouncementRequested) {
2207
2495
  nextDispatchAnnouncementRequested = false;
2208
2496
  controlDispatchPending = true;
2209
2497
  const announcementGeneration = nextDispatchAnnouncementGeneration;
@@ -113,6 +113,8 @@ export declare function createTelegramLiveRebindCoordinator(input: {
113
113
  operationId: string;
114
114
  /** Body-free copy of the authenticated saved command reference: correlation only, never authority, an ACK or a cleanup permit. */
115
115
  selectedCommandReference(): TelegramLiveRebindSelectedCommandReference | undefined;
116
+ /** Observe only an already-issued leader command; never prepares, relocates or executes input. */
117
+ observeCommandCompletion(): "unknown" | "released" | undefined;
116
118
  advance(): Promise<"protected" | "unknown" | "released">;
117
119
  };
118
120
  /** One admitted Restore attempt through recipient readiness, never source dispatch or cleanup. */
@@ -212,6 +214,8 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
212
214
  /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
213
215
  temporaryThreadCleanupDelayMs?: number;
214
216
  /** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
217
+ /** Bounded ACK observation inside the original chooser admission, independent from menu/reply delivery. */
218
+ liveRebindCommandCompletionTimeoutMs?: number;
215
219
  liveRebindCleanupSchedule?: {
216
220
  delaysMs: readonly number[];
217
221
  intervalMs: number;
@@ -584,6 +584,20 @@ export function createTelegramLiveRebindCoordinator(input) {
584
584
  }
585
585
  : undefined;
586
586
  },
587
+ /** Observe only an already-issued leader command; never prepares, relocates or executes input. */
588
+ observeCommandCompletion() {
589
+ if (!continuationIssued || !leader?.complete)
590
+ return undefined;
591
+ if (!current())
592
+ return "unknown";
593
+ try {
594
+ return observeCommandCompletion();
595
+ }
596
+ catch (error) {
597
+ input.recordRuntimeEvent?.("telegram", error, { phase: "live-rebind-command-observation" });
598
+ return "unknown";
599
+ }
600
+ },
587
601
  async advance() {
588
602
  if (advancing || !current())
589
603
  return "protected";
@@ -3100,7 +3114,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3100
3114
  Bus.isSameTelegramBusFollowerRegistration(live, follower) &&
3101
3115
  [
3102
3116
  ...Bus.TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES,
3103
- ...(heldSelected ? Bus.TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES : []),
3117
+ ...(heldSelected ? Bus.getTelegramBusHeldCommandCapabilities(heldSelected.name) : []),
3104
3118
  ].every((cap) => Bus.hasTelegramBusCapability(live.protocol, cap)) &&
3105
3119
  !!live.target &&
3106
3120
  live.target.chatId === binding.target.chatId &&
@@ -3653,6 +3667,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3653
3667
  return {
3654
3668
  operationId: request.operationId,
3655
3669
  selectedCommandReference: () => coordinator?.selectedCommandReference(),
3670
+ observeCommandCompletion: () => coordinator?.observeCommandCompletion(),
3656
3671
  async advance() {
3657
3672
  if (!coordinator) {
3658
3673
  if (!current() || !getRecipient())
@@ -6184,6 +6199,52 @@ export function createTelegramInboundRouteRuntime(deps) {
6184
6199
  const operationId = selection.coordinator.operationId;
6185
6200
  if (current())
6186
6201
  outcome = await selection.coordinator.advance();
6202
+ const completionTimeoutMs = deps.liveRebindCommandCompletionTimeoutMs ?? 0;
6203
+ if (outcome === "unknown" && !selection.commandCompletionWaited && selection.record.owner?.kind === "leader" &&
6204
+ (selection.helpCommand || selection.menuCommand || selection.abortCommand || selection.nextCommand || selection.stopCommand) &&
6205
+ Number.isFinite(completionTimeoutMs) && completionTimeoutMs > 0 &&
6206
+ selection.coordinator.observeCommandCompletion() !== undefined) {
6207
+ selection.commandCompletionWaited = true;
6208
+ const coordinator = selection.coordinator;
6209
+ const signal = Updates.getTelegramUpdateExecutionFence(query)?.signal;
6210
+ outcome = await new Promise((resolve) => {
6211
+ let settled = false;
6212
+ const subscriptions = [];
6213
+ const finish = (result) => {
6214
+ if (settled)
6215
+ return;
6216
+ settled = true;
6217
+ clearTimeout(timer);
6218
+ for (const unsubscribe of subscriptions)
6219
+ unsubscribe();
6220
+ signal?.removeEventListener("abort", abort);
6221
+ resolve(result);
6222
+ };
6223
+ const abort = () => finish("unknown");
6224
+ const observe = () => {
6225
+ if (settled)
6226
+ return;
6227
+ if (!current()) {
6228
+ finish("unknown");
6229
+ return;
6230
+ }
6231
+ if (coordinator.observeCommandCompletion() === "released")
6232
+ finish("released");
6233
+ };
6234
+ const timer = setTimeout(abort, completionTimeoutMs);
6235
+ timer.unref?.();
6236
+ for (const message of selection.messages) {
6237
+ const unsubscribe = Updates.subscribeTelegramDeferredSourceCompletion(message, observe);
6238
+ if (unsubscribe)
6239
+ subscriptions.push(unsubscribe);
6240
+ }
6241
+ signal?.addEventListener("abort", abort, { once: true });
6242
+ if (signal?.aborted || !current())
6243
+ abort();
6244
+ else if (coordinator.observeCommandCompletion() === "released")
6245
+ finish("released");
6246
+ });
6247
+ }
6187
6248
  // Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
6188
6249
  // still be pending while every attempt samples current work afresh.
6189
6250
  const row = outcome === "protected"
@@ -6,7 +6,7 @@
6
6
  */
7
7
  /** UI copy is allowlisted; raw exception text belongs only in redacted diagnostics. */
8
8
  export declare function formatTelegramConnectionFailure(error: unknown): string;
9
- export type TelegramStatusQueueLane = "control" | "priority" | "default";
9
+ export type TelegramStatusQueueLane = "control" | "steer" | "priority" | "default";
10
10
  export interface TelegramUsageStats {
11
11
  totalInput: number;
12
12
  totalOutput: number;
@@ -345,7 +345,6 @@ export interface TelegramBridgeStatusRuntimeDeps<TQueueItem extends {
345
345
  hasPendingModelSwitch: () => boolean;
346
346
  getQueuedItems: () => TQueueItem[];
347
347
  getQueuedItemCount?: (items: TQueueItem[]) => number;
348
- formatQueuedStatus: (items: TQueueItem[]) => string;
349
348
  getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
350
349
  getRuntimeLockState?: () => string;
351
350
  getBusRole?: () => TelegramBridgeBusRole | undefined;