@llblab/pi-kit 0.34.3 → 0.35.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 (50) hide show
  1. package/BACKLOG.md +2 -0
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +3 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -3
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +6 -1
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  7. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +12 -8
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +39 -23
  12. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
  13. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
  14. package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +6 -7
  15. package/node_modules/@llblab/pi-telegram/dist/lib/config.js +6 -8
  16. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +40 -45
  17. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
  18. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
  21. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
  22. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
  23. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
  26. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
  27. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +10 -5
  28. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +19 -2
  29. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +2 -0
  30. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +41 -14
  31. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  32. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
  33. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +5 -3
  34. package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
  35. package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -3
  36. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +14 -8
  37. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
  38. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +45 -24
  39. package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
  40. package/node_modules/@llblab/pi-telegram/lib/config.ts +11 -17
  41. package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -55
  42. package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
  43. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
  44. package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
  45. package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
  46. package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
  47. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +22 -5
  48. package/node_modules/@llblab/pi-telegram/lib/threads.ts +54 -18
  49. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  50. package/package.json +2 -2
@@ -1033,6 +1033,29 @@ export default function (pi) {
1033
1033
  delivery: deliveryLifecycleRuntime,
1034
1034
  api: telegramApiTargetActivityRuntime,
1035
1035
  });
1036
+ const prepareTemporaryThreadJournalInspection = function (target, requiredJournalBindingKeys, ownInputs) {
1037
+ const binding = resolveTelegramUpdateJournalBinding();
1038
+ const botToken = configStore.getBotToken();
1039
+ if (!binding || !botToken)
1040
+ return undefined;
1041
+ const profile = configStore.getActiveProfileName() ?? "default";
1042
+ const botIdentity = Journal.createTelegramUpdateJournalBotIdentity({ botToken, botId: getTelegramBotId() });
1043
+ return {
1044
+ directory: Paths.resolveTelegramTempDir(), profile, botIdentity,
1045
+ pollingPath: telegramLeaderJournalPath.resolve(configStore.getActiveProfileName()),
1046
+ requiredJournalBindingKeys: [binding.recoveryKey, ...requiredJournalBindingKeys],
1047
+ ...(target.threadId !== undefined && ownInputs
1048
+ ? { cleanup: { target: { chatId: target.chatId, threadId: target.threadId }, ownInputs } } : {}),
1049
+ limits: { maxDirectoryEntries: 10_000, maxFiles: 4096, maxBytes: 64 * 1024 * 1024,
1050
+ maxEntries: 10_000, maxWork: 100_000 },
1051
+ withSourceReference(path, operation) {
1052
+ return telegramJournalReferenceRegistry.withReference({
1053
+ referenceClass: "operator-disposition",
1054
+ recoveryKey: Journal.createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity }),
1055
+ }, operation);
1056
+ },
1057
+ };
1058
+ };
1036
1059
  const inboundRouteRuntime = Routing.createTelegramInboundRouteRuntime({
1037
1060
  configStore,
1038
1061
  callApi: callTelegramApi,
@@ -1112,53 +1135,24 @@ export default function (pi) {
1112
1135
  });
1113
1136
  },
