@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/routing.ts CHANGED
@@ -916,6 +916,16 @@ export function createTelegramLiveRebindCoordinator(input: {
916
916
  }
917
917
  : undefined;
918
918
  },
919
+ /** Observe only an already-issued leader command; never prepares, relocates or executes input. */
920
+ observeCommandCompletion(): "unknown" | "released" | undefined {
921
+ if (!continuationIssued || !leader?.complete) return undefined;
922
+ if (!current()) return "unknown";
923
+ try { return observeCommandCompletion(); }
924
+ catch (error) {
925
+ input.recordRuntimeEvent?.("telegram", error, { phase: "live-rebind-command-observation" });
926
+ return "unknown";
927
+ }
928
+ },
919
929
  async advance(): Promise<"protected" | "unknown" | "released"> {
920
930
  if (advancing || !current()) return "protected";
921
931
  if (released) return "released";
@@ -1561,6 +1571,8 @@ export interface TelegramInboundRouteRuntimeDeps<
1561
1571
  /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
1562
1572
  temporaryThreadCleanupDelayMs?: number;
1563
1573
  /** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
1574
+ /** Bounded ACK observation inside the original chooser admission, independent from menu/reply delivery. */
1575
+ liveRebindCommandCompletionTimeoutMs?: number;
1564
1576
  liveRebindCleanupSchedule?: {
1565
1577
  delaysMs: readonly number[];
1566
1578
  intervalMs: number;
@@ -1937,6 +1949,7 @@ export function createTelegramInboundRouteRuntime<
1937
1949
  extensionCommand?: Commands.ParsedTelegramCommand;
1938
1950
  extensionKind?: Commands.PreparedSelectedCommand["kind"];
1939
1951
  coordinator?: ReturnType<typeof createTelegramLiveRebindCoordinator>;
1952
+ commandCompletionWaited?: true;
1940
1953
  isCurrent?: () => boolean;
1941
1954
  };
1942
1955
  /** The acknowledged tab presenting an All source; `sourceTarget` is this tab, not the original's location. */
@@ -4870,7 +4883,7 @@ export function createTelegramInboundRouteRuntime<
4870
4883
  Bus.isSameTelegramBusFollowerRegistration(live, follower!) &&
4871
4884
  [
4872
4885
  ...Bus.TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES,
4873
- ...(heldSelected ? Bus.TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES : []),
4886
+ ...(heldSelected ? Bus.getTelegramBusHeldCommandCapabilities(heldSelected.name) : []),
4874
4887
  ].every((cap) => Bus.hasTelegramBusCapability(live.protocol, cap)) &&
4875
4888
  !!live.target &&
4876
4889
  live.target.chatId === binding.target.chatId &&
@@ -5642,6 +5655,7 @@ export function createTelegramInboundRouteRuntime<
5642
5655
  return {
5643
5656
  operationId: request.operationId,
5644
5657
  selectedCommandReference: () => coordinator?.selectedCommandReference(),
5658
+ observeCommandCompletion: () => coordinator?.observeCommandCompletion(),
5645
5659
  async advance() {
5646
5660
  if (!coordinator) {
5647
5661
  if (!current() || !getRecipient()) return "protected";
@@ -9058,6 +9072,42 @@ export function createTelegramInboundRouteRuntime<
9058
9072
  if (!(await selectPendingDestination())) return true;
9059
9073
  const operationId = selection.coordinator.operationId;
9060
9074
  if (current()) outcome = await selection.coordinator.advance();
9075
+ const completionTimeoutMs = deps.liveRebindCommandCompletionTimeoutMs ?? 0;
9076
+ if (outcome === "unknown" && !selection.commandCompletionWaited && selection.record.owner?.kind === "leader" &&
9077
+ (selection.helpCommand || selection.menuCommand || selection.abortCommand || selection.nextCommand || selection.stopCommand) &&
9078
+ Number.isFinite(completionTimeoutMs) && completionTimeoutMs > 0 &&
9079
+ selection.coordinator.observeCommandCompletion() !== undefined) {
9080
+ selection.commandCompletionWaited = true;
9081
+ const coordinator = selection.coordinator;
9082
+ const signal = Updates.getTelegramUpdateExecutionFence(query)?.signal;
9083
+ outcome = await new Promise<"unknown" | "released">((resolve) => {
9084
+ let settled = false;
9085
+ const subscriptions: (() => void)[] = [];
9086
+ const finish = (result: "unknown" | "released") => {
9087
+ if (settled) return;
9088
+ settled = true;
9089
+ clearTimeout(timer);
9090
+ for (const unsubscribe of subscriptions) unsubscribe();
9091
+ signal?.removeEventListener("abort", abort);
9092
+ resolve(result);
9093
+ };
9094
+ const abort = () => finish("unknown");
9095
+ const observe = () => {
9096
+ if (settled) return;
9097
+ if (!current()) { finish("unknown"); return; }
9098
+ if (coordinator.observeCommandCompletion() === "released") finish("released");
9099
+ };
9100
+ const timer = setTimeout(abort, completionTimeoutMs);
9101
+ timer.unref?.();
9102
+ for (const message of selection.messages) {
9103
+ const unsubscribe = Updates.subscribeTelegramDeferredSourceCompletion(message, observe);
9104
+ if (unsubscribe) subscriptions.push(unsubscribe);
9105
+ }
9106
+ signal?.addEventListener("abort", abort, { once: true });
9107
+ if (signal?.aborted || !current()) abort();
9108
+ else if (coordinator.observeCommandCompletion() === "released") finish("released");
9109
+ });
9110
+ }
9061
9111
  // Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
9062
9112
  // still be pending while every attempt samples current work afresh.
9063
9113
  const row =
package/lib/status.ts CHANGED
@@ -66,7 +66,7 @@ export function formatTelegramConnectionFailure(error: unknown): string {
66
66
 
67
67
  const TELEGRAM_STATUS_DEFAULT_PROFILE_NAME = "default";
68
68
 
69
- export type TelegramStatusQueueLane = "control" | "priority" | "default";
69
+ export type TelegramStatusQueueLane = "control" | "steer" | "priority" | "default";
70
70
 
71
71
  export interface TelegramUsageStats {
72
72
  totalInput: number;
@@ -441,7 +441,6 @@ export interface TelegramBridgeStatusRuntimeDeps<
441
441
  hasPendingModelSwitch: () => boolean;
442
442
  getQueuedItems: () => TQueueItem[];
443
443
  getQueuedItemCount?: (items: TQueueItem[]) => number;
444
- formatQueuedStatus: (items: TQueueItem[]) => string;
445
444
  getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
446
445
  getRuntimeLockState?: () => string;
447
446
  getBusRole?: () => TelegramBridgeBusRole | undefined;
@@ -1200,9 +1199,7 @@ export function buildTelegramStatusBarText(
1200
1199
  state: TelegramStatusBarState,
1201
1200
  ): string {
1202
1201
  const label = theme.fg("accent", getTelegramStatusBarLabel(state));
1203
- const queued = state.queuedStatus
1204
- ? theme.fg("warning", state.queuedStatus)
1205
- : "";
1202
+ const queued = state.queuedStatus ? theme.fg("warning", state.queuedStatus) : "";
1206
1203
  if (!state.hasBotToken)
1207
1204
  return `${label} ${theme.fg("muted", "not configured")}${queued}`;
1208
1205
  if (
@@ -1612,6 +1609,7 @@ function countTelegramQueueLanes(
1612
1609
  ): Record<TelegramStatusQueueLane, number> {
1613
1610
  const counts: Record<TelegramStatusQueueLane, number> = {
1614
1611
  control: 0,
1612
+ steer: 0,
1615
1613
  priority: 0,
1616
1614
  default: 0,
1617
1615
  };
@@ -1626,10 +1624,11 @@ function buildTelegramBridgeCompactStatusLines(
1626
1624
  control: controlQueueCount,
1627
1625
  priority: priorityQueueCount,
1628
1626
  default: defaultQueueCount,
1627
+ steer: steerQueueCount,
1629
1628
  } = countTelegramQueueLanes(state.queuedItems);
1630
1629
  const queueLine = `- queued turns: ${state.queuedItems.length}${
1631
1630
  state.queuedItems.length > 0
1632
- ? ` (control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
1631
+ ? ` (control=${controlQueueCount}${steerQueueCount > 0 ? `, steer=${steerQueueCount}` : ""}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
1633
1632
  : ""
1634
1633
  }`;
1635
1634
  const executionState = state.pendingDispatch
@@ -1698,6 +1697,7 @@ function buildTelegramBridgeDiagnosticStatusLines(
1698
1697
  control: controlQueueCount,
1699
1698
  priority: priorityQueueCount,
1700
1699
  default: defaultQueueCount,
1700
+ steer: steerQueueCount,
1701
1701
  } = countTelegramQueueLanes(state.queuedItems);
1702
1702
  return [
1703
1703
  "connection:",
@@ -1744,7 +1744,7 @@ function buildTelegramBridgeDiagnosticStatusLines(
1744
1744
  "",
1745
1745
  "queue:",
1746
1746
  `- queued turns: ${state.queuedItems.length}`,
1747
- `- lanes: control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
1747
+ `- lanes: control=${controlQueueCount}${steerQueueCount > 0 ? `, steer=${steerQueueCount}` : ""}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
1748
1748
  ...buildTelegramBusFollowerLines(state),
1749
1749
  ...buildTelegramLocalBusLines(state, { verbose: true }),
1750
1750
  ...buildTelegramTopicTargetDiagnosticLines(state),
package/lib/sync.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  isSameTelegramProcessInstance,
19
19
  isTelegramTopicTargetStaleError,
20
20
  provisionOwnBusTopic,
21
+ resetTelegramConnectionProvisioning,
21
22
  type TelegramOwnTopicProvisionResult,
22
23
  type TelegramTopicTargetStore,
23
24
  type TelegramWorkspaceDisplayBinding,
@@ -826,6 +827,15 @@ export async function ensureTelegramLeaderThreadBinding(
826
827
  ...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
827
828
  };
828
829
  const leaderProfileKey = getTelegramThreadOwnerKey(leaderOwner);
830
+ if (deps.forceFreshUnnamed) {
831
+ await resetTelegramConnectionProvisioning({
832
+ store: deps.topicTargetStore, owner: "leader", instanceId: deps.instanceId,
833
+ profileKey: leaderProfileKey, isCurrent: () => {
834
+ assertLeaderEpoch("connect-recovery");
835
+ return true;
836
+ }, recordEvent: deps.recordEvent,
837
+ });
838
+ }
829
839
  const legacyLeaderRecord =
830
840
  deps.topicTargetStore.getByProfileKey(leaderProfileKey);
831
841
  let capacityUnavailable = false;
@@ -836,6 +846,7 @@ export async function ensureTelegramLeaderThreadBinding(
836
846
  legacyLeaderRecord?.instanceId,
837
847
  {
838
848
  sessionId: deps.sessionId,
849
+ provisionProfileKey: leaderProfileKey,
839
850
  onCapacityUnavailable() {
840
851
  capacityUnavailable = true;
841
852
  },
package/lib/threads.ts CHANGED
@@ -783,6 +783,7 @@ export interface TelegramTopicTargetStore {
783
783
  ) => { slot?: string; threadName?: string } | undefined;
784
784
  listReservations: () => TelegramThreadReservation[];
785
785
  listPendingProvisions: () => TelegramThreadPendingProvision[];
786
+ resetPendingProvisionForConnect: (expected: TelegramThreadPendingProvision, isCurrent: () => boolean) => Promise<boolean>;
786
787
  listPendingCleanups: () => TelegramThreadCleanupIntent[];
787
788
  listSyncObservations: () => TelegramTopicSyncObservation[];
788
789
  reserveThread: (reservation: TelegramThreadReservation) => void;
@@ -902,6 +903,7 @@ export interface TelegramTopicTargetStore {
902
903
  options?: {
903
904
  existingBindingOnly?: boolean;
904
905
  sessionId?: string;
906
+ provisionProfileKey?: string;
905
907
  onCapacityUnavailable?: () => void;
906
908
  },
907
909
  ) => TelegramWorkspaceBindingIdentity | undefined;
@@ -4168,6 +4170,7 @@ export function createTelegramTopicTargetStore(
4168
4170
  }
4169
4171
  ),
4170
4172
  isCurrentPublication?: () => boolean,
4173
+ resetProvisions?: readonly TelegramThreadPendingProvision[],
4171
4174
  ): Promise<boolean> => {
4172
4175
  const submittedPath = getPath(),
4173
4176
  submittedProfile = activeProfile();
@@ -4367,6 +4370,13 @@ export function createTelegramTopicTargetStore(
4367
4370
  !!expectedRestore,
4368
4371
  ),
4369
4372
  );
4373
+ if (resetProvisions) {
4374
+ const recoveries = readProvisionRecoveries(path, true);
4375
+ if (mutationRevision !== persistedRevision || resetProvisions.some(expected =>
4376
+ expected.target || Object.hasOwn(recoveries, expected.id) ||
4377
+ !isDeepStrictEqual(disk.pendingProvisions?.find(value => value.id === expected.id), expected)))
4378
+ throw new Error("Telegram connection recovery provisioning evidence changed.");
4379
+ }
4370
4380
  if (!isDeepStrictEqual(disk.workspaceRestore, expectedRestore))
4371
4381
  throw new Error(
4372
4382
  "Workspace Restore evidence changed before publication.",
@@ -4429,7 +4439,7 @@ export function createTelegramTopicTargetStore(
4429
4439
  }
4430
4440
  : {}),
4431
4441
  reservations: reservations.map((reservation) => ({ ...reservation })),
4432
- pendingProvisions: pendingProvisions.map((provision) => ({
4442
+ pendingProvisions: pendingProvisions.filter(provision => !resetProvisions?.some(expected => expected.id === provision.id)).map((provision) => ({
4433
4443
  ...provision,
4434
4444
  ...(provision.target ? { target: { ...provision.target } } : {}),
4435
4445
  })),
@@ -4636,6 +4646,7 @@ export function createTelegramTopicTargetStore(
4636
4646
  loadedProfile = submittedProfile;
4637
4647
  loaded = true;
4638
4648
  observedWorkspaceSemantic = getTelegramStateSemanticSnapshot(wire);
4649
+ if (resetProvisions) { pendingProvisions = file.pendingProvisions; mutationRevision += 1; }
4639
4650
  if (transition) {
4640
4651
  records = new Map(
4641
4652
  file.threads.map((record) => {
@@ -4808,6 +4819,10 @@ export function createTelegramTopicTargetStore(
4808
4819
  if (isCurrentPublication?.() === false) return false;
4809
4820
  loadedPath = path;
4810
4821
  loaded = true;
4822
+ if (resetProvisions) {
4823
+ if (mutationRevision !== persistedRevision) return false;
4824
+ pendingProvisions = file.pendingProvisions;
4825
+ }
4811
4826
  if (mutationRevision === persistedRevision) dirty = false;
4812
4827
  return true;
4813
4828
  });
@@ -6814,6 +6829,11 @@ export function createTelegramTopicTargetStore(
6814
6829
  current.status = "ambiguous";
6815
6830
  return true;
6816
6831
  },
6832
+ resetPendingProvisionForConnect(expected, isCurrent) {
6833
+ // Stage no in-memory deletion: refusal or a stale publication must keep its protective hold.
6834
+ if (expected.target || !isCurrent()) return Promise.resolve(false);
6835
+ return persistSnapshot(undefined, isCurrent, [structuredClone(expected)]);
6836
+ },
6817
6837
  removePendingProvision(id) {
6818
6838
  const before = pendingProvisions.length;
6819
6839
  pendingProvisions = pendingProvisions.filter(
@@ -7608,8 +7628,14 @@ export function createTelegramTopicTargetStore(
7608
7628
  ) {
7609
7629
  return undefined;
7610
7630
  }
7611
- const retainedTarget = binding?.target ?? legacyRecord?.target;
7612
- const retainedSlot = binding?.slot ?? legacyRecord?.slot;
7631
+ const ownedProvisions = pendingProvisions.filter(value =>
7632
+ value.workspaceBindingKey === identity.bindingKey &&
7633
+ (value.instanceId === instanceId || value.instanceId === effectivePreviousInstanceId ||
7634
+ (options?.provisionProfileKey !== undefined && value.profileKey === options.provisionProfileKey)));
7635
+ if (ownedProvisions.length > 1) return undefined;
7636
+ const ownProvision = ownedProvisions[0];
7637
+ const retainedTarget = ownProvision?.target ?? binding?.target ?? legacyRecord?.target;
7638
+ const retainedSlot = ownProvision?.slot ?? binding?.slot ?? legacyRecord?.slot;
7613
7639
  const otherBindings = Array.from(workspaceBindings.values())
7614
7640
  .filter(
7615
7641
  (other) => other.bindingKey !== identity.bindingKey && other.slot,
@@ -7641,7 +7667,7 @@ export function createTelegramTopicTargetStore(
7641
7667
  )
7642
7668
  .map((reservation) => reservation.slot),
7643
7669
  ...pendingProvisions
7644
- .filter((provision) =>
7670
+ .filter((provision) => provision !== ownProvision &&
7645
7671
  isPendingProvisionLiveOrTargeted(provision, nowMs),
7646
7672
  )
7647
7673
  .map((provision) => provision.slot),
@@ -9661,6 +9687,32 @@ export function createTelegramTopicTargetRenamer(
9661
9687
  };
9662
9688
  }
9663
9689
 
9690
+ /** Caller holds profile Workspace admission; only an explicit connect grants a fresh creation attempt. */
9691
+ export async function resetTelegramConnectionProvisioning(input: {
9692
+ store: TelegramTopicTargetStore;
9693
+ owner: TelegramThreadPendingProvision["owner"];
9694
+ instanceId: string;
9695
+ profileKey: string;
9696
+ isCurrent: () => boolean;
9697
+ recordEvent: (category: string, message: unknown, details?: Record<string, unknown>) => void;
9698
+ }): Promise<void> {
9699
+ if (!input.isCurrent()) throw new Error("Telegram connection recovery authority changed.");
9700
+ const removed: TelegramThreadPendingProvision[] = [];
9701
+ for (const pending of input.store.listPendingProvisions()) {
9702
+ if (pending.owner !== input.owner || pending.target ||
9703
+ (pending.profileKey !== input.profileKey && pending.instanceId !== input.instanceId)) continue;
9704
+ if (!input.isCurrent()) throw new Error("Telegram connection recovery authority changed.");
9705
+ if (!await input.store.resetPendingProvisionForConnect(pending, input.isCurrent))
9706
+ throw new Error("Telegram connection recovery lost publication authority.");
9707
+ if (!input.isCurrent()) throw new Error("Telegram connection recovery authority changed.");
9708
+ removed.push(pending);
9709
+ }
9710
+ if (removed.length === 0) return;
9711
+ input.recordEvent("connection", "Explicit connect reset unfinished Thread creation; an unconfirmed Telegram Thread may remain.", {
9712
+ phase: "connect-provision-reset", count: removed.length, owner: input.owner,
9713
+ });
9714
+ }
9715
+
9664
9716
  export function createTelegramTopicTargetProvisioner(
9665
9717
  deps: TelegramTopicTargetProvisionerDeps,
9666
9718
  ): (
@@ -9923,7 +9975,7 @@ export function createTelegramTopicTargetProvisioner(
9923
9975
  slot,
9924
9976
  );
9925
9977
  }
9926
- const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`;
9978
+ const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}:${randomUUID()}`;
9927
9979
  const pendingOwner =
9928
9980
  request.owner?.kind === "leader" ? "leader" : "manual-follower";
9929
9981
  assertLeaderEpoch("before-pending-intent");
package/lib/updates.ts CHANGED
@@ -108,11 +108,10 @@ export type TelegramReactionType =
108
108
 
109
109
  export const TELEGRAM_PRIORITY_REACTIONS = [
110
110
  { id: 10, name: "like", emoji: "👍" },
111
- { id: 11, name: "lightning", emoji: "⚡" },
112
111
  { id: 12, name: "heart", emoji: "❤" },
113
112
  { id: 13, name: "dove", emoji: "🕊" },
114
- { id: 14, name: "fire", emoji: "🔥" },
115
113
  ] as const;
114
+ export const TELEGRAM_STEER_REACTIONS = [{ id: 14, name: "fire", emoji: "🔥" }] as const;
116
115
  export const TELEGRAM_REMOVAL_REACTIONS = [
117
116
  { id: 20, name: "dislike", emoji: "👎" },
118
117
  { id: 21, name: "ghost", emoji: "👻" },
@@ -160,14 +159,10 @@ function getTelegramQueueReactionTransition(
160
159
  ): TelegramQueueReactionDisposition | undefined {
161
160
  const oldEmojis = collectTelegramReactionEmojis(oldReactions);
162
161
  const newEmojis = collectTelegramReactionEmojis(newReactions);
163
- const oldPriorityEmoji = getTelegramReactionEmoji(
164
- oldEmojis,
165
- TELEGRAM_PRIORITY_REACTION_EMOJIS,
166
- );
167
- const newPriorityEmoji = getTelegramReactionEmoji(
168
- newEmojis,
169
- TELEGRAM_PRIORITY_REACTION_EMOJIS,
170
- );
162
+ // Fire is effective Steer intent; weaker positive aliases under it do not reorder the lane.
163
+ const steerEmoji = TELEGRAM_STEER_REACTIONS[0].emoji;
164
+ const oldPriorityEmoji = oldEmojis.has(steerEmoji) ? steerEmoji : getTelegramReactionEmoji(oldEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
165
+ const newPriorityEmoji = newEmojis.has(steerEmoji) ? steerEmoji : getTelegramReactionEmoji(newEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
171
166
  const oldSuppressionEmoji = getTelegramReactionEmoji(
172
167
  oldEmojis,
173
168
  TELEGRAM_REMOVAL_REACTION_EMOJIS,
@@ -188,6 +183,7 @@ function getTelegramQueueReactionTransition(
188
183
  > = { kind: "reaction-transition" };
189
184
  if (oldPriorityEmoji !== newPriorityEmoji) {
190
185
  transition.priorityEmoji = newPriorityEmoji ?? null;
186
+ if (newPriorityEmoji === steerEmoji) transition.promptLane = "steer";
191
187
  }
192
188
  if (oldSuppressionEmoji !== newSuppressionEmoji) {
193
189
  transition.suppressionEmoji = newSuppressionEmoji ?? null;
@@ -551,6 +547,7 @@ interface TelegramUpdateAdmissionBinding {
551
547
  inspectSource?: () => TelegramDeferredSourceEvidence | undefined;
552
548
  inspectSourceSnapshot?: () => TelegramDeferredSourceSnapshot | undefined;
553
549
  inspectCompletion?: () => TelegramDeferredSourceEvidence | undefined;
550
+ subscribeCompletion?: (notify: () => void) => () => void;
554
551
  isSourceUnsettled?: () => boolean;
555
552
  prepareLiveInput?: (
556
553
  sourceUpdateIds: readonly number[],
@@ -619,6 +616,7 @@ export function bindTelegramUpdateAdmissionSource<
619
616
  | "inspectSource"
620
617
  | "inspectSourceSnapshot"
621
618
  | "inspectCompletion"
619
+ | "subscribeCompletion"
622
620
  | "isSourceUnsettled"
623
621
  | "acknowledgeTransferred"
624
622
  | "prepareLiveInput"
@@ -747,6 +745,15 @@ export function inspectTelegramDeferredSourceCompletion(
747
745
  return execution.isCurrent() && source ? { ...source } : undefined;
748
746
  }
749
747
 
748
+ /** Warm ACK notification only; subscribers must independently recheck exact completion and recipient authority. */
749
+ export function subscribeTelegramDeferredSourceCompletion(value: unknown, notify: () => void): (() => void) | undefined {
750
+ const execution = getTelegramUpdateExecutionFence(value);
751
+ if (execution?.isCurrent() !== true) return undefined;
752
+ return getTelegramUpdateAdmissionBinding(value)?.subscribeCompletion?.(() => {
753
+ if (execution.isCurrent()) notify();
754
+ });
755
+ }
756
+
750
757
  /** Reuse only live bound deferred originals; raw IDs or cold/adopted input cannot mint this carrier. */
751
758
  export function prepareTelegramLiveDeferredInput(
752
759
  values: readonly unknown[],
@@ -8725,6 +8732,7 @@ function createTelegramUpdateSourceAdmissionHandle<
8725
8732
  let outcome: TelegramUpdateAdmissionOutcome | undefined;
8726
8733
  let completionAttempted = false;
8727
8734
  let completedSource: TelegramDeferredSourceCompletion | undefined;
8735
+ const completionObservers = new Set<() => void>();
8728
8736
  const boundUpdate = bindTelegramUpdateExecutionFence(
8729
8737
  bindTelegramUpdateAdmissionSource(
8730
8738
  update,
@@ -8781,11 +8789,12 @@ function createTelegramUpdateSourceAdmissionHandle<
8781
8789
  execution.isCurrent() &&
8782
8790
  completion.isCurrent() &&
8783
8791
  isDeepStrictEqual(completion.source, expectedSource)
8784
- )
8785
- completedSource = {
8786
- ...completion,
8787
- source: { ...expectedSource },
8788
- };
8792
+ ) {
8793
+ completedSource = { ...completion, source: { ...expectedSource } };
8794
+ for (const notify of completionObservers) {
8795
+ try { notify(); } catch { /* Observation cannot invalidate an exact removal ACK. */ }
8796
+ }
8797
+ }
8789
8798
  })
8790
8799
  .catch((error) => {
8791
8800
  try {
@@ -8846,6 +8855,10 @@ function createTelegramUpdateSourceAdmissionHandle<
8846
8855
  signal,
8847
8856
  })
8848
8857
  : undefined,
8858
+ subscribeCompletion: (notify) => {
8859
+ completionObservers.add(notify);
8860
+ return () => { completionObservers.delete(notify); };
8861
+ },
8849
8862
  inspectCompletion: () =>
8850
8863
  execution.isCurrent() && completedSource?.isCurrent()
8851
8864
  ? { ...completedSource.source }
@@ -9322,6 +9335,7 @@ export function createTelegramQueueHandoffControlExecutionFactory<TContext>(
9322
9335
 
9323
9336
  export interface TelegramQueueHandoffCoordinatorInput<TContext> {
9324
9337
  item: TelegramQueueItem<TContext>;
9338
+ steerSupported?: boolean;
9325
9339
  expectedOwner: TelegramUpdateJournalQueueOwner;
9326
9340
  recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
9327
9341
  handoffToken: string;
@@ -9377,6 +9391,7 @@ export async function coordinateTelegramQueueHandoff<TContext>(
9377
9391
  const handoff = createTelegramQueueHandoff({
9378
9392
  handoffToken: input.handoffToken,
9379
9393
  item: input.item,
9394
+ steerSupported: input.steerSupported,
9380
9395
  });
9381
9396
  const receipt = handoff.payload.admissionReceipts[0];
9382
9397
  if (!receipt || handoff.payload.admissionReceipts.length !== 1) {
@@ -9553,6 +9568,8 @@ export function createTelegramQueueHandoffRecipientRuntime<TContext>(
9553
9568
  }
9554
9569
 
9555
9570
  export interface TelegramQueueHandoffReconcilerDeps<TContext> {
9571
+ canHandoffSteerWithRecipient?: (recipient: TelegramBusFollowerView) => boolean;
9572
+ hasPendingDispatch?: () => boolean;
9556
9573
  ownsDirect: () => boolean;
9557
9574
  isFollowerRegistered: () => boolean;
9558
9575
  isBusEnabled: () => boolean;
@@ -9591,6 +9608,8 @@ export interface TelegramQueueHandoffReconcilerDeps<TContext> {
9591
9608
  export interface TelegramQueueHandoffReconciliationRuntimeAssemblyDeps<
9592
9609
  TContext,
9593
9610
  > {
9611
+ canHandoffSteerWithRecipient?: (recipient: TelegramBusFollowerView) => boolean;
9612
+ hasPendingDispatch?: () => boolean;
9594
9613
  ownsDirect: () => boolean;
9595
9614
  isFollowerRegistered: () => boolean;
9596
9615
  isBusEnabled: () => boolean;
@@ -9630,6 +9649,8 @@ export function createTelegramQueueHandoffReconciliationRuntimeAssembly<
9630
9649
  deps: TelegramQueueHandoffReconciliationRuntimeAssemblyDeps<TContext>,
9631
9650
  ): (ctx: TContext) => Promise<void> {
9632
9651
  return createTelegramQueueHandoffReconciler({
9652
+ canHandoffSteerWithRecipient: deps.canHandoffSteerWithRecipient,
9653
+ hasPendingDispatch: deps.hasPendingDispatch,
9633
9654
  ownsDirect: deps.ownsDirect,
9634
9655
  isFollowerRegistered: deps.isFollowerRegistered,
9635
9656
  isBusEnabled: deps.isBusEnabled,
@@ -9706,6 +9727,8 @@ export function createTelegramQueueHandoffReconciler<TContext>(
9706
9727
  .filter((follower) => follower.instanceId !== deps.donorInstanceId);
9707
9728
  if (followers.length === 0) return;
9708
9729
  for (const item of [...deps.getQueuedItems()]) {
9730
+ if ((deps.hasPendingDispatch?.() && deps.getQueuedItems()[0] === item) ||
9731
+ (item.kind === "prompt" && (item.steerAttempted || item.steerPacketTail !== undefined || item.steerPacketSourceOrders !== undefined))) continue;
9709
9732
  const target = item.target;
9710
9733
  const recipient = target
9711
9734
  ? followers.find(
@@ -9715,6 +9738,8 @@ export function createTelegramQueueHandoffReconciler<TContext>(
9715
9738
  )
9716
9739
  : undefined;
9717
9740
  if (!recipient) continue;
9741
+ const steerSupported = item.queueLane === "steer" && deps.canHandoffSteerWithRecipient?.(recipient) === true;
9742
+ if (item.queueLane === "steer" && !steerSupported) continue;
9718
9743
  const receipt = item.admissionReceipts?.[0];
9719
9744
  if (!receipt || item.admissionReceipts?.length !== 1) continue;
9720
9745
  if (
@@ -9741,6 +9766,7 @@ export function createTelegramQueueHandoffReconciler<TContext>(
9741
9766
  const handoffToken = deps.createHandoffToken();
9742
9767
  const result = await coordinateTelegramQueueHandoff({
9743
9768
  item,
9769
+ steerSupported,
9744
9770
  expectedOwner,
9745
9771
  recipientOwner: {
9746
9772
  instanceId: recipient.instanceId,
@@ -9751,6 +9777,9 @@ export function createTelegramQueueHandoffReconciler<TContext>(
9751
9777
  handoffToken,
9752
9778
  lifecycle,
9753
9779
  stageRemote: async ({ payload: sourcePayload }) => {
9780
+ if (sourcePayload.queueLane === "steer" && deps.canHandoffSteerWithRecipient?.(recipient) !== true) {
9781
+ throw new Error("Steer queue handoff capability changed before staging.");
9782
+ }
9754
9783
  const payload = {
9755
9784
  ...sourcePayload,
9756
9785
  admissionReceipts: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.55.1",
3
+ "version": "0.56.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"