@llblab/pi-kit 0.36.1 → 0.36.3

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 (77) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +4 -0
  4. package/node_modules/@llblab/pi-claude-usage/README.md +2 -2
  5. package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +2 -3
  6. package/node_modules/@llblab/pi-claude-usage/package.json +1 -1
  7. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +5 -0
  8. package/node_modules/@llblab/pi-codex-usage/README.md +1 -1
  9. package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +2 -3
  10. package/node_modules/@llblab/pi-codex-usage/package.json +1 -1
  11. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  12. package/node_modules/@llblab/pi-telegram/BACKLOG.md +43 -5
  13. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +45 -0
  14. package/node_modules/@llblab/pi-telegram/README.md +4 -2
  15. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +17 -1
  16. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +23 -11
  17. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +24 -6
  18. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +2 -1
  19. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +7 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +7 -4
  21. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +22 -4
  22. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +52 -10
  23. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +1 -0
  24. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.js +9 -0
  25. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -1
  26. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +1 -1
  27. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +3 -2
  28. package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +4 -4
  29. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -2
  30. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +6 -3
  31. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +50 -9
  32. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +3 -2
  33. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +120 -14
  34. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +13 -0
  35. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +12 -0
  36. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +39 -3
  37. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +144 -16
  38. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +1 -1
  39. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +15 -8
  40. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +14 -1
  41. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +122 -8
  42. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +9 -6
  43. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +4 -3
  44. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +3 -3
  45. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +1 -0
  46. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +52 -2
  47. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +17 -0
  48. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +25 -0
  49. package/node_modules/@llblab/pi-telegram/dist/package.json +2 -1
  50. package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
  51. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +23 -3
  52. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -0
  53. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  54. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +24 -1
  55. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +30 -13
  56. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +25 -8
  57. package/node_modules/@llblab/pi-telegram/lib/bus.ts +11 -0
  58. package/node_modules/@llblab/pi-telegram/lib/commands.ts +34 -8
  59. package/node_modules/@llblab/pi-telegram/lib/extension.ts +54 -11
  60. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +12 -0
  61. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +2 -0
  62. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +4 -3
  63. package/node_modules/@llblab/pi-telegram/lib/menu.ts +4 -4
  64. package/node_modules/@llblab/pi-telegram/lib/pi.ts +2 -0
  65. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +79 -16
  66. package/node_modules/@llblab/pi-telegram/lib/routing.ts +142 -26
  67. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +18 -0
  68. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +173 -17
  69. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +14 -8
  70. package/node_modules/@llblab/pi-telegram/lib/threads.ts +166 -10
  71. package/node_modules/@llblab/pi-telegram/lib/updates.ts +9 -6
  72. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +8 -7
  73. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +56 -2
  74. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +44 -0
  75. package/node_modules/@llblab/pi-telegram/package.json +2 -1
  76. package/node_modules/@llblab/pi-telegram/scripts/run-macos-tests.mjs +43 -0
  77. package/package.json +4 -4
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Telegram prompt injection helpers
3
3
  * Zones: pi agent prompts, telegram guidance
4
- * Owns Telegram-specific system prompt suffixes injected into pi agent turns
4
+ * Owns stable Telegram guidance, turn-scoped context, and compatibility fallback hooks
5
5
  */
6
6
 
7
- import type { BeforeAgentStartEvent } from "./pi.ts";
7
+ import type { BeforeAgentStartEvent, BeforeAgentStartEventResult } from "./pi.ts";
8
8
  import { TELEGRAM_PREFIX } from "./turns.ts";
9
9
 
10
10
  export const TELEGRAM_CONNECTED_CONTEXT_MESSAGE =
@@ -164,18 +164,34 @@ type TelegramBeforeAgentStartResult = {
164
164
  systemPrompt: TelegramSystemPrompt;
165
165
  };
166
166
 
167
+ type TelegramProactiveBeforeAgentStartResult = Omit<BeforeAgentStartEventResult, "systemPrompt"> & {
168
+ systemPrompt?: TelegramSystemPrompt;
169
+ };
170
+
167
171
  type TelegramBeforeAgentStartHook = (
168
172
  event: TelegramBeforeAgentStartEvent,
169
173
  ) => TelegramBeforeAgentStartResult;
170
174
 