1114
1137
  inspectTemporaryThreadSources(target, requiredJournalBindingKeys, ownInputs) {
1115
- const binding = resolveTelegramUpdateJournalBinding();
1116
- const botToken = configStore.getBotToken();
1117
- if (!binding || !botToken)
1138
+ const input = prepareTemporaryThreadJournalInspection(target, requiredJournalBindingKeys, ownInputs);
1139
+ if (!input)
1118
1140
  return undefined;
1119
- const profile = configStore.getActiveProfileName() ?? "default";
1120
- const botIdentity = Journal.createTelegramUpdateJournalBotIdentity({
1121
- botToken,
1122
- botId: getTelegramBotId(),
1123
- });
1124
1141
  return withTelegramJournalSourceSerialization(function () {
1125
- return Journal.isTelegramThreadCleanupJournalNamespaceClear({
1126
- directory: Paths.resolveTelegramTempDir(),
1127
- profile,
1128
- botIdentity,
1129
- pollingPath: telegramLeaderJournalPath.resolve(configStore.getActiveProfileName()),
1130
- requiredJournalBindingKeys: [
1131
- binding.recoveryKey,
1132
- ...requiredJournalBindingKeys,
1133
- ],
1134
- ...(target.threadId !== undefined && ownInputs
1135
- ? {
1136
- cleanup: {
1137
- target: { chatId: target.chatId, threadId: target.threadId },
1138
- ownInputs,
1139
- },
1140
- }
1141
- : {}),
1142
- limits: {
1143
- maxDirectoryEntries: 10_000,
1144
- maxFiles: 4096,
1145
- maxBytes: 64 * 1024 * 1024,
1146
- maxEntries: 10_000,
1147
- maxWork: 100_000,
1148
- },
1149
- withSourceReference(path, operation) {
1150
- return telegramJournalReferenceRegistry.withReference({
1151
- referenceClass: "operator-disposition",
1152
- recoveryKey: Journal.createTelegramUpdateJournalBindingKey({
1153
- path,
1154
- profileName: profile,
1155
- botIdentity,
1156
- }),
1157
- }, operation);
1158
- },
1159
- })
1160
- ? []
1161
- : undefined;
1142
+ return Journal.isTelegramThreadCleanupJournalNamespaceClear(input) ? [] : undefined;
1143
+ });
1144
+ },
1145
+ recoverTemporaryThreadDeadQueueOwners(target, requiredJournalBindingKeys, ownInputs, isCurrent) {
1146
+ const input = prepareTemporaryThreadJournalInspection(target, requiredJournalBindingKeys, ownInputs);
1147
+ if (!input || !isCurrent())
1148
+ return 0;
1149
+ return Journal.recoverTelegramThreadCleanupDeadQueueOwners({
1150
+ ...input, isCurrent, withSourceSerialization: withTelegramJournalSourceSerialization,
1151
+ recoveryOwner: {
1152
+ instanceId: telegramInstanceId, processId: telegramProcessId,
1153
+ processBirthId: telegramQueueProcessBirthId,
1154
+ sessionGeneration: telegramSessionContextStore.getGeneration(),
1155
+ },
1162
1156
  });
1163
1157
  },
1164
1158
  hasWorkspaceRestoreAuthority() {
@@ -2485,6 +2479,7 @@ export default function (pi) {
2485
2479
  deleteMessage: deleteTelegramMessage,
2486
2480
  sendGuestReply,
2487
2481
  editGuestReply,
2482
+ editGuestInlineMessage: telegramApiRuntime.editGuestInlineMessage,
2488
2483
  stopGuestPlaceholder: guestPlaceholderRuntime.stop,
2489
2484
  finalizeMarkdownPreview,
2490
2485
  preparePreviewDelivery,
@@ -751,6 +751,13 @@ export declare function isTelegramThreadCleanupJournalNamespaceClear(input: Tele
751
751
  }[];
752
752
  };
753
753
  }): boolean;
754
+ /** Demand-driven recovery only; caller holds temporary-cleanup admission. Never a deletion grant. */
755
+ export declare function recoverTelegramThreadCleanupDeadQueueOwners(input: Parameters<typeof isTelegramThreadCleanupJournalNamespaceClear>[0] & {
756
+ recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
757
+ isCurrent: () => boolean;
758
+ withSourceSerialization: NonNullable<TelegramUpdateJournalStoreOptions["withSourceSerialization"]>;
759
+ getQueueProcessLiveness?: TelegramUpdateJournalStoreOptions["getQueueProcessLiveness"];
760
+ }): number;
754
761
  export declare function publishTelegramUpdateJournalSegment(path: string, segment: TelegramUpdateJournalSegment): TelegramUpdateJournalSegmentPublicationResult;
755
762
  export declare function createTelegramUpdateQueueHandoffToken(): string;
