@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
@@ -629,6 +629,7 @@ export function createTelegramCommandTargetQueueRuntime(deps) {
629
629
  createControlItem,
630
630
  enqueueControlItem: controlQueueController.enqueue,
631
631
  }),
632
+ getReplyTarget: deps.getReplyTarget,
632
633
  getAdmissionScope: deps.getAdmissionScope,
633
634
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
634
635
  onControlQueued: deps.onControlQueued,
@@ -639,6 +640,22 @@ export function createTelegramCommandTargetQueueRuntime(deps) {
639
640
  });
640
641
  }
641
642
  export function createTelegramCommandTargetRuntime(deps) {
643
+ const getReplyTarget = (message, options) => {
644
+ const source = getTelegramCommandMessageTarget(message);
645
+ const recipient = options?.assertAuthority
646
+ ? undefined
647
+ : deps.getReplyTarget?.(message);
648
+ if (!recipient)
649
+ return source;
650
+ if (recipient.chatId !== source.chatId)
651
+ throw new Error("Telegram command reply cannot change its recipient chat.");
652
+ if (source.threadId !== undefined && recipient.threadId === undefined)
653
+ throw new Error("Telegram command cannot move a Thread reply to All.");
654
+ return {
655
+ ...recipient,
656
+ replyToMessageId: recipient.threadId === source.threadId ? source.replyToMessageId : undefined,
657
+ };
658
+ };
642
659
  return {
643
660
  enqueueControlItem: (message, ctx, controlType, statusSummary, execute) => {
644
661
  const sourceUpdateId = message.pi_telegram_source_update_id;
@@ -659,15 +676,15 @@ export function createTelegramCommandTargetRuntime(deps) {
659
676
  deps.enqueueControlItem(getTelegramCommandMessageTarget(message), ctx, controlType, statusSummary, execute, receipt ? [receipt] : undefined, receipt ? () => deps.onControlQueued?.(message, receipt) : undefined);
660
677
  },
661
678
  showStatus: (message, ctx, options) => {
662
- const target = getTelegramCommandMessageTarget(message);
679
+ const target = getReplyTarget(message, options);
663
680
  return deps.showStatus(target.chatId, target.replyToMessageId, ctx, target.threadId, options);
664
681
  },
665
682
  openModelMenu: (message, ctx, options) => {
666
- const target = getTelegramCommandMessageTarget(message);
683
+ const target = getReplyTarget(message, options);
667
684
  return deps.openModelMenu(target.chatId, target.replyToMessageId, ctx, target.threadId, options);
668
685
  },
669
686
  openSettingsMenu: async (message, ctx, options) => {
670
- const target = getTelegramCommandMessageTarget(message);
687
+ const target = getReplyTarget(message, options);
671
688
  if (!deps.openSettingsMenu) {
672
689
  await deps.sendTextReply(target.chatId, target.replyToMessageId, formatTelegramInformationHeading("🚫", "Settings menu is unavailable."), { target, parseMode: "HTML" });
673
690
  return;
@@ -675,7 +692,7 @@ export function createTelegramCommandTargetRuntime(deps) {
675
692
  await deps.openSettingsMenu(target.chatId, target.replyToMessageId, ctx, target.threadId, options);
676
693
  },
677
694
  sendTextReply: async (message, text, options) => {
678
- const target = getTelegramCommandMessageTarget(message);
695
+ const target = getReplyTarget(message, options);
679
696
  const assertAuthority = options?.assertAuthority, send = deps.sendTextReply;
680
697
  const capturedOptions = { ...options, target };
681
698
  assertAuthority?.();
@@ -1061,6 +1078,7 @@ export function createTelegramCommandHandlerTargetRuntime(deps) {
1061
1078
  }),
1062
1079
  appendControlItem: deps.appendControlItem,
1063
1080
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
1081
+ getReplyTarget: deps.getReplyTarget,
1064
1082
  getAdmissionScope: deps.getAdmissionScope,
1065
1083
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
1066
1084
  onControlQueued: deps.onControlQueued,
@@ -454,17 +454,32 @@ export default function (pi) {
454
454
  return WorkspaceRetirement.isCurrentTelegramWorkspaceBinding(threadStore, binding);
455
455
  },
456
456
  });
457
- const inactiveThreadCleanupReviewRuntime = ThreadCleanupManager.createTelegramInactiveThreadCleanupReviewRuntime({
458
- getProfileName() {
459
- return configStore.getActiveProfileName() ?? "default";
457
+ const inactiveThreadCleanupRuntime = ThreadCleanupManager.createTelegramInactiveThreadCleanupRuntime({
458
+ store: threadStore,
459
+ captureAuthority() {
460
+ const ledger = workspaceAdmissionRuntime.resolve();
461
+ const leaderEpoch = getCurrentLeaderEpoch();
462
+ const operator = configStore.getAllowedUserId();
463
+ if (!ledger || leaderEpoch === undefined || operator === undefined)
464
+ return undefined;
465
+ const stamp = telegramTransportStampRuntime.getStamp();
466
+ const generation = telegramSessionContextStore.getGeneration();
467
+ return {
468
+ profileName: configStore.getActiveProfileName() ?? "default",
469
+ leaderEpoch, ledger,
470
+ isCurrent() {
471
+ return getCurrentLeaderEpoch() === leaderEpoch &&
472
+ telegramTransportStampRuntime.isActive(stamp) &&
473
+ configStore.getAllowedUserId() === operator &&
474
+ telegramSessionContextStore.getGeneration() === generation;
475
+ },
476
+ };
460
477
  },
461
- listBindings: threadStore.listWorkspaceBindings,
462
478
  getProtection: captureWorkspaceExternalProtection,
463
- listReservations: threadStore.listReservations,
464
- listPendingProvisions: threadStore.listPendingProvisions,
465
- listPendingCleanups: threadStore.listPendingCleanups,
466
- getWorkStore() {
467
- const profileName = configStore.getActiveProfileName() ?? "default";
479
+ getProcessLiveness: ProcessIdentity.getTelegramProcessLiveness,
480
+ runExclusive: telegramWorkspaceOperationRuntime.runExclusive,
481
+ getWorkStore(authority) {
482
+ const profileName = authority.profileName;
468
483
  const botToken = configStore.getBotToken();
469
484
  if (!botToken)
470
485
  throw new Error("Telegram Thread cleanup review requires an active bot token.");
@@ -474,9 +489,16 @@ export default function (pi) {
474
489
  tokenSha256: Journal.createTelegramUpdateJournalBotIdentity({
475
490
  botToken,
476
491
  }).tokenSha256,
492
+ admissionProfileKey: authority.ledger.getProfileKey(),
477
493
  });
478
494
  },
479
495
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
496
+ deleteThread(authorize) {
497
+ return directTelegramApiRuntime.deleteWorkspaceThread(authorize);
498
+ },
499
+ onError(error) {
500
+ recordRuntimeEvent("telegram", error, { phase: "manual-inactive-thread-cleanup" });
501
+ },
480
502
  });
481
503
  const updateAdmissionRuntimeBinding = Updates.createTelegramUpdateAdmissionRuntimeBinding({
482
504
  isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
@@ -940,7 +962,8 @@ export default function (pi) {
940
962
  sendInteractiveMessage,
941
963
  answerCallbackQuery,
942
964
  ...configControls,
943
- reviewInactiveThreads: inactiveThreadCleanupReviewRuntime.review,
965
+ reviewInactiveThreads: inactiveThreadCleanupRuntime.review,
966
+ cleanInactiveThreads: inactiveThreadCleanupRuntime.clean,
944
967
  getThreadDisplayMode() {
945
968
  return threadStore.getBotState().threadMode === "enabled"
946
969
  ? Config.resolveTelegramThreadDisplayMode(configStore.get())
@@ -1070,6 +1093,25 @@ export default function (pi) {
1070
1093
  },
1071
1094
  getAdmissionScope: getTelegramUpdateAdmissionScope,
1072
1095
  getAdmissionJournalBinding: getTelegramQueueJournalBinding,
1096
+ getCommandReplyTarget(message) {
1097
+ if (message.chat.type !== "private" ||
1098
+ "business_connection_id" in message ||
1099
+ !Updates.getTelegramUpdateExecutionFence(message) ||
1100
+ lockRuntime.owns())
1101
+ return undefined;
1102
+ Updates.assertTelegramUpdateExecutionCurrent(message);
1103
+ const binding = resolveTelegramFollowerJournalBinding();
1104
+ const target = telegramBusFollowerRegistrationState.getTarget();
1105
+ if (!telegramBusFollowerRegistrationState.isRegistered() ||
1106
+ !binding ||
1107
+ binding.recoveryKey !==
1108
+ followerAdmissionLifecycleRuntime.getJournalBindingKey() ||
1109
+ !target ||
1110
+ (threadStore.getBotState().threadMode === "enabled" &&
1111
+ target.threadId === undefined))
1112
+ throw new Error("Telegram command reply lost its registered recipient.");
1113
+ return { ...target };
1114
+ },
1073
1115
  beginCommandEffectWork: publicationRuntime.beginWork,
1074
1116
  getMessageOwnership: messageOwnershipRuntime.getForwardOwnership,
1075
1117
  recordMessageOwnership: messageOwnershipRuntime.recordRouted,
@@ -159,6 +159,7 @@ export interface TelegramBindToolRegistrationDeps extends GenerativeAppRuntimeOp
159
159
  recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
160
160
  }
161
161
  export declare function resolveGenerativeAppDir(agentDir: string, app: string): string;
162
+ export declare function isGenerativeAppInstalled(agentDir: string, app: string): Promise<boolean>;
162
163
  export declare function resolveGenerativeAppModulePath(agentDir: string, app: string): string;
163
164
  export declare function invokeGenerativeApp(options: GenerativeAppRuntimeOptions & {
164
165
  argument?: unknown;
@@ -86,6 +86,15 @@ export function resolveGenerativeAppDir(agentDir, app) {
86
86
  assertAppName(app);
87
87
  return join(getAppsRoot(agentDir), app);
88
88
  }
89
+ export async function isGenerativeAppInstalled(agentDir, app) {
90
+ try {
91
+ await stat(resolveGenerativeAppModulePath(agentDir, app));
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
89
98
  export function resolveGenerativeAppModulePath(agentDir, app) {
90
99
  return join(resolveGenerativeAppDir(agentDir, app), `${app}.mjs`);
91
100
  }
@@ -5,7 +5,7 @@
5
5
  * Transport authority, durable bindings, and queue custody remain with their owners.
6
6
  */
7
7
  import * as BusFollower from "./bus-follower.ts";
8
- import type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionCompactFailedEvent, SessionShutdownEvent, SessionStartEvent, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolExecutionUpdateEvent, TurnEndEvent, TurnEndEventResult, UIPromptEndEvent, UIPromptStartEvent } from "./pi.ts";
8
+ import type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, ExtensionAPI, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionCompactFailedEvent, SessionShutdownEvent, SessionStartEvent, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolExecutionUpdateEvent, TurnEndEvent, TurnEndEventResult, UIPromptEndEvent, UIPromptStartEvent } from "./pi.ts";
9
9
  import * as Machine from "./machine.ts";
10
10
  import * as Queue from "./queue.ts";
11
11
  export declare function createAgentStartDedupHook(inner: (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>, resetReplyDedup: () => void, schedulePublication?: (task: () => Promise<void>) => void): (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>;
@@ -14,6 +14,7 @@ type TelegramBeforeAgentStartEvent = Omit<BeforeAgentStartEvent, "systemPrompt">
14
14
  };
15
15
  export interface TelegramBeforeAgentStartResult {
16
16
  systemPrompt?: string | string[];
17
+ message?: BeforeAgentStartEventResult["message"];
17
18
  }
18
19
  type TelegramBeforeAgentStartReturn = Promise<TelegramBeforeAgentStartResult | undefined> | TelegramBeforeAgentStartResult | undefined;
19
20
  type TelegramLifecycleModel = ExtensionContext["model"];
@@ -52,7 +52,7 @@ export interface TelegramSettingsMenuCallbackDeps extends TelegramSettingsMutati
52
52
  sectionRegistry?: TelegramSectionRegistry;
53
53
  }
54
54
  export interface TelegramSettingsMenuRuntime<TContext> {
55
- openSettingsMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
55
+ openSettingsMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
56
56
  handleCallbackQuery: (query: {
57
57
  id: string;
58
58
  data?: string;
@@ -49,7 +49,7 @@ export function buildAutomaticThreadCleanupSettingsText(enabled) {
49
49
  "<code>-</code> <code>on</code> (default): delete the bound thread and release Telegram authority on graceful quit.",
50
50
  "<code>-</code> <code>off</code>: preserve the tab as a restart hint; manual <code>/telegram-disconnect</code> still confirms and deletes it.",
51
51
  "",
52
- "Review inactive tabs checks current owner and work evidence. Review never deletes tabs.",
52
+ "On the leader instance, Review inactive tabs checks current owner and work evidence, including tabs without a letter. Review never deletes tabs.",
53
53
  ].join("\n");
54
54
  }
55
55
  function buildInactiveThreadReviewText(count) {
@@ -58,7 +58,8 @@ function buildInactiveThreadReviewText(count) {
58
58
  "",
59
59
  `${count} proven inactive tab${count === 1 ? "" : "s"}.`,
60
60
  "No tabs were deleted.",
61
- "Deletion requires a separate confirmed Clean action.",
61
+ "Clean inactive tabs deletes these Telegram tabs and all their messages. Pi session history and project files are not removed.",
62
+ "Clean rechecks owner and work protection; a changed or protected tab is skipped. Choose Back to leave without deleting anything.",
62
63
  ].join("\n");
63
64
  }
64
65
  export function buildInactiveThreadReviewReplyMarkup(operationId, canCleanInactiveThreads = false) {
@@ -87,7 +87,7 @@ export interface TelegramMenuActionRuntimeDeps<TContext, TModel extends MenuMode
87
87
  storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
88
88
  isIdle: (ctx: TContext) => boolean;
89
89
  canOfferInFlightModelSwitch: (ctx: TContext) => boolean;
90
- sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
90
+ sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
91
91
  target?: {
92
92
  chatId: number;
93
93
  threadId?: number;
@@ -102,9 +102,9 @@ export interface TelegramMenuActionRuntime<TContext, TModel extends MenuModel =
102
102
  updateModelMenuMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
103
103
  updateThinkingMenuMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
104
104
  updateStatusMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
105
- sendStatusMessage: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
106
- openModelMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
107
- openThinkingMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
105
+ sendStatusMessage: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
106
+ openModelMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
107
+ openThinkingMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
108
108
  }
109
109
  export declare function handleTelegramMenuCallbackEntry(callbackQueryId: string, data: string | undefined, state: TelegramModelMenuState | undefined, deps: TelegramMenuCallbackEntryDeps): Promise<void>;
110
110
  export declare function handleStoredTelegramMenuCallback<TModel extends MenuModel = MenuModel>(query: MenuCallbackQuery, deps: StoredTelegramMenuCallbackDeps<TModel>): Promise<void>;
@@ -4,8 +4,8 @@
4
4
  * Owns direct pi SDK imports and exposes narrow bridge-facing helpers/types for the extension composition layer
5
5
  */
6
6
  import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
7
- import { type AgentEndEvent, type AgentSettledEvent, type AgentStartEvent, type BeforeAgentStartEvent, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type InputEvent, type MessageEndEvent, type SessionBeforeCompactEvent, type SessionCompactEvent, type SessionShutdownEvent, type SessionStartEvent, type SlashCommandInfo, type TurnEndEvent, type TurnEndEventResult, type UIPromptEndEvent, type UIPromptStartEvent } from "@earendil-works/pi-coding-agent";
8
- export type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, ExtensionAPI, ExtensionCommandContext, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionShutdownEvent, SessionStartEvent, SlashCommandInfo, TurnEndEvent, TurnEndEventResult, UIPromptEndEvent, UIPromptStartEvent, };
7
+ import { type AgentEndEvent, type AgentSettledEvent, type AgentStartEvent, type BeforeAgentStartEvent, type BeforeAgentStartEventResult, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type InputEvent, type MessageEndEvent, type SessionBeforeCompactEvent, type SessionCompactEvent, type SessionShutdownEvent, type SessionStartEvent, type SlashCommandInfo, type TurnEndEvent, type TurnEndEventResult, type UIPromptEndEvent, type UIPromptStartEvent } from "@earendil-works/pi-coding-agent";
8
+ export type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, ExtensionAPI, ExtensionCommandContext, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionShutdownEvent, SessionStartEvent, SlashCommandInfo, TurnEndEvent, TurnEndEventResult, UIPromptEndEvent, UIPromptStartEvent, };
9
9
  export interface SessionCompactFailedEvent {
10
10
  type: "session_compact_failed";
11
11
  reason: "manual" | "threshold" | "overflow";
@@ -1,9 +1,9 @@
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
- import type { BeforeAgentStartEvent } from "./pi.ts";
6
+ import type { BeforeAgentStartEvent, BeforeAgentStartEventResult } from "./pi.ts";
7
7
  export declare const TELEGRAM_CONNECTED_CONTEXT_MESSAGE = "Telegram session connected. Use Telegram features for Telegram-originated turns or explicit Telegram requests; connectivity alone is not user intent.";
8
8
  export declare const TELEGRAM_DISCONNECTED_CONTEXT_MESSAGE = "Telegram session disconnected. Do not use Telegram delivery, actions, or Telegram-specific reply features unless the user reconnects it.";
9
9
  export declare const TELEGRAM_ATTACH_PROMPT_SNIPPET = "Queue files for the active Telegram reply; outside Telegram turns, send files directly to Telegram.";
@@ -37,6 +37,9 @@ type TelegramBeforeAgentStartEvent = Omit<BeforeAgentStartEvent, "systemPrompt"
37
37
  type TelegramBeforeAgentStartResult = {
38
38
  systemPrompt: TelegramSystemPrompt;
39
39
  };
40
+ type TelegramProactiveBeforeAgentStartResult = Omit<BeforeAgentStartEventResult, "systemPrompt"> & {
41
+ systemPrompt?: TelegramSystemPrompt;
42
+ };
40
43
  type TelegramBeforeAgentStartHook = (event: TelegramBeforeAgentStartEvent) => TelegramBeforeAgentStartResult;
41
44
  export declare function buildTelegramBridgeSystemPrompt(options: {
42
45
  prompt?: string;
@@ -55,5 +58,5 @@ export interface TelegramProactivePromptHookDeps<TContext> {
55
58
  reconcileAvailability?: () => void;
56
59
  isAvailable: (ctx: TContext) => boolean;
57
60
  }
58
- export declare function createTelegramProactiveBeforeAgentStartHook<TContext>(deps: TelegramProactivePromptHookDeps<TContext>): (event: TelegramBeforeAgentStartEvent, ctx: TContext) => Promise<TelegramBeforeAgentStartResult>;
61
+ export declare function createTelegramProactiveBeforeAgentStartHook<TContext>(deps: TelegramProactivePromptHookDeps<TContext>): (event: TelegramBeforeAgentStartEvent, ctx: TContext) => Promise<TelegramProactiveBeforeAgentStartResult | undefined>;
59
62
  export {};
@@ -1,7 +1,7 @@
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
  import { TELEGRAM_PREFIX } from "./turns.js";
7
7
  export const TELEGRAM_CONNECTED_CONTEXT_MESSAGE = "Telegram session connected. Use Telegram features for Telegram-originated turns or explicit Telegram requests; connectivity alone is not user intent.";
@@ -107,8 +107,17 @@ export function createTelegramModelContextAvailabilityRuntime(deps) {
107
107
  },
108
108
  };
109
109
  }
110
- export function buildTelegramBridgeSystemPrompt(options) {
111
- const basePrompt = options.systemPrompt ?? "";
110
+ // Returning any systemPrompt makes Pi replace its prompt, which defeats prompt
111
+ // caching; return an override only when the computed prompt actually differs.
112
+ function overrideSystemPromptIfChanged(incoming, computed) {
113
+ const base = incoming ?? "";
114
+ const unchanged = Array.isArray(base) && Array.isArray(computed)
115
+ ? base.length === computed.length &&
116
+ base.every((block, index) => block === computed[index])
117
+ : base === computed;
118
+ return unchanged ? undefined : { systemPrompt: computed };
119
+ }
120
+ function buildTelegramTurnSuffix(options) {
112
121
  const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
113
122
  const telegramHead = telegramPrefix.endsWith("]")
114
123
  ? telegramPrefix.slice(0, -1)
@@ -116,9 +125,13 @@ export function buildTelegramBridgeSystemPrompt(options) {
116
125
  const trimmedPrompt = options.prompt?.trimStart() ?? "";
117
126
  const telegramTurn = trimmedPrompt.startsWith(`${telegramHead}]`) ||
118
127
  trimmedPrompt.startsWith(`${telegramHead}|`);
119
- const telegramSuffix = telegramTurn
128
+ return telegramTurn
120
129
  ? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
121
130
  : "";
131
+ }
132
+ export function buildTelegramBridgeSystemPrompt(options) {
133
+ const basePrompt = options.systemPrompt ?? "";
134
+ const telegramSuffix = buildTelegramTurnSuffix(options);
122
135
  return {
123
136
  systemPrompt: Array.isArray(basePrompt)
124
137
  ? [...basePrompt, options.localSystemPromptSuffix + telegramSuffix]
@@ -148,15 +161,43 @@ function stripTelegramToolMetadataFromSystemPrompt(systemPrompt) {
148
161
  ? systemPrompt.map(stripTelegramToolMetadataFromString)
149
162
  : stripTelegramToolMetadataFromString(systemPrompt);
150
163
  }
164
+ const TELEGRAM_TOOL_NAMES = ["telegram_attach", "telegram_message"];
165
+ // Sections cannot modify an opaque prompt owned by an earlier handler.
166
+ // Preserve append/strip compatibility there instead of silently losing guidance.
167
+ function applyTelegramPromptSections(event, available) {
168
+ const options = event.systemPromptOptions;
169
+ const sections = options?.sections;
170
+ if (typeof sections !== "object" || sections === null || options?.forceSystemPrompt !== undefined)
171
+ return false;
172
+ if (available) {
173
+ sections.telegram = LOCAL_SYSTEM_PROMPT_SUFFIX.trim();
174
+ return true;
175
+ }
176
+ delete sections.telegram;
177
+ for (const name of TELEGRAM_TOOL_NAMES) {
178
+ if (options?.toolSnippets)
179
+ delete options.toolSnippets[name];
180
+ if (options?.toolGuidelines)
181
+ delete options.toolGuidelines[name];
182
+ }
183
+ return true;
184
+ }
151
185
  export function createTelegramProactiveBeforeAgentStartHook(deps) {
152
186
  const baseHook = deps.baseHook ?? createTelegramBeforeAgentStartHook();
153
187
  return async (event, ctx) => {
154
188
  deps.reconcileAvailability?.();
155
- if (!deps.isAvailable(ctx)) {
156
- return {
157
- systemPrompt: stripTelegramToolMetadataFromSystemPrompt(event.systemPrompt),
158
- };
189
+ const available = deps.isAvailable(ctx);
190
+ // A caller-supplied baseHook owns its own prompt text, so it stays on the forced-prompt path.
191
+ if (!deps.baseHook && applyTelegramPromptSections(event, available)) {
192
+ const turnSuffix = available ? buildTelegramTurnSuffix({
193
+ prompt: event.messageType === "custom" ? undefined : event.prompt,
194
+ telegramTurnSystemPromptSuffix: TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX,
195
+ }).trim() : "";
196
+ return turnSuffix ? { message: { customType: "telegram-turn", content: turnSuffix, display: false } } : undefined;
197
+ }
198
+ if (!available) {
199
+ return overrideSystemPromptIfChanged(event.systemPrompt, stripTelegramToolMetadataFromSystemPrompt(event.systemPrompt));
159
200
  }
160
- return baseHook(event);
201
+ return overrideSystemPromptIfChanged(event.systemPrompt, baseHook(event).systemPrompt);
161
202
  };
162
203
  }
@@ -174,6 +174,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
174
174
  getCurrentInstanceId?: () => string | undefined;
175
175
  getAdmissionScope?: () => string | undefined;
176
176
  getAdmissionJournalBinding?: () => string | undefined;
177
+ getCommandReplyTarget?: Commands.TelegramCommandTargetRuntimeDeps<TContext>["getReplyTarget"];
177
178
  getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
178
179
  getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
179
180
  recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
@@ -256,7 +257,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
256
257
  isVoiceReplyActive?: () => boolean;
257
258
  updateSettingsMenuMessage?: (state: Menu.TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
258
259
  openQueueMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
259
- openSettingsMenu?: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
260
+ openSettingsMenu?: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
260
261
  settingsMenuCallbackHandler?: (query: TCallbackQuery, ctx: TContext) => Promise<boolean>;
261
262
  queueMenuCallbackHandler: (query: TCallbackQuery, ctx: TContext) => Promise<boolean>;
262
263
  buttonActionStore?: OutboundHandlers.TelegramButtonActionStore;
@@ -303,7 +304,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
303
304
  }) => Promise<string | undefined>;
304
305
  /** Starts the animated placeholder on an answered guest inline message. */
305
306
  startGuestPlaceholder?: (inlineMessageId: string) => void;
306
- sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
307
+ sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
307
308
  parseMode?: "HTML";
308
309
  target?: Queue.TelegramQueueTarget;
309
310
  assertAuthority?: TelegramApiCallOptions["assertAuthority"];
@@ -1052,6 +1052,8 @@ export function createTelegramInboundRouteRuntime(deps) {
1052
1052
  /** A frozen destination pauses All-command expiry until routing finishes or releases it. */
1053
1053
  const isPendingRerouteDestinationHeld = (pending) => pending.phase.kind !== "waiting" && pending.phase.kind !== "released";
1054
1054
  const pendingUnboundReroutes = new Map();
1055
+ // Body-free, bounded warm deduplication: album members can expire through the same chooser.
1056
+ const expiredRoutingViews = new Set();
1055
1057
  const implicitThreadCreations = new Map();
1056
1058
  const guidedUnboundTopicKeys = new Set();
1057
1059
  let nextUnboundRerouteId = 0;
@@ -1409,6 +1411,20 @@ export function createTelegramInboundRouteRuntime(deps) {
1409
1411
  return result;
1410
1412
  }
1411
1413
  catch (error) {
1414
+ const missingTarget = TelegramApi.getTelegramApiErrorRequestTarget(error);
1415
+ if (method === "deleteForumTopic" && missingTarget && error instanceof Error &&
1416
+ TelegramApi.isTelegramApiRequestRejected(error, method) &&
1417
+ TelegramApi.isTelegramStaleTargetHttpError(error) &&
1418
+ ThreadReconciler.isTelegramTopicDeletedErrorMessage(error.message) &&
1419
+ missingTarget?.chatId === temporaryCleanup.target?.chatId &&
1420
+ missingTarget?.threadId === temporaryCleanup.target?.threadId &&
1421
+ missingTarget?.chatId === body.chat_id && missingTarget?.threadId === body.message_thread_id) {
1422
+ // Only the client's parsed exact-target HTTP 400 absence closes the one-shot grant.
1423
+ // Closed/denied/foreign/forged responses remain uncertain and never license another request.
1424
+ assertExecutionCurrent?.();
1425
+ temporaryDeleteConfirmed = true;
1426
+ return true;
1427
+ }
1412
1428
  temporaryTransportUncertain = true;
1413
1429
  throw error;
1414
1430
  }
@@ -5524,7 +5540,8 @@ export function createTelegramInboundRouteRuntime(deps) {
5524
5540
  return formatTelegramRoutingInputPreview(message);
5525
5541
  };
5526
5542
  const expireRoutingInput = async (source, ctx, signal) => {
5527
- const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
5543
+ const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.(), generation = deps.getSessionGeneration?.(), instanceId = deps.getCurrentInstanceId?.(), scope = deps.getAdmissionScope?.();
5544
+ const sendTextReply = deps.sendTextReply, editInteractiveMessage = deps.editInteractiveMessage;
5528
5545
  const message = source.original.update.message;
5529
5546
  const runtimeCurrent = () => !signal.aborted &&
5530
5547
  operator !== undefined &&
@@ -5532,6 +5549,9 @@ export function createTelegramInboundRouteRuntime(deps) {
5532
5549
  deps.isContextActive?.(ctx) === true &&
5533
5550
  deps.configStore.getAllowedUserId() === operator &&
5534
5551
  deps.getCurrentLeaderEpoch?.() === epoch &&
5552
+ deps.getSessionGeneration?.() === generation &&
5553
+ deps.getCurrentInstanceId?.() === instanceId &&
5554
+ deps.getAdmissionScope?.() === scope &&
5535
5555
  deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
5536
5556
  source.original.routingInput?.operatorUserId === operator &&
5537
5557
  message?.from?.id === operator &&
@@ -5559,27 +5579,73 @@ export function createTelegramInboundRouteRuntime(deps) {
5559
5579
  return;
5560
5580
  // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
5561
5581
  let movedIntoTab = false;
5582
+ let unissued = source.original.routingInput?.phase === "waiting" &&
5583
+ (!temporary || temporary.forwardProtocol === "one-shot-v1") &&
5584
+ ![...(temporary?.forwardedInputs ?? []), ...(temporary?.completedInputs ?? [])].some(input => input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId));
5585
+ const views = new Map();
5586
+ const chooser = source.original.routingInput?.chooser;
5587
+ if (chooser)
5588
+ views.set(`${chooser.chatId}:${chooser.messageId}`, { ...chooser });
5562
5589
  for (const [id, pending] of pendingUnboundReroutes) {
5563
5590
  if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
5564
5591
  continue;
5565
5592
  movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
5593
+ unissued &&= isPendingRerouteUntouched(pending) || pending.phase.kind === "released";
5594
+ if (pending.chooserMessageId !== undefined)
5595
+ views.set(`${pending.sourceTarget.chatId}:${pending.chooserMessageId}`, {
5596
+ ...pending.sourceTarget, messageId: pending.chooserMessageId,
5597
+ });
5566
5598
  removePendingReroute(id);
5567
- if (signal.aborted ||
5568
- deps.isContextActive?.(ctx) !== true ||
5569
- deps.configStore.getAllowedUserId() !== operator ||
5570
- deps.getCurrentLeaderEpoch?.() !== epoch ||
5571
- deps.getAdmissionJournalBinding?.() !== source.journalBindingKey)
5572
- continue;
5573
- if (deps.editInteractiveMessage &&
5574
- pending.chooserMessageId !== undefined) {
5599
+ }
5600
+ const notice = unissued
5601
+ ? "<b>⌛ Routing expired; input was not delivered.</b>"
5602
+ : "<b>⌛ Routing expired; delivery is unconfirmed.</b>\nPreviously accepted work may continue.";
5603
+ const noticeKey = `${source.journalBindingKey}:${source.original.routingInput?.expiresAtMs}:${chooser ? `${chooser.chatId}:${chooser.messageId}` : source.original.updateId}`;
5604
+ if (runtimeCurrent() && !expiredRoutingViews.has(noticeKey)) {
5605
+ if (expiredRoutingViews.size >= 100)
5606
+ expiredRoutingViews.delete(expiredRoutingViews.values().next().value);
5607
+ expiredRoutingViews.add(noticeKey);
5608
+ let visible = false;
5609
+ const assertNoticeAuthority = () => {
5610
+ if (!runtimeCurrent())
5611
+ throw new Error("Routing expiry notice authority changed.");
5612
+ };
5613
+ for (const view of views.values()) {
5614
+ if (!runtimeCurrent() || !editInteractiveMessage || deps.editInteractiveMessage !== editInteractiveMessage)
5615
+ break;
5616
+ if (view.chatId !== message.chat.id || !Number.isSafeInteger(view.threadId) || view.threadId <= 0)
5617
+ continue;
5575
5618
  try {
5576
- await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⌛ Routing choice expired.</b>", "html", { inline_keyboard: [] });
5619
+ await editInteractiveMessage(view.chatId, view.messageId, notice, "html", { inline_keyboard: [] }, {
5620
+ target: { chatId: view.chatId, threadId: view.threadId }, assertAuthority: assertNoticeAuthority,
5621
+ });
5622
+ visible = true;
5577
5623
  }
5578
5624
  catch (error) {
5579
- deps.recordRuntimeEvent?.("routing", error, {
5580
- phase: "routing-input-expiry-view",
5581
- });
5625
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" });
5626
+ }
5627
+ }
5628
+ // A temporary tab is about to disappear; a cold/missing chooser also needs an independent owned destination.
5629
+ const leader = instanceId ? deps.threadStore?.getActiveByInstanceId(instanceId) : undefined;
5630
+ if ((!visible || temporary?.target) && runtimeCurrent() && leader &&
5631
+ leader.target.chatId === message.chat.id && Number.isSafeInteger(leader.target.threadId) && leader.target.threadId > 0) {
5632
+ const target = { chatId: leader.target.chatId, threadId: leader.target.threadId };
5633
+ try {
5634
+ const assertFallback = () => {
5635
+ assertNoticeAuthority();
5636
+ const active = deps.threadStore?.getActiveByInstanceId(instanceId);
5637
+ if (deps.sendTextReply !== sendTextReply || active?.target.chatId !== target.chatId || active.target.threadId !== target.threadId)
5638
+ throw new Error("Routing expiry notice destination changed.");
5639
+ };
5640
+ assertFallback();
5641
+ await sendTextReply(target.chatId, 0, notice, { parseMode: "HTML", target, assertAuthority: assertFallback });
5582
5642
  }
5643
+ catch (error) {
5644
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-notice" });
5645
+ }
5646
+ }
5647
+ else if (!visible && runtimeCurrent()) {
5648
+ deps.recordRuntimeEvent?.("routing", "Routing expiry notice has no current owned Thread.", { phase: "routing-input-expiry-notice-unavailable" });
5583
5649
  }
5584
5650
  }
5585
5651
  // The scheduled stage captures only context/epoch and a tab token. It reconstructs disposition from body-free proof,
@@ -5780,7 +5846,8 @@ export function createTelegramInboundRouteRuntime(deps) {
5780
5846
  if (!recordRecoveredRerouteSource(pending, source.original.updateId, source.result))
5781
5847
  continue;
5782
5848
  try {
5783
- await retireCancellationChooser(id, pending, isCurrent);
5849
+ if (await retireCancellationChooser(id, pending, isCurrent))
5850
+ await deleteCancelledOrphanTab(pending, pending.abandonment, isCurrent);
5784
5851
  }
5785
5852
  catch (error) {
5786
5853
  deps.recordRuntimeEvent?.("routing", error, {
@@ -5809,6 +5876,43 @@ export function createTelegramInboundRouteRuntime(deps) {
5809
5876
  }
5810
5877
  return true;
5811
5878
  };
5879
+ /**
5880
+ * A cancelled input in an unbound (non-temporary) tab leaves nothing to route: once no chooser, queue, live owner,
5881
+ * Workspace/Restore record or retained journal arrival still names the tab, it is deleted like a routing tab. Any
5882
+ * missing proof or failure keeps the tab; this never retries and never blocks the Cancel outcome.
5883
+ */
5884
+ const deleteCancelledOrphanTab = async (pending, cancellation, isAuthorityCurrent) => {
5885
+ const { chatId, threadId } = pending.sourceTarget;
5886
+ if (threadId === undefined ||
5887
+ pending.temporaryThread ||
5888
+ isTemporaryTabTarget({ chatId, threadId }) ||
5889
+ hasPendingRerouteForTarget({ chatId, threadId }) ||
5890
+ !deps.inspectTemporaryThreadSources)
5891
+ return;
5892
+ const target = { chatId, threadId };
5893
+ try {
5894
+ const own = {
5895
+ journalBindingKey: cancellation.journalBindingKey,
5896
+ updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages),
5897
+ };
5898
+ const assertCurrent = () => {
5899
+ if (!isAuthorityCurrent())
5900
+ throw new Error("Unbound tab cleanup authority changed.");
5901
+ };
5902
+ assertCurrent();
5903
+ if (deps.inspectTemporaryThreadSources(target, [cancellation.journalBindingKey], [own])?.length !== 0)
5904
+ return;
5905
+ assertCurrent();
5906
+ await closeReroutedUnboundTopic(target, pending.chooserMessageId, assertCurrent);
5907
+ }
5908
+ catch (error) {
5909
+ deps.recordRuntimeEvent?.("routing", error, {
5910
+ phase: "unbound-cancel-tab-cleanup",
5911
+ chatId: target.chatId,
5912
+ threadId: target.threadId,
5913
+ });
5914
+ }
5915
+ };
5812
5916
  const handleUnboundRerouteCancelCallback = async (query, ctx) => {
5813
5917
  if (!query.data?.startsWith(TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX))
5814
5918
  return false;
@@ -5890,6 +5994,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5890
5994
  await Promise.all([
5891
5995
  deps.answerCallbackQuery(query.id, "Routing cancelled"),
5892
5996
  deleteAllTabCopies(pending),
5997
+ deleteCancelledOrphanTab(pending, cancellation, isAuthorityCurrent),
5893
5998
  ]);
5894
5999
  }
5895
6000
  catch (error) {
@@ -7034,6 +7139,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7034
7139
  appendControlItem: deps.queueMutationRuntime.append,
7035
7140
  getAdmissionScope: deps.getAdmissionScope,
7036
7141
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
7142
+ getReplyTarget: deps.getCommandReplyTarget,
7037
7143
  onControlQueued: (message, receipt) => reportQueueAdmission([message], [receipt]),
7038
7144
  showStatus: deps.menuActions.sendStatusMessage,
7039
7145
  openModelMenu: deps.menuActions.openModelMenu,