171
- export function buildTelegramBridgeSystemPrompt(options: {
175
+ // Returning any systemPrompt makes Pi replace its prompt, which defeats prompt
176
+ // caching; return an override only when the computed prompt actually differs.
177
+ function overrideSystemPromptIfChanged(
178
+ incoming: TelegramSystemPrompt | null | undefined,
179
+ computed: TelegramSystemPrompt,
180
+ ): TelegramBeforeAgentStartResult | undefined {
181
+ const base = incoming ?? "";
182
+ const unchanged =
183
+ Array.isArray(base) && Array.isArray(computed)
184
+ ? base.length === computed.length &&
185
+ base.every((block, index) => block === computed[index])
186
+ : base === computed;
187
+ return unchanged ? undefined : { systemPrompt: computed };
188
+ }
189
+
190
+ function buildTelegramTurnSuffix(options: {
172
191
  prompt?: string;
173
- systemPrompt?: TelegramSystemPrompt | null;
174
192
  telegramPrefix?: string;
175
- localSystemPromptSuffix: string;
176
193
  telegramTurnSystemPromptSuffix: string;
177
- }): TelegramBeforeAgentStartResult {
178
- const basePrompt = options.systemPrompt ?? "";
194
+ }): string {
179
195
  const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
180
196
  const telegramHead = telegramPrefix.endsWith("]")
181
197
  ? telegramPrefix.slice(0, -1)
@@ -184,9 +200,20 @@ export function buildTelegramBridgeSystemPrompt(options: {
184
200
  const telegramTurn =
185
201
  trimmedPrompt.startsWith(`${telegramHead}]`) ||
186
202
  trimmedPrompt.startsWith(`${telegramHead}|`);
187
- const telegramSuffix = telegramTurn
203
+ return telegramTurn
188
204
  ? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
189
205
  : "";
206
+ }
207
+
208
+ export function buildTelegramBridgeSystemPrompt(options: {
209
+ prompt?: string;
210
+ systemPrompt?: TelegramSystemPrompt | null;
211
+ telegramPrefix?: string;
212
+ localSystemPromptSuffix: string;
213
+ telegramTurnSystemPromptSuffix: string;
214
+ }): TelegramBeforeAgentStartResult {
215
+ const basePrompt = options.systemPrompt ?? "";
216
+ const telegramSuffix = buildTelegramTurnSuffix(options);
190
217
  return {
191
218
  systemPrompt: Array.isArray(basePrompt)
192
219
  ? [...basePrompt, options.localSystemPromptSuffix + telegramSuffix]
@@ -236,22 +263,58 @@ export interface TelegramProactivePromptHookDeps<TContext> {
236
263
  isAvailable: (ctx: TContext) => boolean;
237
264
  }
238
265
 
266
+ const TELEGRAM_TOOL_NAMES = ["telegram_attach", "telegram_message"];
267
+
268
+ // Sections cannot modify an opaque prompt owned by an earlier handler.
269
+ // Preserve append/strip compatibility there instead of silently losing guidance.
270
+ function applyTelegramPromptSections(
271
+ event: TelegramBeforeAgentStartEvent,
272
+ available: boolean,
273
+ ): boolean {
274
+ const options = event.systemPromptOptions as
275
+ | Partial<TelegramBeforeAgentStartEvent["systemPromptOptions"]>
276
+ | undefined;
277
+ const sections = options?.sections;
278
+ if (typeof sections !== "object" || sections === null || options?.forceSystemPrompt !== undefined) return false;
279
+ if (available) {
280
+ sections.telegram = LOCAL_SYSTEM_PROMPT_SUFFIX.trim();
281
+ return true;
282
+ }
283
+ delete sections.telegram;
284
+ for (const name of TELEGRAM_TOOL_NAMES) {
285
+ if (options?.toolSnippets) delete options.toolSnippets[name];
286
+ if (options?.toolGuidelines) delete options.toolGuidelines[name];
287
+ }
288
+ return true;
289
+ }
290
+
239
291
  export function createTelegramProactiveBeforeAgentStartHook<TContext>(
240
292
  deps: TelegramProactivePromptHookDeps<TContext>,
241
293
  ): (
242
294
  event: TelegramBeforeAgentStartEvent,
243
295
  ctx: TContext,
244
- ) => Promise<TelegramBeforeAgentStartResult> {
296
+ ) => Promise<TelegramProactiveBeforeAgentStartResult | undefined> {
245
297
  const baseHook = deps.baseHook ?? createTelegramBeforeAgentStartHook();
246
298
  return async (event, ctx) => {
247
299
  deps.reconcileAvailability?.();
248
- if (!deps.isAvailable(ctx)) {
249
- return {
250
- systemPrompt: stripTelegramToolMetadataFromSystemPrompt(
251
- event.systemPrompt,
252
- ),
253
- };
300
+ const available = deps.isAvailable(ctx);
301
+ // A caller-supplied baseHook owns its own prompt text, so it stays on the forced-prompt path.
302
+ if (!deps.baseHook && applyTelegramPromptSections(event, available)) {
303
+ const turnSuffix = available ? buildTelegramTurnSuffix({
304
+ prompt: event.messageType === "custom" ? undefined : event.prompt,
305
+ telegramTurnSystemPromptSuffix: TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX,
306
+ }).trim() : "";
307
+ return turnSuffix ? { message: { customType: "telegram-turn", content: turnSuffix, display: false } } : undefined;
308
+ }
309
+ if (!available) {
310
+ return overrideSystemPromptIfChanged(
311
+ event.systemPrompt,
312
+ stripTelegramToolMetadataFromSystemPrompt(event.systemPrompt),
313
+ );
254
314
  }
255
- return baseHook(event);
315
+ return overrideSystemPromptIfChanged(
316
+ event.systemPrompt,
317
+ baseHook(event).systemPrompt,
318
+ );
256
319
  };
257
320
  }
@@ -1499,6 +1499,8 @@ export interface TelegramInboundRouteRuntimeDeps<
1499
1499
  getCurrentInstanceId?: () => string | undefined;
1500
1500
  getAdmissionScope?: () => string | undefined;
1501
1501
  getAdmissionJournalBinding?: () => string | undefined;
1502
+ getCommandReplyTarget?:
1503
+ Commands.TelegramCommandTargetRuntimeDeps<TContext>["getReplyTarget"];
1502
1504
  getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
1503
1505
  getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
1504
1506
  recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
@@ -1640,7 +1642,7 @@ export interface TelegramInboundRouteRuntimeDeps<
1640
1642
  ) => Promise<void>;
1641
1643
  openSettingsMenu?: (
1642
1644
  chatId: number,
1643
- replyToMessageId: number,
1645
+ replyToMessageId: number | undefined,
1644
1646
  ctx: TContext,
1645
1647
  threadId?: number,
1646
1648
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
@@ -1728,7 +1730,7 @@ export interface TelegramInboundRouteRuntimeDeps<
1728
1730
  startGuestPlaceholder?: (inlineMessageId: string) => void;
1729
1731
  sendTextReply: (
1730
1732
  chatId: number,
1731
- replyToMessageId: number,
1733
+ replyToMessageId: number | undefined,
1732
1734
  text: string,
1733
1735
  options?: {
1734
1736
  parseMode?: "HTML";
@@ -2026,6 +2028,8 @@ export function createTelegramInboundRouteRuntime<
2026
2028
  ): boolean =>
2027
2029
  pending.phase.kind !== "waiting" && pending.phase.kind !== "released";
2028
2030
  const pendingUnboundReroutes = new Map<string, PendingUnboundReroute>();
2031
+ // Body-free, bounded warm deduplication: album members can expire through the same chooser.
2032
+ const expiredRoutingViews = new Set<string>();
2029
2033
  const implicitThreadCreations = new Map<
2030
2034
  string,
2031
2035
  {
@@ -2542,6 +2546,20 @@ export function createTelegramInboundRouteRuntime<
2542
2546
  temporaryDeleteConfirmed = true;
2543
2547
  return result;
2544
2548
  } catch (error) {
2549
+ const missingTarget = TelegramApi.getTelegramApiErrorRequestTarget(error);
2550
+ if (method === "deleteForumTopic" && missingTarget && error instanceof Error &&
2551
+ TelegramApi.isTelegramApiRequestRejected(error, method) &&
2552
+ TelegramApi.isTelegramStaleTargetHttpError(error) &&
2553
+ ThreadReconciler.isTelegramTopicDeletedErrorMessage(error.message) &&
2554
+ missingTarget?.chatId === temporaryCleanup.target?.chatId &&
2555
+ missingTarget?.threadId === temporaryCleanup.target?.threadId &&
2556
+ missingTarget?.chatId === body.chat_id && missingTarget?.threadId === body.message_thread_id) {
2557
+ // Only the client's parsed exact-target HTTP 400 absence closes the one-shot grant.
2558
+ // Closed/denied/foreign/forged responses remain uncertain and never license another request.
2559
+ assertExecutionCurrent?.();
2560
+ temporaryDeleteConfirmed = true;
2561
+ return true as TResponse;
2562
+ }
2545
2563
  temporaryTransportUncertain = true;
2546
2564
  throw error;
2547
2565
  }
@@ -8102,7 +8120,11 @@ export function createTelegramInboundRouteRuntime<
8102
8120
  signal: AbortSignal,
8103
8121
  ): Promise<void> => {
8104
8122
  const operator = deps.configStore.getAllowedUserId(),
8105
- epoch = deps.getCurrentLeaderEpoch?.();
8123
+ epoch = deps.getCurrentLeaderEpoch?.(),
8124
+ generation = deps.getSessionGeneration?.(),
8125
+ instanceId = deps.getCurrentInstanceId?.(),
8126
+ scope = deps.getAdmissionScope?.();
8127
+ const sendTextReply = deps.sendTextReply, editInteractiveMessage = deps.editInteractiveMessage;
8106
8128
  const message = source.original.update.message as
8107
8129
  Partial<TMessage> | undefined;
8108
8130
  const runtimeCurrent = () =>
@@ -8112,6 +8134,9 @@ export function createTelegramInboundRouteRuntime<
8112
8134
  deps.isContextActive?.(ctx) === true &&
8113
8135
  deps.configStore.getAllowedUserId() === operator &&
8114
8136
  deps.getCurrentLeaderEpoch?.() === epoch &&
8137
+ deps.getSessionGeneration?.() === generation &&
8138
+ deps.getCurrentInstanceId?.() === instanceId &&
8139
+ deps.getAdmissionScope?.() === scope &&
8115
8140
  deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
8116
8141
  source.original.routingInput?.operatorUserId === operator &&
8117
8142
  message?.from?.id === operator &&
@@ -8142,6 +8167,13 @@ export function createTelegramInboundRouteRuntime<
8142
8167
  if (!result) return;
8143
8168
  // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
8144
8169
  let movedIntoTab = false;
8170
+ let unissued = source.original.routingInput?.phase === "waiting" &&
8171
+ (!temporary || temporary.forwardProtocol === "one-shot-v1") &&
8172
+ ![...(temporary?.forwardedInputs ?? []), ...(temporary?.completedInputs ?? [])].some(input =>
8173
+ input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId));
8174
+ const views = new Map<string, { chatId: number; threadId?: number; messageId: number }>();
8175
+ const chooser = source.original.routingInput?.chooser;
8176
+ if (chooser) views.set(`${chooser.chatId}:${chooser.messageId}`, { ...chooser });
8145
8177
  for (const [id, pending] of pendingUnboundReroutes) {
8146
8178
  if (
8147
8179
  !Updates.collectTelegramAdmissionSourceUpdateIds(
@@ -8150,32 +8182,55 @@ export function createTelegramInboundRouteRuntime<
8150
8182
  )
8151
8183
  continue;
8152
8184
  movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
8185
+ unissued &&= isPendingRerouteUntouched(pending) || pending.phase.kind === "released";
8186
+ if (pending.chooserMessageId !== undefined)
8187
+ views.set(`${pending.sourceTarget.chatId}:${pending.chooserMessageId}`, {
8188
+ ...pending.sourceTarget, messageId: pending.chooserMessageId,
8189
+ });
8153
8190
  removePendingReroute(id);
8154
- if (
8155
- signal.aborted ||
8156
- deps.isContextActive?.(ctx) !== true ||
8157
- deps.configStore.getAllowedUserId() !== operator ||
8158
- deps.getCurrentLeaderEpoch?.() !== epoch ||
8159
- deps.getAdmissionJournalBinding?.() !== source.journalBindingKey
8160
- )
8161
- continue;
8162
- if (
8163
- deps.editInteractiveMessage &&
8164
- pending.chooserMessageId !== undefined
8165
- ) {
8191
+ }
8192
+ const notice = unissued
8193
+ ? "<b>⌛ Routing expired; input was not delivered.</b>"
8194
+ : "<b>⌛ Routing expired; delivery is unconfirmed.</b>\nPreviously accepted work may continue.";
8195
+ const noticeKey = `${source.journalBindingKey}:${source.original.routingInput?.expiresAtMs}:${chooser ? `${chooser.chatId}:${chooser.messageId}` : source.original.updateId}`;
8196
+ if (runtimeCurrent() && !expiredRoutingViews.has(noticeKey)) {
8197
+ if (expiredRoutingViews.size >= 100) expiredRoutingViews.delete(expiredRoutingViews.values().next().value!);
8198
+ expiredRoutingViews.add(noticeKey);
8199
+ let visible = false;
8200
+ const assertNoticeAuthority = () => {
8201
+ if (!runtimeCurrent()) throw new Error("Routing expiry notice authority changed.");
8202
+ };
8203
+ for (const view of views.values()) {
8204
+ if (!runtimeCurrent() || !editInteractiveMessage || deps.editInteractiveMessage !== editInteractiveMessage) break;
8205
+ if (view.chatId !== message!.chat!.id || !Number.isSafeInteger(view.threadId) || view.threadId! <= 0) continue;
8166
8206
  try {
8167
- await deps.editInteractiveMessage(
8168
- pending.sourceTarget.chatId,
8169
- pending.chooserMessageId,
8170
- "<b>⌛ Routing choice expired.</b>",
8171
- "html",
8172
- { inline_keyboard: [] },
8173
- );
8174
- } catch (error) {
8175
- deps.recordRuntimeEvent?.("routing", error, {
8176
- phase: "routing-input-expiry-view",
8207
+ await editInteractiveMessage(view.chatId, view.messageId, notice, "html", { inline_keyboard: [] }, {
8208
+ target: { chatId: view.chatId, threadId: view.threadId }, assertAuthority: assertNoticeAuthority,
8177
8209
  });
8210
+ visible = true;
8211
+ } catch (error) {
8212
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" });
8213
+ }
8214
+ }
8215
+ // A temporary tab is about to disappear; a cold/missing chooser also needs an independent owned destination.
8216
+ const leader = instanceId ? deps.threadStore?.getActiveByInstanceId(instanceId) : undefined;
8217
+ if ((!visible || temporary?.target) && runtimeCurrent() && leader &&
8218
+ leader.target.chatId === message!.chat!.id && Number.isSafeInteger(leader.target.threadId) && leader.target.threadId! > 0) {
8219
+ const target = { chatId: leader.target.chatId, threadId: leader.target.threadId };
8220
+ try {
8221
+ const assertFallback = () => {
8222
+ assertNoticeAuthority();
8223
+ const active = deps.threadStore?.getActiveByInstanceId(instanceId!);
8224
+ if (deps.sendTextReply !== sendTextReply || active?.target.chatId !== target.chatId || active.target.threadId !== target.threadId)
8225
+ throw new Error("Routing expiry notice destination changed.");
8226
+ };
8227
+ assertFallback();
8228
+ await sendTextReply(target.chatId, 0, notice, { parseMode: "HTML", target, assertAuthority: assertFallback });
8229
+ } catch (error) {
8230
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-notice" });
8178
8231
  }
8232
+ } else if (!visible && runtimeCurrent()) {
8233
+ deps.recordRuntimeEvent?.("routing", "Routing expiry notice has no current owned Thread.", { phase: "routing-input-expiry-notice-unavailable" });
8179
8234
  }
8180
8235
  }
8181
8236
  // The scheduled stage captures only context/epoch and a tab token. It reconstructs disposition from body-free proof,
@@ -8434,7 +8489,12 @@ export function createTelegramInboundRouteRuntime<
8434
8489
  )
8435
8490
  continue;
8436
8491
  try {
8437
- await retireCancellationChooser(id, pending, isCurrent);
8492
+ if (await retireCancellationChooser(id, pending, isCurrent))
8493
+ await deleteCancelledOrphanTab(
8494
+ pending,
8495
+ pending.abandonment!,
8496
+ isCurrent,
8497
+ );
8438
8498
  } catch (error) {
8439
8499
  deps.recordRuntimeEvent?.("routing", error, {
8440
8500
  phase: "recovered-chooser-cleanup",
@@ -8463,6 +8523,60 @@ export function createTelegramInboundRouteRuntime<
8463
8523
  }
8464
8524
  return true;
8465
8525
  };
8526
+ /**
8527
+ * A cancelled input in an unbound (non-temporary) tab leaves nothing to route: once no chooser, queue, live owner,
8528
+ * Workspace/Restore record or retained journal arrival still names the tab, it is deleted like a routing tab. Any
8529
+ * missing proof or failure keeps the tab; this never retries and never blocks the Cancel outcome.
8530
+ */
8531
+ const deleteCancelledOrphanTab = async (
8532
+ pending: PendingUnboundReroute,
8533
+ cancellation: NonNullable<PendingUnboundReroute["abandonment"]>,
8534
+ isAuthorityCurrent: () => boolean,
8535
+ ): Promise<void> => {
8536
+ const { chatId, threadId } = pending.sourceTarget;
8537
+ if (
8538
+ threadId === undefined ||
8539
+ pending.temporaryThread ||
8540
+ isTemporaryTabTarget({ chatId, threadId }) ||
8541
+ hasPendingRerouteForTarget({ chatId, threadId }) ||
8542
+ !deps.inspectTemporaryThreadSources
8543
+ )
8544
+ return;
8545
+ const target = { chatId, threadId };
8546
+ try {
8547
+ const own = {
8548
+ journalBindingKey: cancellation.journalBindingKey,
8549
+ updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(
8550
+ pending.messages,
8551
+ ),
8552
+ };
8553
+ const assertCurrent = (): void => {
8554
+ if (!isAuthorityCurrent())
8555
+ throw new Error("Unbound tab cleanup authority changed.");
8556
+ };
8557
+ assertCurrent();
8558
+ if (
8559
+ deps.inspectTemporaryThreadSources(
8560
+ target,
8561
+ [cancellation.journalBindingKey],
8562
+ [own],
8563
+ )?.length !== 0
8564
+ )
8565
+ return;
8566
+ assertCurrent();
8567
+ await closeReroutedUnboundTopic(
8568
+ target,
8569
+ pending.chooserMessageId,
8570
+ assertCurrent,
8571
+ );
8572
+ } catch (error) {
8573
+ deps.recordRuntimeEvent?.("routing", error, {
8574
+ phase: "unbound-cancel-tab-cleanup",
8575
+ chatId: target.chatId,
8576
+ threadId: target.threadId,
8577
+ });
8578
+ }
8579
+ };
8466
8580
  const handleUnboundRerouteCancelCallback = async (
8467
8581
  query: TCallbackQuery,
8468
8582
  ctx: TContext,
@@ -8580,6 +8694,7 @@ export function createTelegramInboundRouteRuntime<
8580
8694
  await Promise.all([
8581
8695
  deps.answerCallbackQuery(query.id, "Routing cancelled"),
8582
8696
  deleteAllTabCopies(pending!),
8697
+ deleteCancelledOrphanTab(pending!, cancellation!, isAuthorityCurrent),
8583
8698
  ]);
8584
8699
  } catch (error) {
8585
8700
  deps.recordRuntimeEvent?.("routing", error, {
@@ -10068,6 +10183,7 @@ export function createTelegramInboundRouteRuntime<
10068
10183
  appendControlItem: deps.queueMutationRuntime.append,
10069
10184
  getAdmissionScope: deps.getAdmissionScope,
10070
10185
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
10186
+ getReplyTarget: deps.getCommandReplyTarget,
10071
10187
  onControlQueued: (message, receipt) =>
10072
10188
  reportQueueAdmission([message], [receipt]),
10073
10189
  showStatus: deps.menuActions.sendStatusMessage,
@@ -1049,6 +1049,24 @@ export class TelegramApiStaleTargetError extends Error {
1049
1049
  }
1050
1050
  }
1051
1051
 
1052
+ /** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
1053
+ export class TelegramApiFollowerNotAllowedError extends Error {
1054
+ readonly requestTarget: { chatId: number; threadId: number } | undefined;
1055
+ /** Present only after a structured pre-execution refusal matches the requested API method. */
1056
+ readonly rejectedRequestMethod: string | undefined;
1057
+
1058
+ constructor(
1059
+ message: string,
1060
+ requestTarget?: { chatId: number; threadId: number },
1061
+ rejectedRequestMethod?: string,
1062
+ ) {
1063
+ super(message);
1064
+ this.name = "TelegramApiFollowerNotAllowedError";
1065
+ this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
1066
+ this.rejectedRequestMethod = rejectedRequestMethod;
1067
+ }
1068
+ }
1069
+
1052
1070
  export function getTelegramApiErrorRequestTarget(
1053
1071
  error: unknown,
1054
1072
  ): { chatId: number; threadId: number } | undefined {