756
763
  export declare function createTelegramUpdateJournalBotIdentity(input: {
@@ -1988,6 +1988,86 @@ export function isTelegramThreadCleanupJournalNamespaceClear(input) {
1988
1988
  return namespace.sources.every((source) => source.evidence.kind === "absent" ||
1989
1989
  source.evidence.file.entries.every((entry) => isUnrelatedPlainInput(entry, source.path)));
1990
1990
  }
1991
+ /** Demand-driven recovery only; caller holds temporary-cleanup admission. Never a deletion grant. */
1992
+ export function recoverTelegramThreadCleanupDeadQueueOwners(input) {
1993
+ return input.withSourceSerialization(() => {
1994
+ const assertCurrent = () => {
1995
+ if (!input.isCurrent())
1996
+ throw new Error("Temporary cleanup queue recovery lost authority.");
1997
+ };
1998
+ assertCurrent();
1999
+ if (!input.cleanup)
2000
+ return 0;
2001
+ // Validate exact references and complete namespace coverage before considering any mutation.
2002
+ if (isTelegramThreadCleanupJournalNamespaceClear(input))
2003
+ return 0;
2004
+ const namespace = inspectTelegramSessionJournalNamespace(input);
2005
+ if (namespace.retainedInputs?.some(value => value.state !== "committed") ||
2006
+ input.requiredJournalBindingKeys.some(key => !namespace.sources.some(source => source.path === getTelegramUpdateJournalBindingPath(key) && source.evidence.kind === "present")))
2007
+ return 0;
2008
+ const plans = [];
2009
+ for (const source of namespace.sources) {
2010
+ if (source.evidence.kind !== "present")
2011
+ continue;
2012
+ const file = source.evidence.file;
2013
+ if (file.version !== TELEGRAM_UPDATE_JOURNAL_VERSION)
2014
+ continue;
2015
+ const groups = new Map();
2016
+ for (const entry of file.entries) {
2017
+ if (!entry.queueReceiptId)
2018
+ continue;
2019
+ const group = groups.get(entry.queueReceiptId) ?? [];
2020
+ group.push(entry);
2021
+ groups.set(entry.queueReceiptId, group);
2022
+ }
2023
+ for (const group of groups.values()) {
2024
+ const entry = group[0];
2025
+ if (entry.state !== "queued" || !entry.queueReceiptId || !entry.queueOwner ||
2026
+ !entry.queueKind || entry.queueHandoff || entry.inputClaim)
2027
+ continue;
2028
+ if (group.some(value => value.state !== "queued" || value.queueHandoff || value.inputClaim ||
2029
+ value.queueKind !== entry.queueKind || !isDeepStrictEqual(value.queueOwner, entry.queueOwner)))
2030
+ continue;
2031
+ plans.push({ path: source.path, recovery: {
2032
+ queueKind: entry.queueKind, receiptId: entry.queueReceiptId,
2033
+ sourceUpdateIds: group.map(value => value.updateId).sort((a, b) => a - b),
2034
+ deadOwner: cloneJournalQueueOwner(entry.queueOwner), recoveryOwner: { ...input.recoveryOwner },
2035
+ } });
2036
+ }
2037
+ }
2038
+ const liveness = input.getQueueProcessLiveness ?? getTelegramProcessLiveness;
2039
+ const dead = plans.filter(plan => {
2040
+ assertCurrent();
2041
+ const result = liveness(plan.recovery.deadOwner);
2042
+ assertCurrent();
2043
+ return result === "dead";
2044
+ });
2045
+ let removed = 0;
2046
+ for (const plan of dead) {
2047
+ assertCurrent();
2048
+ removed += input.withSourceReference(plan.path, () => {
2049
+ const journal = createTelegramUpdateJournalStore({
2050
+ path: plan.path, profileName: input.profile, botIdentity: input.botIdentity,
2051
+ queueRuntimeIdentity: input.recoveryOwner,
2052
+ // This private synchronous store stays inside the cross-family serializer already held above.
2053
+ withSourceSerialization(operation) { assertCurrent(); return operation(); },
2054
+ sourceAccess: { directory: dirname(plan.path), limits: input.limits },
2055
+ getQueueProcessLiveness(owner) {
2056
+ assertCurrent();
2057
+ const result = liveness(owner);
2058
+ assertCurrent();
2059
+ return result;
2060
+ },
2061
+ withWriterAdmission(operation) { assertCurrent(); return operation(); },
2062
+ });
2063
+ // The journal transaction rechecks whole-group identity, absence of handoff and fresh process-death proof.
2064
+ return journal.recoverDeadQueueOwner(plan.recovery).recoveredUpdateIds.length;
2065
+ });
2066
+ }
2067
+ assertCurrent();
2068
+ return removed;
2069
+ });
2070
+ }
1991
2071
  function cloneEntry(entry) {
1992
2072
  return {
1993
2073
  ...entry,
@@ -73,6 +73,7 @@ export interface TelegramQueuedOutboundAttachmentView {
73
73
  export interface TelegramOutboundAttachmentQueueTargetView {
74
74
  queuedAttachments: TelegramQueuedOutboundAttachmentView[];
75
75
  guestQueryId?: string;
76
+ guestInlineMessageId?: string;
76
77
  }
77
78
  export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
78
79
  chatId: number;
@@ -161,6 +162,17 @@ export declare function queueTelegramOutboundAttachments(options: {
161
162
  }): Promise<TelegramOutboundAttachmentToolResult>;
162
163
  export declare function deliverTelegramGuestCachedAttachment(options: {
163
164
  guestQueryId: string;
165
+ guestInlineMessageId?: string;
166
+ editGuestInlineMessage?: (inlineMessageId: string, content: {
167
+ media: {
168
+ type: "photo" | "document" | "audio";
169
+ media: string;
170
+ caption?: string;
171
+ };
172
+ }, options: {
173
+ assertAuthority: () => void;
174
+ }) => Promise<void>;
175
+ isDeliveryActive?: () => boolean;
164
176
  stagingChatId: number;
165
177
  stagingTarget?: TelegramTarget;
166
178
  attachment: TelegramQueuedOutboundAttachmentView;
@@ -375,6 +375,9 @@ export async function queueTelegramOutboundAttachments(options) {
375
375
  maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
376
376
  statPath: options.statPath,
377
377
  });
378
+ if (options.activeTurn.guestInlineMessageId && pendingAttachments.some((attachment) => getTelegramGuestAttachmentTransport(attachment.path).fileField === "voice")) {
379
+ throw new Error("Telegram Guest Mode cannot replace an inline placeholder with a voice note; no attachment was queued. Use MP3 audio or a normal bot chat.");
380
+ }
378
381
  options.activeTurn.queuedAttachments.push(...pendingAttachments);
379
382
  const added = pendingAttachments.map((attachment) => attachment.path);
380
383
  return {
@@ -389,6 +392,16 @@ export async function queueTelegramOutboundAttachments(options) {
389
392
  }
390
393
  export async function deliverTelegramGuestCachedAttachment(options) {
391
394
  const transport = getTelegramGuestAttachmentTransport(options.attachment.path);
395
+ const isActive = () => options.isDeliveryActive?.() !== false;
396
+ const assertActive = () => {
397
+ if (!isActive())
398
+ throw new Error("Guest attachment delivery lost authority.");
399
+ };
400
+ if (!isActive())
401
+ return;
402
+ if (options.guestInlineMessageId && (!options.editGuestInlineMessage || transport.fileField === "voice")) {
403
+ throw new Error("Guest inline attachment editing is unavailable for this media.");
404
+ }
392
405
  let stagingMessageId;
393
406
  let answerAttempted = false;
394
407
  try {
@@ -397,6 +410,8 @@ export async function deliverTelegramGuestCachedAttachment(options) {
397
410
  ...getTelegramMultipartTargetFields(options.stagingTarget),
398
411
  }, transport.fileField, options.attachment.path, options.attachment.fileName));
399
412
  stagingMessageId = message.message_id;
413
+ if (!isActive())
414
+ return;
400
415
  const caption = options.caption
401
416
  ? Array.from(options.caption).slice(0, 1024).join("")
402
417
  : undefined;
@@ -446,11 +461,26 @@ export async function deliverTelegramGuestCachedAttachment(options) {
446
461
  ...(caption ? { caption } : {}),
447
462
  };
448
463
  }
464
+ if (!isActive())
465
+ return;
449
466
  answerAttempted = true;
450
- await options.answerGuestQuery(options.guestQueryId, result);
467
+ if (options.guestInlineMessageId && options.editGuestInlineMessage) {
468
+ if (result.type === "voice")
469
+ throw new Error("Voice notes cannot replace inline placeholders.");
470
+ const media = result.type === "photo" ? result.photo_file_id
471
+ : result.type === "audio" ? result.audio_file_id : result.document_file_id;
472
+ await options.editGuestInlineMessage(options.guestInlineMessageId, {
473
+ media: { type: result.type, media, ...(caption ? { caption } : {}) },
474
+ }, { assertAuthority: assertActive });
475
+ }
476
+ else {
477
+ await options.answerGuestQuery(options.guestQueryId, result);
478
+ }
451
479
  }
452
480
  catch (error) {
453
- if (!answerAttempted && options.answerGuestText && options.fallbackText) {
481
+ if (!isActive())
482
+ return;
483
+ if (!options.guestInlineMessageId && !answerAttempted && options.answerGuestText && options.fallbackText) {
454
484
  answerAttempted = true;
455
485
  await options.answerGuestText(options.guestQueryId, options.fallbackText);
456
486
  }
@@ -459,7 +489,7 @@ export async function deliverTelegramGuestCachedAttachment(options) {
459
489
  }
460
490
  }
461
491
  finally {
462
- if (stagingMessageId !== undefined) {
492
+ if (stagingMessageId !== undefined && isActive()) {
463
493
  try {
464
494
  await options.deleteMessage(options.stagingChatId, stagingMessageId);
465
495
  }
@@ -506,10 +506,14 @@ export interface TelegramAgentEndRuntimeDeps<TTurn extends PendingTelegramTurn,
506
506
  }) => Promise<void>;
507
507
  sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
508
508
  /** Replaces the early guest ACK with the final text. */
509
- editGuestReply?: (inlineMessageId: string, markdown: string) => Promise<void>;
509
+ editGuestReply?: (inlineMessageId: string, markdown: string, options?: {
510
+ assertAuthority?: () => void;
511
+ }) => Promise<void>;
510
512
  /** Cancels the animated guest placeholder before the final edit. */
511
513
  stopGuestPlaceholder?: (inlineMessageId: string) => Promise<void>;
512
- sendGuestAttachment?: (turn: TTurn, attachment: QueuedAttachment, caption?: string) => Promise<void>;
514
+ sendGuestAttachment?: (turn: TTurn, attachment: QueuedAttachment, caption?: string, options?: {
515
+ isDeliveryActive?: () => boolean;
516
+ }) => Promise<void>;
513
517
  sendGuestVoiceReply?: (turn: TTurn, plan: TelegramAgentEndOutboundReplyPlan<TReplyMarkup>, caption?: string) => Promise<void>;
514
518
  planOutboundReply?: (markdown: string) => TelegramAgentEndOutboundReplyPlan<TReplyMarkup>;
515
519
  sendOutboundReplyArtifacts?: (turn: TTurn, plan: TelegramAgentEndOutboundReplyPlan, options?: {
@@ -1377,13 +1377,55 @@ export async function handleTelegramAgentEndRuntime(deps) {
1377
1377
  });
1378
1378
  }
1379
1379
  }
1380
- if (turn.guestInlineMessageId && deps.editGuestReply) {
1381
- const experimentText = assistant.errorMessage
1380
+ if (!isDeliveryActive())
1381
+ return;
1382
+ if (turn.guestInlineMessageId) {
1383
+ let experimentText = assistant.errorMessage
1382
1384
  ? "Telegram bridge: Pi failed while processing the request."
1383
1385
  : finalText;
1386
+ const [attachment] = turn.queuedAttachments;
1387
+ if (!assistant.errorMessage && attachment) {
1388
+ try {
1389
+ if (!deps.sendGuestAttachment)
1390
+ throw new Error("Guest attachment delivery is unavailable.");
1391
+ await deps.sendGuestAttachment(turn, attachment, finalText || undefined, { isDeliveryActive });
1392
+ if (isDeliveryActive() && endPlan.shouldDispatchNext)
1393
+ deps.dispatchNextQueuedTelegramTurn();
1394
+ return;
1395
+ }
1396
+ catch (error) {
1397
+ deps.recordRuntimeEvent?.("delivery", error, {
1398
+ phase: "guest-attachment", guestQueryId: turn.guestQueryId,
1399
+ });
1400
+ if (!isDeliveryActive())
1401
+ return;
1402
+ if (isTelegramApiCommitUnknownError(error)) {
1403
+ if (endPlan.shouldDispatchNext)
1404
+ deps.dispatchNextQueuedTelegramTurn();
1405
+ return;
1406
+ }
1407
+ experimentText = "Telegram bridge could not deliver the requested attachment. Please retry in a normal bot chat.";
1408
+ }
1409
+ }
1410
+ else if (!assistant.errorMessage && hasOutboundArtifacts) {
1411
+ experimentText = [finalText, "Telegram Guest Mode cannot replace an inline placeholder with a voice note. Please request MP3 audio or use a normal bot chat."].filter(Boolean).join("\n\n");
1412
+ }
1413
+ if (!deps.editGuestReply) {
1414
+ deps.recordRuntimeEvent?.("delivery", new Error("Guest inline reply editing is unavailable."), {
1415
+ phase: "guest-ack-edit", guestQueryId: turn.guestQueryId,
1416
+ });
1417
+ if (endPlan.shouldDispatchNext)
1418
+ deps.dispatchNextQueuedTelegramTurn();
1419
+ return;
1420
+ }
1384
1421
  if (experimentText) {
1385
1422
  try {
1386
- await deps.editGuestReply(turn.guestInlineMessageId, experimentText);
1423
+ await deps.editGuestReply(turn.guestInlineMessageId, experimentText, {
1424
+ assertAuthority() {
1425
+ if (!isDeliveryActive())
1426
+ throw new Error("Guest reply edit lost authority.");
1427
+ },
1428
+ });
1387
1429
  deps.recordRuntimeEvent?.("guest", new Error("Guest ACK edited the guest answer"), { phase: "guest-ack-edited", guestQueryId: turn.guestQueryId });
1388
1430
  }
1389
1431
  catch (error) {
@@ -1419,7 +1461,7 @@ export async function handleTelegramAgentEndRuntime(deps) {
1419
1461
  const [guestAttachment] = turn.queuedAttachments;
1420
1462
  if (guestAttachment && deps.sendGuestAttachment) {
1421
1463
  try {
1422
- await deps.sendGuestAttachment(turn, guestAttachment, finalText || undefined);
1464
+ await deps.sendGuestAttachment(turn, guestAttachment, finalText || undefined, { isDeliveryActive });
1423
1465
  }
1424
1466
  catch (error) {
1425
1467
  deps.recordRuntimeEvent?.("delivery", error, {
@@ -163,8 +163,12 @@ export declare function createGuestMarkdownReplyEditor(deps: {
163
163
  editGuestInlineMessage: (inlineMessageId: string, content: {
164
164
  richMessage?: TelegramInputRichMessage;
165
165
  text?: string;
166
+ }, options?: {
167
+ assertAuthority?: () => void;
166
168
  }) => Promise<void>;
167
- }): (inlineMessageId: string, markdown: string) => Promise<void>;
169
+ }): (inlineMessageId: string, markdown: string, options?: {
170
+ assertAuthority?: () => void;
171
+ }) => Promise<void>;
168
172
  /**
169
173
  * Guest Mode placeholder rotation: the early ACK is the first placeholder
170
174
  * frame and the runtime steps the remaining frames once per interval, moving
@@ -578,11 +578,11 @@ export function createGuestMarkdownReplySender(deps) {
578
578
  * chat/message pair.
579
579
  */
580
580
  export function createGuestMarkdownReplyEditor(deps) {
581
- return async (inlineMessageId, markdown) => {
581
+ return async (inlineMessageId, markdown, options) => {
582
582
  const [richMarkdown = markdown] = splitTelegramNativeMarkdown(markdown);
583
583
  await deps.editGuestInlineMessage(inlineMessageId, {
584
584
  richMessage: { markdown: richMarkdown },
585
- });
585
+ }, options);
586
586
  };
587
587
  }
588
588
  /**
@@ -211,6 +211,8 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
211
211
  canUsePreDispatchRebind?: (recipient?: Threads.TelegramWorkspaceRestoreRecipient) => boolean;
212
212
  /** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
213
213
  inspectTemporaryThreadSources?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[], ownInputs?: readonly Threads.TelegramTemporaryThreadInput[]) => readonly number[] | undefined;
214
+ /** Explicit cleanup-only recovery; never called by a read-only protection observer. */
215
+ recoverTemporaryThreadDeadQueueOwners?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[], ownInputs: readonly Threads.TelegramTemporaryThreadInput[], isCurrent: () => boolean) => number;
214
216
  /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
215
217
  temporaryThreadCleanupDelayMs?: number;
216
218
  /** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
@@ -4574,6 +4574,100 @@ export function createTelegramInboundRouteRuntime(deps) {
4574
4574
  }
4575
4575
  await promptEnqueue(messages, ctx);
4576
4576
  };
4577
+ /** Local menu acceptance owns a one-shot effect; source removal and cleanup precede its release, never its replay. */
4578
+ const stageTemporaryRerouteMenu = async (pending, command, messages, ctx, assertRecipientCurrent, completion) => {
4579
+ const original = pending.messages[0];
4580
+ const cap = captureTemporaryThreadAuthority(ctx);
4581
+ const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
4582
+ if (!cap?.isCurrent() || !temporary)
4583
+ return undefined;
4584
+ let accept, resume;
4585
+ const accepted = new Promise(resolve => { accept = resolve; });
4586
+ const released = new Promise(resolve => { resume = resolve; });
4587
+ const admission = {
4588
+ assertSourceCurrent() {
4589
+ if (!completion.isCurrent())
4590
+ throw new Error("Rerouted menu source authority changed.");
4591
+ },
4592
+ assertRecipientCurrent,
4593
+ reportCompleted() {
4594
+ const result = completion.reportCompleted();
4595
+ accept(result);
4596
+ return result;
4597
+ },
4598
+ async beforeEffects() {
4599
+ if (!(await released))
4600
+ throw new Error("Rerouted menu execution was not released.");
4601
+ },
4602
+ };
4603
+ const prepare = command.name === "start" ? commandHandler.prepareSelectedStartCommand
4604
+ : command.name === "help" ? commandHandler.prepareSelectedHelpCommand : commandHandler.prepareSelectedMenuCommand;
4605
+ const dispatch = prepare(command, messages, ctx, admission);
4606
+ if (!dispatch)
4607
+ return undefined;
4608
+ const execution = dispatch().catch(error => {
4609
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-execution" });
4610
+ return false;
4611
+ });
4612
+ if (!(await Promise.race([accepted, execution.then(() => false)]))) {
4613
+ resume(false);
4614
+ await execution;
4615
+ return undefined;
4616
+ }
4617
+ return async () => {
4618
+ let allowEffects = false;
4619
+ try {
4620
+ const confirmed = await new Promise(resolve => {
4621
+ let done = false, unsubscribe;
4622
+ const signal = Updates.getTelegramUpdateExecutionFence(original)?.signal;
4623
+ const finish = (result) => {
4624
+ if (done)
4625
+ return;
4626
+ done = true;
4627
+ clearTimeout(timer);
4628
+ unsubscribe?.();
4629
+ signal?.removeEventListener("abort", abort);
4630
+ resolve(result);
4631
+ };
4632
+ const abort = () => finish(false);
4633
+ const observe = () => {
4634
+ if (!cap.isCurrent() || !completion.isCurrent())
4635
+ finish(false);
4636
+ else if (completion.inspectCompletion())
4637
+ finish(true);
4638
+ };
4639
+ const timer = setTimeout(abort, deps.liveRebindCommandCompletionTimeoutMs ?? 8_000);
4640
+ timer.unref?.();
4641
+ unsubscribe = Updates.subscribeTelegramDeferredSourceCompletion(original, observe);
4642
+ signal?.addEventListener("abort", abort, { once: true });
4643
+ if (signal?.aborted)
4644
+ abort();
4645
+ else
4646
+ observe();
4647
+ });
4648
+ if (!confirmed)
4649
+ throw new Error("Rerouted menu source removal is unconfirmed; execution remains suppressed.");
4650
+ assertRecipientCurrent();
4651
+ cancelTemporaryThreadCleanup(temporary.token);
4652
+ try {
4653
+ await runTemporaryThreadCleanup(temporary.token, cap, false);
4654
+ }
4655
+ catch (error) {
4656
+ // Cleanup uncertainty retains its canonical grant, not command execution authority or a retry license.
4657
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-cleanup" });
4658
+ }
4659
+ assertRecipientCurrent();
4660
+ allowEffects = true;
4661
+ }
4662
+ catch (error) {
4663
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-release" });
4664
+ }
4665
+ finally {
4666
+ resume(allowEffects);
4667
+ await execution;
4668
+ }
4669
+ };
4670
+ };
4577
4671
  const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query), retainRecord = false) => {
4578
4672
  assertExecutionCurrent();
4579
4673
  const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
@@ -4888,9 +4982,17 @@ export function createTelegramInboundRouteRuntime(deps) {
4888
4982
  if (!cancelledCurrent())
4889
4983
  return;
4890
4984
  entry = cap.adopt(entry);
4891
- if (!entry ||
4892
- !cap.isCurrent() ||
4893
- isRerouteTargetProtected(target, undefined, entry))
4985
+ if (!entry || !cap.isCurrent())
4986
+ return;
4987
+ if (isRerouteTargetProtected(target, undefined, entry) && deps.recoverTemporaryThreadDeadQueueOwners &&
4988
+ store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary") {
4989
+ const inputs = Threads.getTelegramTemporaryThreadInputs(entry);
4990
+ const removed = deps.recoverTemporaryThreadDeadQueueOwners(target, [cap.journalBindingKey, ...inputs.map(input => input.journalBindingKey)], inputs, cap.isCurrent);
4991
+ if (removed > 0)
4992
+ deps.recordRuntimeEvent?.("routing", "Discarded historical queue receipts from confirmed-dead owners before temporary Thread cleanup.", { phase: "temporary-thread-dead-owner-recovery", removedUpdateCount: removed });
4993
+ }
4994
+ // Recovery is not deletion authority: recapture every ordinary protection after the journal CAS.
4995
+ if (!cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry))
4894
4996
  return;
4895
4997
  const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
4896
4998
  if (!issued || !cap.isCurrent())
@@ -5911,7 +6013,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5911
6013
  await deps.answerCallbackQuery(query.id);
5912
6014
  return true;
5913
6015
  };
5914
- const executeUnboundRerouteCallbackOperation = async (query, ctx) => {
6016
+ const executeUnboundRerouteCallbackOperation = async (query, ctx, afterAdmission) => {
5915
6017
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
5916
6018
  if (!parsed)
5917
6019
  return false;
@@ -6432,7 +6534,21 @@ export function createTelegramInboundRouteRuntime(deps) {
6432
6534
  const localMessages = pending.rerouteCopies
6433
6535
  ? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
6434
6536
  : reroutedMessages;
6435
- await dispatchPendingRerouteMessages(pending, localMessages, ctx);
6537
+ const command = pending.dispatchKind === "command"
6538
+ ? getLiveRebindMenu(localMessages) ?? getLiveRebindHelp(localMessages) : undefined;
6539
+ const completion = pending.rerouteCopies && command
6540
+ ? Updates.prepareTelegramDeferredSourceCompletion(pending.messages[0]) : undefined;
6541
+ if (command && completion) {
6542
+ const release = await stageTemporaryRerouteMenu(pending, command, localMessages, ctx, assertRerouteCurrent, completion);
6543
+ if (!release) {
6544
+ await deps.answerCallbackQuery(query.id, "Command acceptance unconfirmed; not resending");
6545
+ return true;
6546
+ }
6547
+ afterAdmission(release);
6548
+ }
6549
+ else {
6550
+ await dispatchPendingRerouteMessages(pending, localMessages, ctx);
6551
+ }
6436
6552
  assertRerouteCurrent();
6437
6553
  pending.messages = [];
6438
6554
  return completeRoutedReroute();
@@ -6441,14 +6557,22 @@ export function createTelegramInboundRouteRuntime(deps) {
6441
6557
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
6442
6558
  if (!parsed)
6443
6559
  return false;
6444
- return withPendingRerouteSelection(parsed.rerouteId, query, () => {
6445
- if (!deps.runWorkspaceOperation)
6446
- return executeUnboundRerouteCallbackOperation(query, ctx);
6447
- return deps.runWorkspaceOperation({
6448
- operationId: `workspace-reroute:${query.id}`,
6449
- operationKind: "workspace.route-unbound-thread",
6450
- scopes: [{ kind: "profile" }],
6451
- }, () => executeUnboundRerouteCallbackOperation(query, ctx));
6560
+ return withPendingRerouteSelection(parsed.rerouteId, query, async () => {
6561
+ let release;
6562
+ const operation = () => executeUnboundRerouteCallbackOperation(query, ctx, value => { release = value; });
6563
+ try {
6564
+ if (!deps.runWorkspaceOperation)
6565
+ return await operation();
6566
+ return await deps.runWorkspaceOperation({
6567
+ operationId: `workspace-reroute:${query.id}`,
6568
+ operationKind: "workspace.route-unbound-thread",
6569
+ scopes: [{ kind: "profile" }],
6570
+ }, operation);
6571
+ }
6572
+ finally {
6573
+ // Keep the exact source references, but release the non-reentrant Workspace gate before cleanup.
6574
+ await release?.();
6575
+ }
6452
6576
  });
6453
6577
  };
6454
6578
  const handleUnboundRerouteCallback = async (query, ctx) => {
@@ -343,6 +343,12 @@ export interface TelegramAnswerGuestQueryOptions {
343
343
  result?: TelegramGuestCachedMediaResult;
344
344
  }
345
345
  export interface TelegramEditGuestInlineMessageContent {
346
+ /** Cached media only: inline edits cannot upload a new local file. */
347
+ media?: {
348
+ type: "photo" | "document" | "audio";
349
+ media: string;
350
+ caption?: string;
351
+ };
346
352
  text?: string;
347
353
  richMessage?: TelegramInputRichMessage;
348
354
  parseMode?: "HTML";
@@ -465,13 +471,12 @@ export interface TelegramBridgeApiRuntime extends Pick<TelegramApiClient, "call"
465
471
  answerCallbackQuery: (callbackQueryId: string, text?: string, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
466
472
  answerGuestQuery: (guestQueryId: string, text?: string, options?: TelegramAnswerGuestQueryOptions) => Promise<void>;
467
473
  /**
468
- * Temporary Guest Mode ACK experiment: answers the guest query and returns
469
- * the sent inline message id so the final answer can edit that early reply.
470
- * Requires direct transport ownership; Telegram does not document editing
471
- * guest answers, so this exists only to falsify that behavior live.
474
+ * Answers the guest query and retains the sent inline message id for final
475
+ * text or cached-media replacement. Requires direct transport ownership;
476
+ * actual guest-media rendering remains live-client gated.
472
477
  */
473
478
  answerGuestQueryForInlineMessage: (guestQueryId: string, text?: string, options?: TelegramAnswerGuestQueryOptions) => Promise<string | undefined>;
474
- editGuestInlineMessage: (inlineMessageId: string, content: TelegramEditGuestInlineMessageContent) => Promise<void>;
479
+ editGuestInlineMessage: (inlineMessageId: string, content: TelegramEditGuestInlineMessageContent, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
475
480
  deleteMessage: (chatId: number, messageId: number) => Promise<void>;
476
481
  prepareTempDir: () => Promise<number>;
477
482
  }