@llblab/pi-kit 0.27.6 → 0.28.0

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 (182) hide show
  1. package/BACKLOG.md +2 -5
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +2 -2
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +10 -8
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -9
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
  7. package/node_modules/@llblab/pi-telegram/LICENSE +21 -0
  8. package/node_modules/@llblab/pi-telegram/README.md +15 -5
  9. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.d.ts +1 -5
  10. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.js +5 -7
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +5 -1
  12. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +6 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +4 -2
  14. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +54 -56
  15. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +206 -139
  16. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -2
  17. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +181 -25
  18. package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.d.ts +4 -1
  19. package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.js +28 -4
  20. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +52 -56
  21. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +78 -246
  22. package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.d.ts +1 -3
  23. package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.js +23 -26
  24. package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.d.ts +0 -5
  25. package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.js +5 -5
  26. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +0 -23
  27. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +15 -15
  28. package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -17
  29. package/node_modules/@llblab/pi-telegram/dist/lib/config.js +12 -14
  30. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.d.ts +0 -4
  31. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.js +2 -5
  32. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +255 -61
  33. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +0 -19
  34. package/node_modules/@llblab/pi-telegram/dist/lib/inbound.d.ts +0 -2
  35. package/node_modules/@llblab/pi-telegram/dist/lib/inbound.js +2 -2
  36. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +177 -16
  37. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +1115 -249
  38. package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.d.ts +0 -2
  39. package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.js +2 -2
  40. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +1 -1
  41. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +100 -12
  42. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +380 -14
  43. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +30 -12
  44. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +129 -72
  45. package/node_modules/@llblab/pi-telegram/dist/lib/media.d.ts +28 -5
  46. package/node_modules/@llblab/pi-telegram/dist/lib/media.js +26 -8
  47. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.d.ts +0 -11
  48. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.js +8 -8
  49. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +0 -18
  50. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +18 -18
  51. package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.d.ts +4 -4
  52. package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.js +12 -7
  53. package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.d.ts +1 -1
  54. package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.js +1 -1
  55. package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +1 -0
  56. package/node_modules/@llblab/pi-telegram/dist/lib/menu.js +4 -3
  57. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +0 -1
  58. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +1 -1
  59. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +1 -8
  60. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +1 -6
  61. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +4 -21
  62. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.d.ts +0 -8
  63. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +5 -5
  64. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +50 -7
  65. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +155 -17
  66. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +1 -0
  67. package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +2 -1
  68. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +0 -5
  69. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +5 -5
  70. package/node_modules/@llblab/pi-telegram/dist/lib/preview.d.ts +0 -3
  71. package/node_modules/@llblab/pi-telegram/dist/lib/preview.js +3 -3
  72. package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.d.ts +0 -1
  73. package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.js +1 -1
  74. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +3 -15
  75. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +69 -8
  76. package/node_modules/@llblab/pi-telegram/dist/lib/recovery.d.ts +29 -9
  77. package/node_modules/@llblab/pi-telegram/dist/lib/recovery.js +104 -33
  78. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +0 -4
  79. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +1 -1
  80. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +73 -8
  81. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +2206 -307
  82. package/node_modules/@llblab/pi-telegram/dist/lib/sections.d.ts +0 -2
  83. package/node_modules/@llblab/pi-telegram/dist/lib/sections.js +1 -1
  84. package/node_modules/@llblab/pi-telegram/dist/lib/status.d.ts +44 -9
  85. package/node_modules/@llblab/pi-telegram/dist/lib/status.js +149 -27
  86. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +0 -5
  87. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -4
  88. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -3
  89. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +34 -23
  90. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +1 -0
  91. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +18 -21
  92. package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.d.ts +32 -5
  93. package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.js +191 -7
  94. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.d.ts +2 -0
  95. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +1 -1
  96. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +291 -59
  97. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +1647 -369
  98. package/node_modules/@llblab/pi-telegram/dist/lib/turns.d.ts +0 -1
  99. package/node_modules/@llblab/pi-telegram/dist/lib/turns.js +1 -1
  100. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +184 -28
  101. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +1032 -114
  102. package/node_modules/@llblab/pi-telegram/dist/lib/wire.d.ts +12 -0
  103. package/node_modules/@llblab/pi-telegram/dist/lib/wire.js +21 -0
  104. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +25 -17
  105. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +95 -20
  106. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.d.ts +20 -0
  107. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.js +103 -0
  108. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +43 -8
  109. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +148 -52
  110. package/node_modules/@llblab/pi-telegram/dist/package.json +6 -6
  111. package/node_modules/@llblab/pi-telegram/dist/skills/generated-control-surface/SKILL.md +3 -3
  112. package/node_modules/@llblab/pi-telegram/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
  113. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
  114. package/node_modules/@llblab/pi-telegram/docs/activity.md +1 -1
  115. package/node_modules/@llblab/pi-telegram/docs/architecture.md +152 -34
  116. package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +1 -1
  117. package/node_modules/@llblab/pi-telegram/docs/delivery.md +1 -1
  118. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
  119. package/node_modules/@llblab/pi-telegram/docs/inbound.md +1 -1
  120. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +125 -19
  121. package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -1
  122. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +22 -8
  123. package/node_modules/@llblab/pi-telegram/docs/updates.md +1 -1
  124. package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +5 -9
  125. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +6 -0
  126. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +5 -2
  127. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +243 -224
  128. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +178 -31
  129. package/node_modules/@llblab/pi-telegram/lib/bus-transport.ts +30 -8
  130. package/node_modules/@llblab/pi-telegram/lib/bus.ts +110 -334
  131. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +29 -29
  132. package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +5 -5
  133. package/node_modules/@llblab/pi-telegram/lib/commands.ts +15 -15
  134. package/node_modules/@llblab/pi-telegram/lib/config.ts +12 -17
  135. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +2 -8
  136. package/node_modules/@llblab/pi-telegram/lib/extension.ts +257 -72
  137. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +0 -22
  138. package/node_modules/@llblab/pi-telegram/lib/inbound.ts +2 -2
  139. package/node_modules/@llblab/pi-telegram/lib/journal.ts +1182 -326
  140. package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +2 -2
  141. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +1 -1
  142. package/node_modules/@llblab/pi-telegram/lib/locks.ts +402 -23
  143. package/node_modules/@llblab/pi-telegram/lib/logging.ts +151 -101
  144. package/node_modules/@llblab/pi-telegram/lib/media.ts +51 -9
  145. package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +8 -8
  146. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +18 -18
  147. package/node_modules/@llblab/pi-telegram/lib/menu-status.ts +11 -0
  148. package/node_modules/@llblab/pi-telegram/lib/menu-thinking.ts +2 -2
  149. package/node_modules/@llblab/pi-telegram/lib/menu.ts +5 -1
  150. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +1 -1
  151. package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +1 -10
  152. package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +5 -24
  153. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +5 -5
  154. package/node_modules/@llblab/pi-telegram/lib/paths.ts +177 -17
  155. package/node_modules/@llblab/pi-telegram/lib/pi.ts +5 -2
  156. package/node_modules/@llblab/pi-telegram/lib/polling.ts +5 -5
  157. package/node_modules/@llblab/pi-telegram/lib/preview.ts +3 -3
  158. package/node_modules/@llblab/pi-telegram/lib/prompt-templates.ts +1 -1
  159. package/node_modules/@llblab/pi-telegram/lib/queue.ts +67 -9
  160. package/node_modules/@llblab/pi-telegram/lib/recovery.ts +104 -44
  161. package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
  162. package/node_modules/@llblab/pi-telegram/lib/routing.ts +1955 -375
  163. package/node_modules/@llblab/pi-telegram/lib/sections.ts +1 -1
  164. package/node_modules/@llblab/pi-telegram/lib/status.ts +156 -36
  165. package/node_modules/@llblab/pi-telegram/lib/sync.ts +4 -4
  166. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +48 -33
  167. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +24 -21
  168. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +267 -9
  169. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +3 -1
  170. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1697 -488
  171. package/node_modules/@llblab/pi-telegram/lib/turns.ts +1 -1
  172. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1072 -151
  173. package/node_modules/@llblab/pi-telegram/lib/wire.ts +28 -0
  174. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +97 -47
  175. package/node_modules/@llblab/pi-telegram/lib/workspace-identity.ts +147 -0
  176. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +188 -89
  177. package/node_modules/@llblab/pi-telegram/package.json +6 -6
  178. package/node_modules/@llblab/pi-telegram/scripts/audit-exports.mjs +100 -0
  179. package/node_modules/@llblab/pi-telegram/scripts/check-downgrade.mjs +80 -43
  180. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +3 -3
  181. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +3 -3
  182. package/package.json +2 -2
@@ -3,8 +3,10 @@
3
3
  * Zones: telegram inbound, orchestration, queue/menu/command composition
4
4
  * Wires authorized updates into menus, commands, media grouping, and prompt queueing, and owns exact assistant-output target/route authority capture
5
5
  */
6
+ import { randomBytes } from "node:crypto";
6
7
  import { readFile } from "node:fs/promises";
7
8
  import { basename, dirname } from "node:path";
9
+ import { isDeepStrictEqual } from "node:util";
8
10
  import * as Bus from "./bus.js";
9
11
  import * as Commands from "./commands.js";
10
12
  import * as Media from "./media.js";
@@ -17,6 +19,7 @@ import * as TextGroups from "./text-groups.js";
17
19
  import * as ThreadNaming from "./thread-naming.js";
18
20
  import * as ThreadReconciler from "./thread-reconciler.js";
19
21
  import * as Turns from "./turns.js";
22
+ import * as WorkspaceIdentity from "./workspace-identity.js";
20
23
  function formatTelegramPromptPeer(peer) {
21
24
  if (!peer)
22
25
  return undefined;
@@ -63,6 +66,21 @@ export function resolveTelegramGuestPromptPeer(input) {
63
66
  }
64
67
  return undefined;
65
68
  }
69
+ /** Stable file scope of the remote Guest Mode peer: username, else numeric id; never the bot's own scope. */
70
+ export function resolveTelegramGuestFileScope(input) {
71
+ const candidates = input.chatType !== "private" ? [input.chat]
72
+ : [input.from, input.chat, input.guestBotCallerUser, input.guestBotCallerChat, input.replyFrom];
73
+ for (const candidate of candidates) {
74
+ if (!candidate || (input.chatType === "private" &&
75
+ (isTelegramPromptOwnerPeer(candidate, input.ownerUserId) || isTelegramPromptBotPeer(candidate))))
76
+ continue;
77
+ if (typeof candidate.username === "string" && candidate.username.length > 0)
78
+ return candidate.username;
79
+ if (typeof candidate.id === "number" && Number.isSafeInteger(candidate.id))
80
+ return String(Math.abs(candidate.id));
81
+ }
82
+ return "guest";
83
+ }
66
84
  function appendTelegramSourceAttachmentSection(text, from, files, outputs = []) {
67
85
  if (files.length === 0 && outputs.length === 0)
68
86
  return text;
@@ -117,7 +135,12 @@ function escapeHtml(text) {
117
135
  }
118
136
  const TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX = "reroute:";
119
137
  const TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX = "rerouterestore:";
138
+ const TELEGRAM_UNBOUND_REROUTE_MENU_CALLBACK_PREFIX = "reroutemenu:";
139
+ const TELEGRAM_UNBOUND_REROUTE_ROOT_CALLBACK_PREFIX = "rerouteroot:";
120
140
  const TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX = "reroutenew:";
141
+ const TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX = "reroutecancel:";
142
+ const TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX = "reroutecancel:review:";
143
+ const TELEGRAM_RETIRED_HISTORICAL_REVIEW_PREFIX = "reroutecancel:history:";
121
144
  const TELEGRAM_SLOT_CAPACITY_MESSAGE = "No Telegram instance slot is available. Automatic reclamation is disabled for safety.";
122
145
  function formatTelegramUnboundRerouteCallbackData(rerouteId, threadId) {
123
146
  return `${TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
@@ -129,9 +152,9 @@ function formatTelegramUnboundRerouteNewSlotCallbackData(rerouteId, threadId) {
129
152
  return `${TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
130
153
  }
131
154
  function parseTelegramUnboundRerouteRestoreMenuCallbackData(data) {
132
- const match = data?.match(/^rerouterestore:([a-z0-9]+)$/);
133
- const rerouteId = match?.[1];
134
- return rerouteId ? { rerouteId } : undefined;
155
+ const match = data?.match(/^(rerouterestore|reroutemenu|rerouteroot):([a-z0-9]+)$/);
156
+ const rerouteId = match?.[2];
157
+ return rerouteId ? { rerouteId, restore: match?.[1] === "rerouterestore", root: match?.[1] === "rerouteroot" } : undefined;
135
158
  }
136
159
  function parseTelegramUnboundRerouteCallbackData(data) {
137
160
  const match = data?.match(/^(reroute|reroutenew):([a-z0-9]+):(\d+)$/);
@@ -145,17 +168,11 @@ function parseTelegramUnboundRerouteCallbackData(data) {
145
168
  function getTelegramThreadRecordLabel(record, getDisplayTitle) {
146
169
  return getDisplayTitle?.(record.target) ?? getRestoredThreadName(record, record.slot ?? "");
147
170
  }
148
- function getNextTelegramSlotPreference(slot) {
149
- if (!slot || !/^[A-Z]$/.test(slot))
150
- return undefined;
151
- const index = slot.charCodeAt(0) - "A".charCodeAt(0);
152
- return String.fromCharCode("A".charCodeAt(0) + ((index + 1) % 26));
153
- }
154
171
  function getRestoredThreadName(record, slot) {
155
172
  return record.threadName &&
156
- Threads.isTelegramTopicThreadNameValidForSlot(record.threadName, slot)
173
+ ThreadNaming.isTelegramTopicThreadNameValidForSlot(record.threadName, slot)
157
174
  ? record.threadName
158
- : (Threads.chooseTelegramThreadName({ slot }) ?? "Pi");
175
+ : (ThreadNaming.chooseTelegramThreadName({ slot }) ?? "Pi");
159
176
  }
160
177
  function isTelegramLiveThreadTarget(record, liveTargets) {
161
178
  if (!liveTargets)
@@ -167,6 +184,14 @@ function getTelegramRoutableThreadRecords(records, liveTargets) {
167
184
  return records.filter((record) => record.status === "active" &&
168
185
  isTelegramLiveThreadTarget(record, liveTargets));
169
186
  }
187
+ function formatTelegramTemporaryThreadChooserText(command) {
188
+ return [
189
+ "<b>🧵 Choose target thread:</b>",
190
+ "",
191
+ command ? `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.` : "Choose where to send your message.",
192
+ "Select the Pi thread that should handle it, or restore a Pi into this tab:",
193
+ ].join("\n");
194
+ }
170
195
  function formatTelegramAllTabMenuChooserText(command) {
171
196
  return [
172
197
  "<b>🧵 Choose target thread:</b>",
@@ -179,25 +204,19 @@ function formatTelegramAllTabMenuChooserText(command) {
179
204
  function buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, options) {
180
205
  const activeRecords = records.filter((record) => record.status === "active");
181
206
  const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
182
- const rows = activeRecords.map((record) => [
183
- {
184
- text: `↪️ ${getTelegramThreadRecordLabel(record, options.getDisplayTitle)}`,
185
- callback_data: formatTelegramUnboundRerouteCallbackData(rerouteId, record.target.threadId),
186
- },
187
- ]);
188
- return {
189
- inline_keyboard: canRestoreAnyLiveThread
190
- ? [
191
- ...rows,
192
- [
193
- {
194
- text: "🔁 Replace/restore thread…",
195
- callback_data: formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
196
- },
197
- ],
198
- ]
199
- : rows,
200
- };
207
+ const rows = activeRecords.length
208
+ ? [[{ text: "↪️ Reroute…", callback_data: `${TELEGRAM_UNBOUND_REROUTE_MENU_CALLBACK_PREFIX}${rerouteId}` }]] : [];
209
+ if (canRestoreAnyLiveThread)
210
+ rows.push([{
211
+ text: "🔁 Restore…",
212
+ callback_data: formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
213
+ }]);
214
+ if (options.canCancel)
215
+ rows.push([{
216
+ text: "⛔️ Cancel routing",
217
+ callback_data: `${TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX}${rerouteId}`,
218
+ }]);
219
+ return { inline_keyboard: rows };
201
220
  }
202
221
  function buildTelegramUnboundRerouteRestoreChooserMarkup(rerouteId, records, getDisplayTitle) {
203
222
  return {
@@ -231,20 +250,109 @@ function formatTelegramUnboundTopicGuidance() {
231
250
  function formatTelegramTargetKey(target) {
232
251
  return `${target.chatId}:${target.threadId ?? "all"}`;
233
252
  }
234
- function formatTelegramUnboundRerouteChooserText(_records, options = {}) {
235
- const rerouteText = [
236
- "<b>🧵 Choose target thread:</b>",
237
- "",
238
- "Your message is still in this Telegram thread.",
239
- "Select the Pi thread that should handle it:",
240
- ].join("\n");
241
- return options.includeGuidance === false
242
- ? rerouteText
243
- : [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n");
244
- }
245
253
  import * as Threads from "./threads.js";
246
254
  import * as Updates from "./updates.js";
247
255
  import { getTelegramVoiceReplyMode } from "./voice.js";
256
+ /** One admitted Restore attempt through recipient readiness, never source dispatch or cleanup. */
257
+ export async function advanceTelegramWorkspaceRestore(input) {
258
+ const isCurrent = input.authority.isCurrent.bind(input.authority);
259
+ if (!isCurrent())
260
+ return undefined;
261
+ const request = structuredClone(input.request);
262
+ if (!Threads.isTelegramWorkspaceRestoreRequest(request))
263
+ return undefined;
264
+ const getRecipient = input.getRecipient;
265
+ const runRecipient = input.runRecipient;
266
+ const observedRecipient = getRecipient();
267
+ if (!Threads.isTelegramWorkspaceRestoreRecipient(observedRecipient) || observedRecipient.sessionId !== request.binding.sessionId)
268
+ return undefined;
269
+ const recipient = structuredClone(observedRecipient);
270
+ const executor = structuredClone(input.authority.executor);
271
+ const operatorUserId = input.authority.operatorUserId;
272
+ const current = () => isCurrent() && input.authority.operatorUserId === operatorUserId &&
273
+ isDeepStrictEqual(input.authority.executor, executor) && isDeepStrictEqual(getRecipient(), recipient);
274
+ const authority = { executor, operatorUserId, isCurrent: current };
275
+ const { restoreStore } = input;
276
+ const retained = () => {
277
+ if (!current())
278
+ return undefined;
279
+ const found = restoreStore.list().find(value => value.request.operationId === request.operationId);
280
+ return current() && found && isDeepStrictEqual(found.request, request) && found.operatorUserId === operatorUserId &&
281
+ isDeepStrictEqual(found.executor, executor) ? found : undefined;
282
+ };
283
+ let intent = retained();
284
+ const predecessor = intent ? undefined : restoreStore.list().find(value => value.request.operationId === request.operationId);
285
+ if (predecessor && current() && isDeepStrictEqual(predecessor.request, request) &&
286
+ predecessor.operatorUserId === operatorUserId && !isDeepStrictEqual(predecessor.executor, executor)) {
287
+ // Adoption transfers executor authority only. Issued grants stay issued, so a successor can merely inspect them.
288
+ try {
289
+ intent = restoreStore.adopt(predecessor, authority);
290
+ }
291
+ catch (error) {
292
+ intent = retained();
293
+ if (!intent)
294
+ throw error;
295
+ }
296
+ if (!current() || !intent)
297
+ return undefined;
298
+ intent = retained();
299
+ if (!intent)
300
+ return undefined;
301
+ }
302
+ if (input.inspectOnly && !intent)
303
+ return undefined;
304
+ if (!intent || intent.phase === "relocated") {
305
+ if ((!input.inspectOnly && recipient.instanceId !== request.owner.instanceId) ||
306
+ recipient.kind !== (request.owner.owner?.kind === "leader" ? "leader" : "follower"))
307
+ return undefined;
308
+ intent = await restoreStore.commit(request, authority);
309
+ if (!current() || !intent)
310
+ return undefined;
311
+ }
312
+ let mode = "inspect";
313
+ if (intent.phase === "relocated") {
314
+ try {
315
+ const issuance = restoreStore.issueRecipient(intent, recipient, authority);
316
+ if (issuance) {
317
+ intent = issuance.intent;
318
+ mode = input.inspectOnly ? "inspect" : "apply";
319
+ }
320
+ else
321
+ intent = retained();
322
+ }
323
+ catch (error) {
324
+ intent = retained();
325
+ if (intent?.phase !== "recipient-issued")
326
+ throw error;
327
+ // A retained issued phase is not a fresh grant, even when publication's reply was lost.
328
+ }
329
+ }
330
+ if (!current() || !intent || !["recipient-issued", "ready"].includes(intent.phase) ||
331
+ (mode === "apply" && !isDeepStrictEqual(intent.recipient, recipient)))
332
+ return undefined;
333
+ const expected = structuredClone(intent);
334
+ if (!isDeepStrictEqual(retained(), expected))
335
+ return undefined;
336
+ const observation = await runRecipient({ intent: structuredClone(expected), mode, isCurrent: current });
337
+ if (!current() || !isDeepStrictEqual(retained(), expected) || !observation || observation.ready !== true ||
338
+ observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
339
+ !isDeepStrictEqual(observation.target, request.target) || observation.slot !== request.binding.slot)
340
+ return undefined;
341
+ if (expected.phase === "ready" && isDeepStrictEqual(expected.readyRecipient ?? expected.recipient, recipient))
342
+ return retained();
343
+ try {
344
+ intent = mode === "inspect" ? restoreStore.confirmInspectedReady(expected, recipient, authority) :
345
+ restoreStore.confirmReady(expected, recipient, authority);
346
+ }
347
+ catch (error) {
348
+ intent = retained();
349
+ if (intent?.phase !== "ready" || !isDeepStrictEqual(intent.readyRecipient ?? intent.recipient, recipient))
350
+ throw error;
351
+ }
352
+ const confirmed = retained();
353
+ return current() && confirmed?.phase === "ready" &&
354
+ isDeepStrictEqual(confirmed.readyRecipient ?? confirmed.recipient, recipient) ? confirmed : undefined;
355
+ }
248
356
  async function deleteReservedTelegramTopicThroughReconciler(deps, target, messageId) {
249
357
  if (!deps.threadStore)
250
358
  return false;
@@ -280,6 +388,8 @@ async function deleteReservedTelegramTopicThroughReconciler(deps, target, messag
280
388
  return plan.actions.some((action) => action.kind === "close-delete-reserved-topic");
281
389
  }
282
390
  export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
391
+ /** One user-facing answer for expired or previous-process routing controls. */
392
+ export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "⌛ Routing choice expired.";
283
393
  export function isTelegramAllTabCommandExpired(message, nowMs = Date.now()) {
284
394
  return message.message_thread_id === undefined &&
285
395
  typeof message.date === "number" && Number.isFinite(message.date) &&
@@ -319,6 +429,7 @@ export function createTelegramInboundBusProjectionRuntime(deps) {
319
429
  const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
320
430
  "allmenu:",
321
431
  TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX,
432
+ TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX,
322
433
  "compact:",
323
434
  "menu:",
324
435
  "model:",
@@ -335,8 +446,10 @@ function isTelegramOwnedCallbackData(data) {
335
446
  }
336
447
  export function createTelegramInboundRouteRuntime(deps) {
337
448
  const pendingUnboundReroutes = new Map();
449
+ const implicitThreadCreations = new Map();
338
450
  const guidedUnboundTopicKeys = new Set();
339
451
  let nextUnboundRerouteId = 0;
452
+ let cancellationReview;
340
453
  const requestDispatchNextQueuedTelegramTurn = (ctx) => {
341
454
  deps.dispatchNextQueuedTelegramTurn(ctx);
342
455
  if (deps.requestDeferredDispatchNextQueuedTelegramTurn &&
@@ -368,7 +481,7 @@ export function createTelegramInboundRouteRuntime(deps) {
368
481
  continue;
369
482
  }
370
483
  return record.threadName &&
371
- Threads.isTelegramTopicThreadNameValidForSlot(record.threadName, record.slot)
484
+ ThreadNaming.isTelegramTopicThreadNameValidForSlot(record.threadName, record.slot)
372
485
  ? record.threadName
373
486
  : getRestoredThreadName(record, record.slot ?? "");
374
487
  }
@@ -449,33 +562,30 @@ export function createTelegramInboundRouteRuntime(deps) {
449
562
  };
450
563
  schedule();
451
564
  };
452
- const prunePendingUnboundReroutes = () => {
453
- const nowMs = Date.now();
565
+ const prunePendingCommandReroutes = () => {
566
+ // Deferred prompts and selected routes with cleanup work must retain their control.
567
+ // Age is not settlement; the chooser capacity limit still bounds retained entries.
454
568
  for (const [id, entry] of pendingUnboundReroutes) {
455
569
  if (entry.dispatchKind === "command" && entry.sourceTarget.threadId === undefined) {
456
570
  expirePendingCommand(id, entry);
457
571
  }
458
- else if (nowMs - entry.createdAtMs > 30 * 60_000) {
459
- removePendingReroute(id);
460
- }
461
572
  }
462
573
  };
463
- const storePendingUnboundReroute = (messages, dispatchKind = "prompt") => {
464
- prunePendingUnboundReroutes();
574
+ const storePendingUnboundReroute = (messages, dispatchKind = "prompt", presentationTarget) => {
575
+ prunePendingCommandReroutes();
465
576
  if (pendingUnboundReroutes.size >= 100) {
466
577
  throw new Error("Telegram route chooser capacity reached; source remains retryable.");
467
578
  }
468
579
  nextUnboundRerouteId += 1;
469
580
  const id = nextUnboundRerouteId.toString(36);
470
581
  pendingUnboundReroutes.set(id, {
471
- sourceTarget: {
582
+ sourceTarget: presentationTarget ? { ...presentationTarget } : {
472
583
  chatId: messages[0].chat.id,
473
584
  ...(typeof messages[0].message_thread_id === "number"
474
585
  ? { threadId: messages[0].message_thread_id }
475
586
  : {}),
476
587
  },
477
588
  messages,
478
- createdAtMs: Date.now(),
479
589
  dispatchKind,
480
590
  });
481
591
  const pending = pendingUnboundReroutes.get(id);
@@ -487,6 +597,19 @@ export function createTelegramInboundRouteRuntime(deps) {
487
597
  if (!pending)
488
598
  return;
489
599
  pending.chooserMessageId = messageId;
600
+ if (Number.isSafeInteger(messageId) && messageId > 0) {
601
+ const owner = deps.configStore.getAllowedUserId();
602
+ if (owner !== undefined && pending.messages.every(source => source.from?.id === owner && !source.from.is_bot && source.chat.type === "private")) {
603
+ try {
604
+ const lifetime = Updates.armTelegramRoutingInputs(pending.messages, owner);
605
+ if (lifetime)
606
+ pending.routingOperatorUserId = lifetime.operatorUserId;
607
+ }
608
+ catch (error) {
609
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-clock" });
610
+ }
611
+ }
612
+ }
490
613
  if (messageId === undefined || pending.dispatchKind !== "command" ||
491
614
  pending.sourceTarget.threadId !== undefined || pending.selectionAttempted)
492
615
  return;
@@ -587,15 +710,48 @@ export function createTelegramInboundRouteRuntime(deps) {
587
710
  reply_to_message: undefined,
588
711
  }));
589
712
  };
590
- const applyThreadCleanupPlan = async (plan, assertExecutionCurrent) => {
713
+ /** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
714
+ const hasPendingRerouteForTarget = (target, own) => [...pendingUnboundReroutes.values()].some(pending => pending.sourceTarget.chatId === target.chatId &&
715
+ pending.sourceTarget.threadId === target.threadId && !own?.(pending));
716
+ const isOwnTemporaryChooser = (entry) => (pending) => pending.temporaryThread?.token === entry.token && isDeepStrictEqual(pending.temporaryThread.source, entry.source);
717
+ const hasOtherTemporaryThreadReroute = (target, own) => hasPendingRerouteForTarget(target, isOwnTemporaryChooser(own));
718
+ const applyThreadCleanupPlan = async (plan, assertExecutionCurrent, restoreCleanup, temporaryCleanup) => {
591
719
  assertExecutionCurrent?.();
720
+ if ((restoreCleanup && restoreCleanup.routing?.cleanup !== "issued") ||
721
+ (temporaryCleanup && !temporaryCleanup.cleanupIssued))
722
+ return false;
723
+ let protectedTarget = false, temporaryDeleteConfirmed = false, temporaryTransportUncertain = false;
592
724
  deps.recordThreadReconciliationPlan?.(plan);
593
725
  const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
726
+ skipCloseBeforeDelete: (temporaryCleanup?.target?.chatId ?? 0) > 0,
594
727
  isCleanupTargetProtected(target) {
595
728
  assertExecutionCurrent?.();
596
- return isRerouteTargetProtected(target);
729
+ // A tab's first source is not authority over sibling choosers, including unsettled cleanup-only work.
730
+ const siblingProtected = !!temporaryCleanup && hasOtherTemporaryThreadReroute(target, temporaryCleanup);
731
+ const lostTemporaryGrant = !!temporaryCleanup && !deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads()
732
+ .some(entry => isDeepStrictEqual(entry, temporaryCleanup));
733
+ const protectedNow = temporaryTransportUncertain || lostTemporaryGrant || siblingProtected || isRerouteTargetProtected(target, restoreCleanup, temporaryCleanup);
734
+ protectedTarget ||= protectedNow;
735
+ return protectedNow;
597
736
  },
598
- callApi: deps.callApi,
737
+ callApi: temporaryCleanup && deps.callApi
738
+ ? async (method, body) => {
739
+ try {
740
+ assertExecutionCurrent?.();
741
+ const result = await deps.callApi(method, body, { maxAttempts: 1, retrySafety: "non-idempotent" });
742
+ assertExecutionCurrent?.();
743
+ if (result !== true)
744
+ throw new Error("Temporary Thread cleanup lacks a positive API acknowledgement.");
745
+ if (method === "deleteForumTopic")
746
+ temporaryDeleteConfirmed = true;
747
+ return result;
748
+ }
749
+ catch (error) {
750
+ temporaryTransportUncertain = true;
751
+ throw error;
752
+ }
753
+ }
754
+ : deps.callApi,
599
755
  markStaleByTarget: (staleTarget, syncStatus, lastSyncError) => deps.threadStore?.markStaleByTarget(staleTarget, syncStatus, lastSyncError) ?? false,
600
756
  persist: () => deps.threadStore?.persist() ?? Promise.resolve(),
601
757
  removePendingProvisionById: (id) => deps.threadStore?.removePendingProvision(id) ?? false,
@@ -603,14 +759,132 @@ export function createTelegramInboundRouteRuntime(deps) {
603
759
  recordRuntimeEvent: deps.recordRuntimeEvent,
604
760
  });
605
761
  assertExecutionCurrent?.();
606
- return (result.incompleteActions?.length ?? 0) === 0;
762
+ return !protectedTarget && (!temporaryCleanup || temporaryDeleteConfirmed) && (result.incompleteActions?.length ?? 0) === 0;
763
+ };
764
+ const findCreatedTemporaryThread = (target) => {
765
+ try {
766
+ return deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
767
+ }
768
+ catch {
769
+ return undefined;
770
+ }
771
+ };
772
+ /**
773
+ * One fenced capture of leader epoch, operator, journal binding and context for a temporary-Thread effect. `isCurrent`
774
+ * re-reads every captured fact (plus an optional extra fence) at each boundary, so a delayed effect keeps the identity it
775
+ * was scheduled under; `adopt` re-keys an entry to this executor before a write.
776
+ */
777
+ const captureTemporaryThreadAuthority = (ctx, extra) => {
778
+ const store = deps.getWorkspaceRestoreStore?.();
779
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
780
+ const operatorUserId = deps.configStore.getAllowedUserId(), journalBindingKey = deps.getAdmissionJournalBinding?.();
781
+ const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.();
782
+ if (!store || !instanceId || epoch === undefined || operatorUserId === undefined || !journalBindingKey)
783
+ return undefined;
784
+ const isCurrent = () => deps.isContextActive?.(ctx) === true && deps.getCurrentInstanceId?.() === instanceId &&
785
+ deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
786
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
787
+ deps.getAdmissionJournalBinding?.() === journalBindingKey && (extra?.() ?? true);
788
+ const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent };
789
+ return { store, operatorUserId, journalBindingKey, epoch, authority, isCurrent,
790
+ adopt: entry => isDeepStrictEqual(entry.executor, authority.executor) ? entry : store.adoptTemporaryThread(entry, authority) };
791
+ };
792
+ const isTemporaryTabTarget = (target) => !!target && findCreatedTemporaryThread(target) !== undefined;
793
+ /** Restore may choose one input only when every other known group is cancelled or a live unassigned chooser that can be retained. */
794
+ const areTemporaryThreadSiblingsAccountedForRestore = (entry, from) => {
795
+ const target = entry.target;
796
+ if (!target)
797
+ return false;
798
+ const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(from.messages) };
799
+ const inputs = Threads.getTelegramTemporaryThreadInputs(entry), resolved = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
800
+ if (!inputs.some(input => isDeepStrictEqual(input, group)) || resolved.some(input => isDeepStrictEqual(input, group)))
801
+ return false;
802
+ const others = [...pendingUnboundReroutes.values()].filter(pending => pending !== from &&
803
+ pending.sourceTarget.chatId === target.chatId && pending.sourceTarget.threadId === target.threadId);
804
+ const groupOf = (pending) => ({ journalBindingKey: entry.source.journalBindingKey,
805
+ updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) });
806
+ if (!others.every(pending => !!pending.abandonment && !pending.abandonment.result && !pending.destinationSelected &&
807
+ !pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage &&
808
+ !pending.workspaceRestore && !entry.forwardedInputs?.some(input => isDeepStrictEqual(input, groupOf(pending))) &&
809
+ inputs.some(input => isDeepStrictEqual(input, groupOf(pending)))))
810
+ return false;
811
+ return inputs.every(input => isDeepStrictEqual(input, group) || resolved.some(other => isDeepStrictEqual(other, input)) ||
812
+ others.some(pending => isDeepStrictEqual(groupOf(pending), input)));
607
813
  };
608
- const isRerouteTargetProtected = (target) => {
814
+ const isRerouteTargetProtected = (target, ownRestore, ownTemporary, restoreFrom) => {
609
815
  const matches = (candidate) => candidate.chatId === target.chatId && candidate.threadId === target.threadId;
610
- return (deps.getLiveThreadTargets?.() ?? []).some(matches) ||
611
- (deps.threadStore?.list() ?? []).some((record) => matches(record.target)) ||
612
- (deps.threadStore?.listReservations() ?? []).some((record) => matches(record.target)) ||
613
- (deps.threadStore?.listPendingProvisions() ?? []).some((record) => !!record.target && matches(record.target));
816
+ const activeTarget = deps.activeTurnRuntime.getTarget();
817
+ if ((activeTarget && matches(activeTarget)) ||
818
+ deps.telegramQueueStore.getQueuedItems().some(item => item.target && matches(item.target)) ||
819
+ (deps.getLiveThreadTargets?.() ?? []).some(matches) ||
820
+ (deps.threadStore?.list() ?? []).some(record => matches(record.target)) ||
821
+ (deps.threadStore?.listReservations() ?? []).some(record => matches(record.target)) ||
822
+ (deps.threadStore?.listPendingProvisions() ?? []).some(record => record.target && matches(record.target)))
823
+ return true;
824
+ const store = deps.getWorkspaceRestoreStore?.();
825
+ if (deps.getWorkspaceRestoreStore && !store)
826
+ return true;
827
+ let temporary;
828
+ try {
829
+ temporary = store?.listTemporaryThreads() ?? [];
830
+ }
831
+ catch {
832
+ return true;
833
+ }
834
+ // Only the source's own Forward/Cancel may remove its retained tab; adoption may have changed its executor.
835
+ const ownTab = (entry) => !!ownTemporary && entry.token === ownTemporary.token &&
836
+ isDeepStrictEqual(entry.source, ownTemporary.source) && isDeepStrictEqual(entry.target, ownTemporary.target);
837
+ if (temporary.some(entry => !!entry.target && matches(entry.target) &&
838
+ (!ownTab(entry) || (Threads.getTelegramTemporaryThreadInputs(entry).length > 1 &&
839
+ !Threads.isTelegramTemporaryThreadFullyResolved(entry) &&
840
+ !(restoreFrom && areTemporaryThreadSiblingsAccountedForRestore(entry, restoreFrom))))))
841
+ return true;
842
+ // Whole-tab cleanup after cancellation also needs a fresh census of retained arrivals no membership has seen.
843
+ if (!restoreFrom && temporary.some(entry => ownTab(entry) && !!(entry.cancelledInputs?.length || entry.completedInputs?.length))) {
844
+ try {
845
+ const required = [...new Set([deps.getAdmissionJournalBinding?.(), ownTemporary.source.journalBindingKey,
846
+ ...Threads.getTelegramTemporaryThreadInputs(ownTemporary).map(input => input.journalBindingKey)].filter((key) => !!key))];
847
+ if (deps.inspectTemporaryThreadSources?.({ chatId: target.chatId, threadId: target.threadId }, required)?.length !== 0)
848
+ return true;
849
+ }
850
+ catch {
851
+ return true;
852
+ }
853
+ }
854
+ const retained = store?.list() ?? [];
855
+ // Exempt only the exact operation's old target. Cleanup itself also requires its issued grant.
856
+ if (ownRestore && (!matches(ownRestore.request.binding.target) ||
857
+ !retained.some(intent => isDeepStrictEqual(intent, ownRestore))))
858
+ return true;
859
+ if (retained.some(intent => (matches(intent.request.binding.target) || matches(intent.request.target)) &&
860
+ !(ownRestore && isDeepStrictEqual(intent, ownRestore) && matches(intent.request.binding.target))))
861
+ return true;
862
+ if (!ownRestore)
863
+ return false;
864
+ let acceptedWorkClear = false;
865
+ try {
866
+ deps.threadStore?.withWorkspaceRestoreSnapshot(ownRestore, snapshot => {
867
+ const bindings = snapshot.workspaceBindings?.filter(binding => binding.bindingKey === ownRestore.request.binding.bindingKey) ?? [];
868
+ if (bindings.length !== 1 || !deps.captureWorkspaceExternalProtection)
869
+ return undefined;
870
+ const binding = bindings[0];
871
+ // Relocation does not erase predecessor references or prove where their work executes.
872
+ const journalBindingKeys = [...new Set([
873
+ ...(ownRestore.request.binding.journalBindingKeys ?? []), ...(binding.journalBindingKeys ?? []),
874
+ ])];
875
+ const journalSources = [...new Map([
876
+ ...(ownRestore.request.binding.journalSources ?? []), ...(binding.journalSources ?? []),
877
+ ].map(source => [JSON.stringify([source.sessionId, source.recipientBindingKey]), source])).values()];
878
+ acceptedWorkClear = deps.captureWorkspaceExternalProtection({ ...binding, target: ownRestore.request.binding.target,
879
+ journalBindingKeys, journalSources }, { requireBindingProvenance: true }).acceptedWork === "clear";
880
+ return undefined;
881
+ });
882
+ }
883
+ catch (error) {
884
+ acceptedWorkClear = false;
885
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-protection" });
886
+ }
887
+ return !acceptedWorkClear;
614
888
  };
615
889
  const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
616
890
  const chatId = query.message?.chat?.id;
@@ -637,7 +911,7 @@ export function createTelegramInboundRouteRuntime(deps) {
637
911
  return false;
638
912
  }
639
913
  };
640
- const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent) => {
914
+ const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent, ownTemporary) => {
641
915
  if (!target || !deps.threadStore)
642
916
  return true;
643
917
  const nowMs = Date.now();
@@ -660,9 +934,11 @@ export function createTelegramInboundRouteRuntime(deps) {
660
934
  },
661
935
  ],
662
936
  });
663
- return applyThreadCleanupPlan(plan, assertExecutionCurrent);
937
+ // A temporary grant licenses this tab only, not unrelated cleanup emitted by the general planner.
938
+ const scoped = ownTemporary ? { ...plan, actions: plan.actions.filter(action => action.kind === "close-delete-unbound-topic" && isDeepStrictEqual(action.target, target)) } : plan;
939
+ return applyThreadCleanupPlan(scoped, assertExecutionCurrent, undefined, ownTemporary);
664
940
  };
665
- const closePreviousLeaderThread = async (target, assertExecutionCurrent) => {
941
+ const closePreviousLeaderThread = async (target, assertExecutionCurrent, restoreCleanup) => {
666
942
  if (!target || !deps.threadStore)
667
943
  return true;
668
944
  const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
@@ -678,9 +954,9 @@ export function createTelegramInboundRouteRuntime(deps) {
678
954
  : {}),
679
955
  },
680
956
  ],
681
- }, assertExecutionCurrent);
957
+ }, assertExecutionCurrent, restoreCleanup);
682
958
  };
683
- const closeReplacedFollowerThread = async (target, instanceId, assertExecutionCurrent) => {
959
+ const closeReplacedFollowerThread = async (target, instanceId, assertExecutionCurrent, restoreCleanup) => {
684
960
  if (!target || !deps.threadStore)
685
961
  return true;
686
962
  const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
@@ -696,11 +972,807 @@ export function createTelegramInboundRouteRuntime(deps) {
696
972
  : {}),
697
973
  },
698
974
  ],
699
- }, assertExecutionCurrent);
975
+ }, assertExecutionCurrent, restoreCleanup);
976
+ };
977
+ // Worker ACK observers outlive callback admission. Re-enter the existing Workspace gate;
978
+ // never await this continuation while holding the dispatch invocation's gate.
979
+ const restoreSettlementTasks = new Set();
980
+ /**
981
+ * After a Restore into a temporary tab reached positive terminal settlement, every still-unassigned sibling there is retained
982
+ * privately and cancelled without Pi delivery. Missing proof, authority or abandonment leaves that sibling pending and the
983
+ * tab's temporary entry protected; selected, queued, running or unknown work is never touched.
984
+ */
985
+ const disposeTemporaryThreadSiblingsAfterRestore = async (intent, ctx, restoreCurrent) => {
986
+ const target = intent.request.target, cap = captureTemporaryThreadAuthority(ctx, restoreCurrent);
987
+ const entry = findCreatedTemporaryThread(target);
988
+ if (!cap || !entry || !entry.target || !cap.isCurrent())
989
+ return;
990
+ const operatorUserId = intent.operatorUserId, journalBindingKey = intent.request.source.journalBindingKey;
991
+ if (cap.operatorUserId !== operatorUserId || cap.journalBindingKey !== journalBindingKey ||
992
+ entry.operatorUserId !== operatorUserId || entry.source.journalBindingKey !== journalBindingKey)
993
+ return;
994
+ const group = { journalBindingKey, updateIds: [...intent.request.source.updateIds] };
995
+ if (!Threads.getTelegramTemporaryThreadInputs(entry).some(input => isDeepStrictEqual(input, group)))
996
+ return;
997
+ for (const [id, pending] of [...pendingUnboundReroutes]) {
998
+ const cancellation = pending.abandonment, source = pending.messages[0];
999
+ if (pending.sourceTarget.chatId !== target.chatId || pending.sourceTarget.threadId !== target.threadId ||
1000
+ pending.workspaceRestore?.operationId === intent.request.operationId || !cancellation || !source || cancellation.result ||
1001
+ cancellation.running)
1002
+ continue;
1003
+ const current = () => cap.isCurrent() &&
1004
+ pendingUnboundReroutes.get(id) === pending && pending.abandonment === cancellation &&
1005
+ !pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry &&
1006
+ !pending.cleanup && !pending.finalizeMessage && !pending.workspaceRestore &&
1007
+ Updates.getTelegramUpdateExecutionFence(source)?.signal.aborted === false &&
1008
+ cancellation.ownerUserId === operatorUserId && cancellation.journalBindingKey === journalBindingKey;
1009
+ if (!current())
1010
+ continue;
1011
+ cancellation.running = true;
1012
+ try {
1013
+ const result = Updates.abandonTelegramDeferredUpdate(source, { operatorAuthorityId: `telegram-owner:${operatorUserId}`, isCurrent: current });
1014
+ if (!result)
1015
+ continue;
1016
+ cancellation.attempted = true;
1017
+ cancellation.result = result;
1018
+ if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
1019
+ continue;
1020
+ await retireCancellationChooser(id, pending, current);
1021
+ }
1022
+ catch (error) {
1023
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-restore-sibling", rerouteId: id });
1024
+ }
1025
+ finally {
1026
+ cancellation.running = false;
1027
+ }
1028
+ }
1029
+ // The entry is released only after every sibling is gone and the Restore group's completion is the single uncancelled input.
1030
+ if (hasPendingRerouteForTarget(target, pending => pending.workspaceRestore?.operationId === intent.request.operationId) ||
1031
+ !cap.isCurrent())
1032
+ return;
1033
+ const fresh = findCreatedTemporaryThread(target), owned = fresh && cap.adopt(fresh);
1034
+ if (owned && cap.isCurrent())
1035
+ cap.store.retireTemporaryThread(owned, cap.authority, group);
1036
+ };
1037
+ const observeRestoreSettlement = (signal, ctx) => {
1038
+ if (!deps.hasWorkspaceRestoreAuthority?.() || !deps.runWorkspaceOperation || !deps.getWorkspaceRestoreStore ||
1039
+ !deps.getSessionGeneration || deps.isContextActive?.(ctx) === false)
1040
+ return;
1041
+ const evidence = signal.kind === "source" ? structuredClone(signal.evidence) : undefined;
1042
+ const follower = signal.kind === "recipient" ? structuredClone(signal.follower) : undefined;
1043
+ const recipientCurrent = () => {
1044
+ if (signal.kind !== "recipient" || !follower)
1045
+ return true;
1046
+ const live = deps.workspaceRestoreRecipient?.followerRegistry.get(follower.instanceId);
1047
+ return signal.isCurrent() && !!live && Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
1048
+ isDeepStrictEqual({ ...live, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }, { ...follower, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined });
1049
+ };
1050
+ const recipientMatches = (intent) => {
1051
+ if (!follower)
1052
+ return true;
1053
+ const ready = intent.readyRecipient ?? intent.recipient;
1054
+ return ready?.kind === "follower" && ready.instanceId === follower.instanceId && ready.sessionId === follower.sessionId &&
1055
+ ready.generation === follower.registrationGeneration && !!follower.cwd &&
1056
+ intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
1057
+ intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
1058
+ };
1059
+ // A same-session successor already registered on the relocated target may prove an issued grant by inspection only.
1060
+ const inspectableSuccessor = (intent) => !!follower?.registrationGeneration && !!follower.cwd && (intent.phase === "relocated" || intent.phase === "recipient-issued") &&
1061
+ intent.request.owner.owner?.kind === "manual-follower" && follower.sessionId === intent.request.binding.sessionId &&
1062
+ intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
1063
+ intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
1064
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
1065
+ const operatorUserId = deps.configStore.getAllowedUserId();
1066
+ const generation = deps.getSessionGeneration(), scope = deps.getAdmissionScope?.();
1067
+ const sourceBinding = deps.getAdmissionJournalBinding?.();
1068
+ if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope ||
1069
+ !sourceBinding || (evidence && evidence.journalBindingKey !== sourceBinding) || !recipientCurrent())
1070
+ return;
1071
+ // This same-session leader already runs on the relocated target; it may only inspect an issued leader grant.
1072
+ const leaderSessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
1073
+ const leaderCwd = deps.workspaceRestoreRecipient?.getCwd(ctx);
1074
+ const leaderAt = (request) => {
1075
+ const ports = deps.workspaceRestoreRecipient;
1076
+ const cwd = ports?.getCwd(ctx), local = ports?.getLeaderIdentity();
1077
+ return !!cwd && cwd === leaderCwd && ports?.getSessionId(ctx) === leaderSessionId &&
1078
+ leaderSessionId === request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd &&
1079
+ !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target);
1080
+ };
1081
+ const observeLeaderRestore = (intent, recipient, isCurrent) => {
1082
+ const threads = deps.threadStore;
1083
+ if (!threads || !isCurrent() || !leaderAt(intent.request))
1084
+ return undefined;
1085
+ const { request } = intent;
1086
+ let observation;
1087
+ threads.withWorkspaceRestoreSnapshot(intent, snapshot => {
1088
+ if (!isCurrent() || !leaderAt(request))
1089
+ return;
1090
+ const bindings = (snapshot.workspaceBindings ?? []).filter(value => value.bindingKey === request.binding.bindingKey);
1091
+ const live = bindings[0];
1092
+ const owners = snapshot.threads.filter(value => value.status === "active" &&
1093
+ (value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
1094
+ if (bindings.length !== 1 || live?.sessionId !== request.binding.sessionId || live.cwd !== request.binding.cwd ||
1095
+ live.slot !== request.binding.slot || live.inactiveSinceMs !== undefined || !isDeepStrictEqual(live.target, request.target) ||
1096
+ owners.length !== 1 || owners[0]?.owner?.kind !== "leader" || owners[0].instanceId !== recipient.instanceId ||
1097
+ owners[0].owner.instanceId !== recipient.instanceId || !owners[0].owner.cwd ||
1098
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) !== request.binding.cwd ||
1099
+ owners[0].profileKey !== request.owner.profileKey || owners[0].slot !== request.binding.slot || !isDeepStrictEqual(owners[0].target, request.target))
1100
+ return;
1101
+ observation = { operationId: request.operationId, recipient, target: request.target, slot: request.binding.slot, ready: true };
1102
+ });
1103
+ return isCurrent() && leaderAt(request) ? observation : undefined;
1104
+ };
1105
+ // Same-process lost replies stay with their chooser; this path only serves a restarted leader process.
1106
+ const inspectableLeader = (intent) => !!evidence &&
1107
+ intent.request.owner.owner?.kind === "leader" && leaderAt(intent.request) &&
1108
+ (intent.phase === "relocated" ? intent.request.owner.instanceId !== instanceId
1109
+ : intent.phase === "recipient-issued" && intent.recipient?.kind === "leader" && intent.recipient.instanceId !== instanceId);
1110
+ const current = () => deps.hasWorkspaceRestoreAuthority?.() === true && deps.isContextActive?.(ctx) !== false &&
1111
+ deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
1112
+ deps.getAdmissionJournalBinding?.() === sourceBinding && deps.getCurrentInstanceId?.() === instanceId &&
1113
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId && recipientCurrent();
1114
+ const recoveryRecipient = (intent) => {
1115
+ const ready = intent.readyRecipient ?? intent.recipient;
1116
+ if (!deps.inspectRestoreSourceCompletion || !deps.workspaceRestoreRecipient || intent.phase !== "ready" ||
1117
+ !intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
1118
+ !intent.routing.acceptances?.some(value => value.kind === "forwarded"))
1119
+ return undefined;
1120
+ const live = deps.workspaceRestoreRecipient.followerRegistry.get(follower?.instanceId ?? ready?.instanceId ?? "");
1121
+ return ready?.kind === "follower" && live?.registrationGeneration && live.cwd &&
1122
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
1123
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION) &&
1124
+ live.sessionId === intent.request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
1125
+ live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) ? live : undefined;
1126
+ };
1127
+ const recoveryLeader = (intent) => {
1128
+ const ready = intent.readyRecipient ?? intent.recipient;
1129
+ if (evidence?.kind !== "completed" || !deps.inspectRestoreSourceCompletion || !deps.threadStore ||
1130
+ intent.phase !== "ready" || !intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
1131
+ intent.request.owner.owner?.kind !== "leader" || ready?.kind !== "leader" || ready.sessionId !== intent.request.binding.sessionId ||
1132
+ !leaderAt(intent.request) ||
1133
+ !intent.routing.acceptances?.some(value => value.kind === "completed" || value.kind === "queued"))
1134
+ return undefined;
1135
+ return { kind: "leader", instanceId, sessionId: intent.request.binding.sessionId, generation: String(generation) };
1136
+ };
1137
+ let selected;
1138
+ try {
1139
+ selected = deps.getWorkspaceRestoreStore()?.list().filter(intent => {
1140
+ if (intent.request.source.journalBindingKey !== sourceBinding)
1141
+ return false;
1142
+ if (evidence && intent.request.source.updateIds.some(id => evidence.updateIds.includes(id)))
1143
+ return true;
1144
+ if (((evidence?.kind === "completed" || follower) && recoveryRecipient(intent)) || recoveryLeader(intent))
1145
+ return true;
1146
+ if (follower && deps.workspaceRestoreRecipient && inspectableSuccessor(intent))
1147
+ return true;
1148
+ if (deps.threadStore && inspectableLeader(intent))
1149
+ return true;
1150
+ // Completion rechecks an unsent Restore whose originals may all have been abandoned before its retirement published.
1151
+ if (evidence?.kind === "completed" && deps.inspectRestoreSourceAbandonment && intent.phase === "ready" &&
1152
+ intent.routing === undefined)
1153
+ return true;
1154
+ // Hints only wake fully settled, unissued cleanup. They never settle another source.
1155
+ return (evidence?.kind === "completed" || !!follower) && intent.phase === "ready" && intent.routing?.cleanup === undefined &&
1156
+ recipientMatches(intent) && intent.request.source.updateIds.every(id => intent.routing?.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id)));
1157
+ }).map(intent => intent.request.operationId) ?? [];
1158
+ }
1159
+ catch (error) {
1160
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" });
1161
+ return;
1162
+ }
1163
+ if (!selected.length || !current())
1164
+ return;
1165
+ const task = Promise.resolve().then(() => deps.runWorkspaceOperation({ operationId: `restore-settlement-${randomBytes(16).toString("hex")}`,
1166
+ operationKind: "workspace.restore-settlement", scopes: [{ kind: "profile" }] }, async () => {
1167
+ if (!current())
1168
+ return;
1169
+ const store = deps.getWorkspaceRestoreStore();
1170
+ if (!store)
1171
+ return;
1172
+ const authority = {
1173
+ executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current
1174
+ };
1175
+ const assertCurrent = () => { if (!current())
1176
+ throw new Error("Workspace Restore settlement authority ended."); };
1177
+ // Exact owner abandonment of every original is terminal for an unsent Restore; no cleanup or delivery follows.
1178
+ const abandoned = (value) => {
1179
+ if (evidence?.kind !== "completed" || !value.request.source.updateIds.length)
1180
+ return false;
1181
+ return value.request.source.updateIds.every(updateId => {
1182
+ const proof = deps.inspectRestoreSourceAbandonment?.(updateId, sourceBinding);
1183
+ assertCurrent();
1184
+ return proof?.journalBindingKey === sourceBinding && proof.updateId === updateId &&
1185
+ proof.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
1186
+ });
1187
+ };
1188
+ for (const operationId of selected) {
1189
+ assertCurrent();
1190
+ let intent = store.list().find(value => value.request.operationId === operationId);
1191
+ let settlementCurrent = current;
1192
+ let settlementCanonicalCurrent;
1193
+ let retired = false;
1194
+ if (follower && intent && inspectableSuccessor(intent) && intent.operatorUserId === operatorUserId) {
1195
+ const ports = deps.workspaceRestoreRecipient;
1196
+ const successor = { kind: "follower", instanceId: follower.instanceId,
1197
+ sessionId: follower.sessionId, generation: follower.registrationGeneration };
1198
+ const { request } = intent;
1199
+ // Adoption plus inspect never consumes the original apply grant, delivers input or issues cleanup.
1200
+ await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
1201
+ getRecipient: () => current() ? successor : undefined,
1202
+ async runRecipient(action) {
1203
+ if (action.mode !== "inspect" || !action.isCurrent())
1204
+ return undefined;
1205
+ return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
1206
+ sessionId: successor.sessionId, slot: request.binding.slot, target: request.target,
1207
+ oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
1208
+ } });
1209
+ continue;
1210
+ }
1211
+ if (intent && inspectableLeader(intent) && intent.operatorUserId === operatorUserId) {
1212
+ const threads = deps.threadStore;
1213
+ const leader = { kind: "leader", instanceId,
1214
+ sessionId: intent.request.binding.sessionId, generation: String(generation) };
1215
+ const { request } = intent;
1216
+ await threads.load();
1217
+ assertCurrent();
1218
+ await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
1219
+ getRecipient: () => current() && leaderAt(request) ? leader : undefined,
1220
+ async runRecipient(action) {
1221
+ if (action.mode !== "inspect")
1222
+ return undefined;
1223
+ return observeLeaderRestore(action.intent, leader, action.isCurrent);
1224
+ } });
1225
+ continue;
1226
+ }
1227
+ if (intent?.phase === "ready" && intent.routing === undefined && intent.operatorUserId === operatorUserId &&
1228
+ intent.request.source.journalBindingKey === sourceBinding && abandoned(intent)) {
1229
+ const owned = isDeepStrictEqual(intent.executor, authority.executor) ? intent : store.adopt(intent, authority);
1230
+ assertCurrent();
1231
+ if (owned)
1232
+ store.retireAbandoned(owned, owned.request.source.updateIds, authority);
1233
+ continue;
1234
+ }
1235
+ const liveRecipient = intent && recoveryRecipient(intent), localRecipient = intent && recoveryLeader(intent);
1236
+ if (intent && (liveRecipient || localRecipient) && intent.request.source.journalBindingKey === sourceBinding) {
1237
+ const expected = intent, ports = deps.workspaceRestoreRecipient;
1238
+ const recoveryCurrent = () => current() && (localRecipient ? leaderAt(expected.request) && isDeepStrictEqual(recoveryLeader(expected), localRecipient) : isDeepStrictEqual({ ...recoveryRecipient(expected), lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }, { ...liveRecipient, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }));
1239
+ // Storage authority callbacks check live fences only; canonical reads cannot re-enter their transaction lock.
1240
+ const recoveryAuthority = { ...authority, isCurrent: recoveryCurrent };
1241
+ settlementCurrent = recoveryCurrent;
1242
+ if (localRecipient) {
1243
+ settlementCanonicalCurrent = () => !!intent && !!observeLeaderRestore(intent, localRecipient, recoveryCurrent);
1244
+ if (!settlementCanonicalCurrent())
1245
+ continue; // A local target alone cannot authorize executor adoption.
1246
+ }
1247
+ const inspect = (acceptance, operation) => {
1248
+ const completion = { journalBindingKey: sourceBinding, updateId: acceptance.updateId,
1249
+ sourceSha256: acceptance.sourceSha256,
1250
+ completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(operation, acceptance) };
1251
+ const observed = deps.inspectRestoreSourceCompletion({ ...completion });
1252
+ if (!recoveryCurrent())
1253
+ throw new Error("Workspace Restore completion observation authority ended.");
1254
+ return isDeepStrictEqual(observed, completion);
1255
+ };
1256
+ // Inspect proof before adopting: missing/foreign ACKs cannot even re-key the retained operation.
1257
+ const accepted = expected.routing.acceptances.filter(value => (localRecipient ? value.kind === "completed" || value.kind === "queued" : value.kind === "forwarded") && inspect(value, expected));
1258
+ if (accepted.length) {
1259
+ const successor = localRecipient ?? { kind: "follower", instanceId: liveRecipient.instanceId,
1260
+ sessionId: liveRecipient.sessionId, generation: liveRecipient.registrationGeneration };
1261
+ const { request } = expected;
1262
+ const inspected = await advanceTelegramWorkspaceRestore({ request, authority: recoveryAuthority, restoreStore: store, inspectOnly: true,
1263
+ getRecipient: () => recoveryCurrent() ? successor : undefined,
1264
+ async runRecipient(action) {
1265
+ if (action.mode !== "inspect" || !action.isCurrent())
1266
+ return undefined;
1267
+ if (localRecipient)
1268
+ return observeLeaderRestore(action.intent, successor, action.isCurrent);
1269
+ return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
1270
+ sessionId: successor.sessionId, slot: request.binding.slot, target: request.target,
1271
+ oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
1272
+ } });
1273
+ if (!inspected || !recoveryCurrent())
1274
+ continue;
1275
+ intent = inspected;
1276
+ if (settlementCanonicalCurrent && !settlementCanonicalCurrent())
1277
+ continue;
1278
+ // The await cannot lend earlier readback; confirm each immutable proof again before publication.
1279
+ const settledIds = new Set(intent.routing.settlements.filter(value => value.kind !== "queued").flatMap(value => value.updateIds));
1280
+ const recovered = new Map();
1281
+ for (const acceptance of accepted.filter(value => !settledIds.has(value.updateId) && inspect(value, intent))) {
1282
+ const key = acceptance.kind === "queued" ? JSON.stringify([acceptance.receiptId, acceptance.queueKind]) : "completed";
1283
+ const settlement = recovered.get(key) ?? { journalBindingKey: sourceBinding, updateIds: [],
1284
+ ...(acceptance.kind === "queued" ? { kind: "queue-completed", receiptId: acceptance.receiptId, queueKind: acceptance.queueKind }
1285
+ : { kind: "completed" }) };
1286
+ settlement.updateIds.push(acceptance.updateId);
1287
+ recovered.set(key, settlement);
1288
+ }
1289
+ for (const settlement of recovered.values()) {
1290
+ intent = store.recordSourceSettlement(intent, settlement, recoveryAuthority);
1291
+ if (!intent)
1292
+ break;
1293
+ }
1294
+ if (!intent)
1295
+ continue;
1296
+ }
1297
+ }
1298
+ // Source hints carry no registry fence; cleanup must keep the acknowledged recipient exact across awaits.
1299
+ const readyRecipientCurrent = () => {
1300
+ const ready = intent?.readyRecipient ?? intent?.recipient;
1301
+ if (intent?.phase !== "ready" || ready?.kind !== "follower")
1302
+ return true;
1303
+ const live = deps.workspaceRestoreRecipient?.followerRegistry.get(ready.instanceId);
1304
+ return !!live?.cwd && live.registrationGeneration === ready.generation && live.sessionId === ready.sessionId &&
1305
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
1306
+ live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) &&
1307
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
1308
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION);
1309
+ };
1310
+ const settlementAuthority = { ...authority, isCurrent: function () { return settlementCurrent() && readyRecipientCurrent(); } };
1311
+ const assertSettlementCurrent = () => {
1312
+ if (!settlementAuthority.isCurrent() || (!retired && settlementCanonicalCurrent && !settlementCanonicalCurrent())) {
1313
+ throw new Error("Workspace Restore settlement authority ended.");
1314
+ }
1315
+ };
1316
+ assertSettlementCurrent();
1317
+ if (!intent || intent.phase !== "ready" || !intent.routing || !isDeepStrictEqual(intent.executor, authority.executor) ||
1318
+ intent.operatorUserId !== operatorUserId || intent.request.source.journalBindingKey !== sourceBinding)
1319
+ continue;
1320
+ const settled = new Set(intent.routing.settlements.flatMap(value => value.updateIds));
1321
+ if (follower && (intent.routing.cleanup !== undefined || !recipientMatches(intent) ||
1322
+ !intent.request.source.updateIds.every(id => settled.has(id))))
1323
+ continue;
1324
+ const updateIds = evidence?.updateIds.filter(id => intent.request.source.updateIds.includes(id) && !settled.has(id)) ?? [];
1325
+ if (evidence && updateIds.length) {
1326
+ intent = store.recordSourceSettlement(intent, { ...evidence, updateIds }, settlementAuthority);
1327
+ if (!intent)
1328
+ continue;
1329
+ }
1330
+ if (intent.routing?.cleanup === undefined) {
1331
+ if (!intent.request.source.updateIds.every(id => intent.routing.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id))))
1332
+ continue;
1333
+ // Positive settlement of every selected source is the success proof; old-thread cleanup may stay protected indefinitely.
1334
+ await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
1335
+ assertSettlementCurrent();
1336
+ if (isRerouteTargetProtected(intent.request.binding.target, intent))
1337
+ continue;
1338
+ const grant = store.issueCleanup(intent, settlementAuthority);
1339
+ if (!grant)
1340
+ continue;
1341
+ intent = grant.intent;
1342
+ const oldTarget = intent.request.binding.target;
1343
+ const completed = intent.request.owner.owner?.kind === "leader"
1344
+ ? await closePreviousLeaderThread(oldTarget, assertSettlementCurrent, intent)
1345
+ : await closeReplacedFollowerThread(oldTarget, intent.request.owner.instanceId, assertSettlementCurrent, intent);
1346
+ assertSettlementCurrent();
1347
+ if (!completed)
1348
+ continue; // Issued uncertainty stays protected, never downgraded or retried.
1349
+ intent = store.recordCleanup(intent, { kind: "completed", target: oldTarget }, settlementAuthority);
1350
+ if (!intent)
1351
+ continue;
1352
+ }
1353
+ if (!store.retire(intent, settlementAuthority))
1354
+ continue;
1355
+ retired = true;
1356
+ for (const [id, pending] of pendingUnboundReroutes) {
1357
+ if (pending.workspaceRestore?.operationId !== operationId)
1358
+ continue;
1359
+ pending.finalizeMessage = "Message routed.";
1360
+ if (!deps.editInteractiveMessage || pending.chooserMessageId === undefined)
1361
+ continue;
1362
+ assertSettlementCurrent();
1363
+ await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
1364
+ assertSettlementCurrent();
1365
+ if (pendingUnboundReroutes.get(id) === pending)
1366
+ removePendingReroute(id);
1367
+ }
1368
+ }
1369
+ })).catch(error => { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" }); });
1370
+ restoreSettlementTasks.add(task);
1371
+ void task.finally(() => restoreSettlementTasks.delete(task));
1372
+ return task;
1373
+ };
1374
+ const beforeQueueReceiptPublished = async (receipt, queueOwner, ctx, isWorkerCurrent) => {
1375
+ const store = deps.getWorkspaceRestoreStore?.();
1376
+ if (!store)
1377
+ return;
1378
+ const selected = store.list().filter(intent => intent.request.source.journalBindingKey === receipt.journalBindingKey &&
1379
+ intent.request.source.updateIds.some(id => receipt.sourceUpdateIds.includes(id))).map(intent => intent.request.operationId);
1380
+ if (!selected.length)
1381
+ return;
1382
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
1383
+ const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.(), operatorUserId = deps.configStore.getAllowedUserId();
1384
+ const binding = receipt.journalBindingKey, ports = deps.workspaceRestoreRecipient, threads = deps.threadStore;
1385
+ if (!instanceId || epoch === undefined || generation === undefined || !scope || !binding || operatorUserId === undefined ||
1386
+ !ports || !threads || !deps.runWorkspaceOperation || !deps.inspectRestoreQueuedReceipt) {
1387
+ throw new Error("Workspace Restore queued acceptance authority is unavailable.");
1388
+ }
1389
+ const expectedReceipt = { queueKind: receipt.queueKind, receiptId: receipt.receiptId,
1390
+ sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...queueOwner } };
1391
+ const current = () => isWorkerCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
1392
+ deps.isContextActive?.(ctx) !== false && deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
1393
+ deps.getAdmissionJournalBinding?.() === binding && deps.getCurrentInstanceId?.() === instanceId &&
1394
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
1395
+ queueOwner.instanceId === instanceId && queueOwner.sessionGeneration === generation;
1396
+ const assertCurrent = () => { if (!current())
1397
+ throw new Error("Workspace Restore queued acceptance authority changed."); };
1398
+ assertCurrent();
1399
+ const recipientGuards = [];
1400
+ const sourceCompletions = [];
1401
+ await deps.runWorkspaceOperation({ operationId: `restore-queue-acceptance-${randomBytes(16).toString("hex")}`,
1402
+ operationKind: "workspace.restore-queue-acceptance", scopes: [{ kind: "profile" }] }, async () => {
1403
+ assertCurrent();
1404
+ const active = deps.getWorkspaceRestoreStore?.();
1405
+ if (!active)
1406
+ throw new Error("Workspace Restore queued acceptance storage disappeared.");
1407
+ const proof = deps.inspectRestoreQueuedReceipt({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
1408
+ assertCurrent();
1409
+ if (!proof || !isDeepStrictEqual(proof.receipt, expectedReceipt) || !/^[a-f0-9]{64}$/u.test(proof.queueOwnerSha256) ||
1410
+ proof.sources.length !== receipt.sourceUpdateIds.length || proof.sources.some((source, index) => source.updateId !== receipt.sourceUpdateIds[index] || !/^[a-f0-9]{64}$/u.test(source.sourceSha256))) {
1411
+ throw new Error("Workspace Restore queued receipt proof is unavailable.");
1412
+ }
1413
+ for (const operationId of selected) {
1414
+ let intent = active.list().find(value => value.request.operationId === operationId);
1415
+ const recipient = intent?.readyRecipient ?? intent?.recipient;
1416
+ if (!intent || intent.phase !== "ready" || !intent.routing || intent.operatorUserId !== operatorUserId ||
1417
+ !isDeepStrictEqual(intent.executor, { instanceId, leaderEpoch: String(epoch) }) ||
1418
+ intent.request.source.journalBindingKey !== binding || recipient?.kind !== "leader" || recipient.instanceId !== instanceId ||
1419
+ recipient.generation !== String(generation) || recipient.sessionId !== intent.request.binding.sessionId) {
1420
+ throw new Error("Workspace Restore queued recipient proof changed.");
1421
+ }
1422
+ const request = intent.request;
1423
+ const liveRecipientCurrent = () => {
1424
+ const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
1425
+ return current() && !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
1426
+ ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
1427
+ };
1428
+ const recipientCurrent = () => {
1429
+ if (!liveRecipientCurrent())
1430
+ return false;
1431
+ let committed = false;
1432
+ threads.withWorkspaceRestoreSnapshot(intent, snapshot => {
1433
+ const live = snapshot.workspaceBindings?.filter(value => value.bindingKey === request.binding.bindingKey) ?? [];
1434
+ const owners = snapshot.threads.filter(value => value.status === "active" &&
1435
+ (value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
1436
+ const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
1437
+ committed = live.length === 1 && live[0]?.sessionId === recipient.sessionId && live[0].cwd === request.binding.cwd &&
1438
+ live[0].slot === request.binding.slot && live[0].inactiveSinceMs === undefined && isDeepStrictEqual(live[0].target, request.target) &&
1439
+ owners.length === 1 && owners[0]?.owner?.kind === "leader" && owners[0].instanceId === instanceId &&
1440
+ owners[0].owner.instanceId === instanceId && !!owners[0].owner.cwd &&
1441
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) === request.binding.cwd && owners[0].profileKey === request.owner.profileKey &&
1442
+ owners[0].slot === request.binding.slot && isDeepStrictEqual(owners[0].target, request.target) &&
1443
+ !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
1444
+ ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
1445
+ });
1446
+ return committed && liveRecipientCurrent();
1447
+ };
1448
+ recipientGuards.push(recipientCurrent);
1449
+ // Storage owns the canonical CAS; its in-transaction authority callback cannot reacquire the snapshot lock.
1450
+ const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: liveRecipientCurrent };
1451
+ for (const source of proof.sources.filter(value => request.source.updateIds.includes(value.updateId))) {
1452
+ if (!recipientCurrent())
1453
+ throw new Error("Workspace Restore queued recipient authority changed.");
1454
+ const evidence = { ...source, journalBindingKey: binding, recipient,
1455
+ kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind, queueOwnerSha256: proof.queueOwnerSha256 };
1456
+ let accepted, failure;
1457
+ try {
1458
+ accepted = active.recordSourceAcceptance(intent, evidence, authority);
1459
+ }
1460
+ catch (error) {
1461
+ failure = error;
1462
+ }
1463
+ if (!accepted)
1464
+ accepted = active.list().find(value => value.request.operationId === operationId &&
1465
+ isDeepStrictEqual(value.request, request) && isDeepStrictEqual(value.executor, authority.executor) && value.operatorUserId === operatorUserId &&
1466
+ value.routing?.acceptances?.some(retained => isDeepStrictEqual(retained, evidence)));
1467
+ if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
1468
+ accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence))) {
1469
+ throw failure ?? new Error("Workspace Restore queued acceptance was not published.");
1470
+ }
1471
+ intent = accepted;
1472
+ sourceCompletions.push({ ...source, journalBindingKey: binding,
1473
+ completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) });
1474
+ if (!recipientCurrent())
1475
+ throw new Error("Workspace Restore queued recipient authority changed.");
1476
+ }
1477
+ }
1478
+ const observed = deps.inspectRestoreQueuedReceipt({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
1479
+ assertCurrent();
1480
+ if (!isDeepStrictEqual(observed, proof))
1481
+ throw new Error("Workspace Restore queued receipt changed after publication.");
1482
+ });
1483
+ assertCurrent();
1484
+ if (recipientGuards.some(guard => !guard()))
1485
+ throw new Error("Workspace Restore queued recipient changed after admission.");
1486
+ return sourceCompletions.sort((a, b) => a.updateId - b.updateId);
1487
+ };
1488
+ const onQueueReceiptCompleted = (receipt, ctx) => {
1489
+ // A hint re-enters admission and rereads scoped proof; it never supplies completed command evidence for a queued source.
1490
+ if (!receipt.journalBindingKey)
1491
+ return;
1492
+ observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
1493
+ updateIds: [...receipt.sourceUpdateIds], kind: "completed" } }, ctx);
1494
+ for (const updateId of receipt.sourceUpdateIds)
1495
+ retireCompletedTemporaryThread(updateId, ctx, receipt.journalBindingKey);
1496
+ };
1497
+ const onQueueReceiptCommitted = (receipt, ctx) => {
1498
+ if (receipt.journalBindingKey)
1499
+ observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
1500
+ updateIds: [...receipt.sourceUpdateIds], kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind } }, ctx);
1501
+ };
1502
+ // Worker-confirmed source completion is the only evidence for a Forwarded input. It records a durable group fact and
1503
+ // starts the same delayed, fully rechecked cleanup as the last Cancel; deletion itself never happens here.
1504
+ const completedTemporaryInputIds = new Map();
1505
+ const retireCompletedTemporaryThread = (updateId, ctx, journalBindingKey) => {
1506
+ const cap = captureTemporaryThreadAuthority(ctx);
1507
+ if (!cap || !deps.runWorkspaceOperation || cap.journalBindingKey !== journalBindingKey)
1508
+ return;
1509
+ const store = cap.store;
1510
+ const matches = (entry) => entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId;
1511
+ const inGroup = (entry) => Threads.getTelegramTemporaryThreadInputs(entry).some(input => input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
1512
+ let restoreOwned = false;
1513
+ try {
1514
+ if (!store.listTemporaryThreads().some(entry => matches(entry) || inGroup(entry)))
1515
+ return;
1516
+ restoreOwned = store.list().some(intent => intent.request.source.journalBindingKey === journalBindingKey &&
1517
+ intent.request.source.updateIds.includes(updateId));
1518
+ }
1519
+ catch (error) {
1520
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" });
1521
+ return;
1522
+ }
1523
+ const task = Promise.resolve().then(() => deps.runWorkspaceOperation({ operationId: `temporary-thread-retire-${randomBytes(16).toString("hex")}`,
1524
+ operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
1525
+ if (!cap.isCurrent())
1526
+ return;
1527
+ let entry = store.listTemporaryThreads().find(restoreOwned ? matches : inGroup);
1528
+ if (!entry)
1529
+ return;
1530
+ if (restoreOwned) {
1531
+ // A Restore keeps its own lifecycle: only its source entry may be released, never by Forward bookkeeping.
1532
+ if (entry.target && hasOtherTemporaryThreadReroute(entry.target, entry))
1533
+ return;
1534
+ entry = cap.adopt(entry);
1535
+ if (entry && cap.isCurrent())
1536
+ store.retireTemporaryThread(entry, cap.authority);
1537
+ return;
1538
+ }
1539
+ const group = Threads.getTelegramTemporaryThreadInputs(entry).find(input => input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
1540
+ const target = entry.target;
1541
+ if (!group || entry.phase !== "created" || !target)
1542
+ return;
1543
+ if (entry.cancelledInputs?.some(input => isDeepStrictEqual(input, group)) ||
1544
+ entry.completedInputs?.some(input => isDeepStrictEqual(input, group)))
1545
+ return;
1546
+ // Process-local accumulation only: a restart forgets partial groups and the tab stays protected.
1547
+ const seen = completedTemporaryInputIds.get(entry.token) ?? new Set();
1548
+ seen.add(updateId);
1549
+ completedTemporaryInputIds.set(entry.token, seen);
1550
+ if (!group.updateIds.every(id => seen.has(id)))
1551
+ return;
1552
+ entry = cap.adopt(entry);
1553
+ if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInputCompletion(entry, group, cap.authority) || !cap.isCurrent())
1554
+ return;
1555
+ scheduleTemporaryThreadCleanup(target, ctx);
1556
+ return true;
1557
+ })).then(published => {
1558
+ // A sibling may complete after Restore already settled. Reinspect only after its terminal fact publishes and admission releases.
1559
+ if (published && cap.isCurrent())
1560
+ observeRestoreSettlement({ kind: "source",
1561
+ evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
1562
+ }).catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" }); });
1563
+ restoreSettlementTasks.add(task);
1564
+ void task.finally(() => restoreSettlementTasks.delete(task));
1565
+ };
1566
+ const onUpdateCompleted = (updateId, ctx, journalBindingKey) => {
1567
+ if (!journalBindingKey)
1568
+ return;
1569
+ observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
1570
+ retireCompletedTemporaryThread(updateId, ctx, journalBindingKey);
700
1571
  };
1572
+ const restoreWorkspace = deps.workspaceRestoreRecipient ? async (input) => {
1573
+ const ports = deps.workspaceRestoreRecipient;
1574
+ const threads = deps.threadStore;
1575
+ if (!threads || !deps.getWorkspaceRestoreStore || !deps.getSessionGeneration ||
1576
+ !deps.hasWorkspaceRestoreAuthority?.() || !input.isCurrent() || input.target.threadId === undefined)
1577
+ return "protected";
1578
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
1579
+ const operatorUserId = deps.configStore.getAllowedUserId(), scope = deps.getAdmissionScope?.();
1580
+ const journalBindingKey = deps.getAdmissionJournalBinding?.(), generation = deps.getSessionGeneration();
1581
+ const sourceIds = Updates.collectTelegramAdmissionSourceUpdateIds(input.messages);
1582
+ if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope || !journalBindingKey || !sourceIds.length)
1583
+ return "protected";
1584
+ const current = () => input.isCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
1585
+ deps.isContextActive?.(input.ctx) !== false && deps.getSessionGeneration?.() === generation &&
1586
+ deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
1587
+ deps.getAdmissionScope?.() === scope && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
1588
+ deps.configStore.getAllowedUserId() === operatorUserId;
1589
+ await threads.load();
1590
+ if (!current())
1591
+ return "protected";
1592
+ const store = deps.getWorkspaceRestoreStore();
1593
+ if (!store)
1594
+ return "protected";
1595
+ const retained = store.list().find(value => value.request.operationId === input.operationId);
1596
+ const bindings = threads.listWorkspaceBindings().filter(value => isDeepStrictEqual(value.target, input.record.target));
1597
+ const binding = retained?.request.binding ?? (bindings.length === 1 ? bindings[0] : undefined);
1598
+ if (!binding || !binding.sessionId || !binding.slot)
1599
+ return "protected";
1600
+ const { sessionId, slot } = binding;
1601
+ const request = { operationId: input.operationId,
1602
+ binding, owner: structuredClone(input.record), target: { chatId: input.target.chatId, threadId: input.target.threadId },
1603
+ source: { journalBindingKey, updateIds: sourceIds } };
1604
+ if (retained && !isDeepStrictEqual(retained.request, request))
1605
+ return "protected";
1606
+ const getRecipient = (snapshot) => {
1607
+ if (!current())
1608
+ return undefined;
1609
+ const liveBindings = (snapshot ? snapshot.workspaceBindings ?? [] : threads.listWorkspaceBindings())
1610
+ .filter(value => value.bindingKey === binding.bindingKey);
1611
+ const live = liveBindings[0];
1612
+ if (liveBindings.length !== 1 || live?.sessionId !== binding.sessionId || live.cwd !== binding.cwd || live.slot !== binding.slot ||
1613
+ live.inactiveSinceMs !== undefined || (!isDeepStrictEqual(live.target, binding.target) && !isDeepStrictEqual(live.target, request.target)))
1614
+ return undefined;
1615
+ const records = (snapshot ? snapshot.threads : threads.list()).filter(value => value.status === "active" &&
1616
+ (value.slot === binding.slot || isDeepStrictEqual(value.target, live.target)));
1617
+ const record = records[0];
1618
+ if (records.length !== 1 || record?.slot !== binding.slot || !isDeepStrictEqual(record.target, live.target))
1619
+ return undefined;
1620
+ if (record.owner?.kind === "leader" && record.instanceId === instanceId) {
1621
+ const cwd = ports.getCwd(input.ctx);
1622
+ if (ports.getSessionId(input.ctx) !== sessionId || !cwd || WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) !== binding.cwd ||
1623
+ !deps.setCurrentLeaderIdentity)
1624
+ return undefined;
1625
+ return { kind: "leader", instanceId, sessionId, generation: String(generation) };
1626
+ }
1627
+ if (record.owner?.kind !== "manual-follower" || !record.instanceId)
1628
+ return undefined;
1629
+ const follower = ports.followerRegistry.get(record.instanceId);
1630
+ if (!follower?.registrationGeneration || !follower.cwd || follower.sessionId !== sessionId ||
1631
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) !== binding.cwd || follower.slot !== slot ||
1632
+ !Bus.hasTelegramBusCapability(follower.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE))
1633
+ return undefined;
1634
+ return { kind: "follower", instanceId: follower.instanceId, sessionId, generation: follower.registrationGeneration };
1635
+ };
1636
+ const hasCommittedTarget = () => threads.listWorkspaceBindings().some(value => value.bindingKey === binding.bindingKey && isDeepStrictEqual(value.target, request.target));
1637
+ const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current };
1638
+ const ready = await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, getRecipient,
1639
+ async runRecipient(action) {
1640
+ const recipient = getRecipient();
1641
+ if (!recipient || !action.isCurrent() || !hasCommittedTarget())
1642
+ return undefined;
1643
+ if (recipient.kind === "follower") {
1644
+ const follower = ports.followerRegistry.get(recipient.instanceId);
1645
+ const observation = await ports.runFollower({ operationId: request.operationId, instanceId: recipient.instanceId,
1646
+ sessionId: recipient.sessionId, slot: binding.slot, target: request.target, oldTarget: binding.target,
1647
+ mode: action.mode, isCurrent: action.isCurrent });
1648
+ if (!action.isCurrent() || !hasCommittedTarget())
1649
+ return undefined;
1650
+ if (!follower || !observation?.ready)
1651
+ return observation;
1652
+ if (observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
1653
+ !isDeepStrictEqual(observation.target, request.target) || observation.slot !== binding.slot)
1654
+ return undefined;
1655
+ const actual = ports.followerRegistry.get(recipient.instanceId);
1656
+ if (!actual || actual.registrationGeneration !== follower.registrationGeneration || actual.sessionId !== follower.sessionId ||
1657
+ actual.cwd !== follower.cwd || actual.slot !== follower.slot || actual.profileKey !== follower.profileKey ||
1658
+ actual.busSocketPath !== follower.busSocketPath || !isDeepStrictEqual(actual.target, follower.target) ||
1659
+ !isDeepStrictEqual(actual.protocol, follower.protocol))
1660
+ return undefined;
1661
+ threads.commitWorkspaceRestoreRegistration({ target: request.target, bindingKey: binding.bindingKey, slot: binding.slot }, () => {
1662
+ if (!action.isCurrent())
1663
+ throw new Error("Workspace Restore recipient authority changed.");
1664
+ ports.followerRegistry.register({ ...actual, target: request.target, connectedAtMs: actual.connectedAtMs });
1665
+ });
1666
+ return observation;
1667
+ }
1668
+ let observation;
1669
+ threads.withWorkspaceRestoreSnapshot(action.intent, snapshot => {
1670
+ if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient))
1671
+ return;
1672
+ const live = snapshot.workspaceBindings?.find(value => value.bindingKey === binding.bindingKey);
1673
+ if (!live || !isDeepStrictEqual(live.target, request.target))
1674
+ return;
1675
+ const local = ports.getLeaderIdentity();
1676
+ if (!local || local.slot !== binding.slot || (!isDeepStrictEqual(local.target, binding.target) &&
1677
+ !isDeepStrictEqual(local.target, request.target)))
1678
+ return;
1679
+ if (action.mode === "apply" && !isDeepStrictEqual(local.target, request.target)) {
1680
+ if (action.intent.phase !== "recipient-issued" || !action.isCurrent())
1681
+ return;
1682
+ deps.setCurrentLeaderIdentity({ target: request.target, slot: binding.slot, threadName: request.owner.threadName });
1683
+ }
1684
+ if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient))
1685
+ return;
1686
+ const observed = ports.getLeaderIdentity();
1687
+ if (!observed || observed.slot !== binding.slot)
1688
+ return;
1689
+ observation = { operationId: request.operationId, recipient, target: observed.target,
1690
+ slot: binding.slot, ready: isDeepStrictEqual(observed.target, request.target) };
1691
+ });
1692
+ return observation;
1693
+ } }).catch(error => {
1694
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-recipient" });
1695
+ return undefined;
1696
+ });
1697
+ if (!ready || !current() || ready.routing)
1698
+ return "protected";
1699
+ const recipient = ready.readyRecipient ?? ready.recipient;
1700
+ const recipientCurrent = () => {
1701
+ if (!current() || !recipient || !hasCommittedTarget() || !isDeepStrictEqual(getRecipient(), recipient))
1702
+ return false;
1703
+ const observed = recipient.kind === "leader" ? ports.getLeaderIdentity() : ports.followerRegistry.get(recipient.instanceId);
1704
+ return observed?.slot === slot && isDeepStrictEqual(observed.target, request.target);
1705
+ };
1706
+ if (!recipient || !recipientCurrent())
1707
+ return "protected";
1708
+ if (deps.callApi) {
1709
+ try {
1710
+ await deps.callApi("editForumTopic", { chat_id: request.target.chatId, message_thread_id: request.target.threadId,
1711
+ name: deps.getDisplayTitle?.(request.target) ?? ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot) });
1712
+ }
1713
+ catch (error) {
1714
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-title" });
1715
+ }
1716
+ }
1717
+ if (!recipientCurrent() || !store.issueRouting(ready, { ...authority, isCurrent: recipientCurrent }))
1718
+ return "protected";
1719
+ const recordAcceptance = (message, delivery) => {
1720
+ if (!recipientCurrent() || recipient.kind !== (delivery ? "follower" : "leader"))
1721
+ throw new Error("Workspace Restore acceptance authority changed.");
1722
+ const source = Updates.inspectTelegramDeferredSource(message);
1723
+ const follower = ports.followerRegistry.get(recipient.instanceId);
1724
+ const ownership = deps.getTargetOwnership?.(request.target);
1725
+ if (!source || source.journalBindingKey !== journalBindingKey || !request.source.updateIds.includes(source.updateId) ||
1726
+ delivery && (!follower || ownership?.instanceId !== recipient.instanceId || ownership.ownerGeneration !== recipient.generation ||
1727
+ !ownership.recipientBindingKey || delivery.sourceUpdateId !== source.updateId ||
1728
+ delivery.recipientBindingKey !== ownership.recipientBindingKey || delivery.deliveryId !== Bus.createTelegramBusFollowerDeliveryIdentity({
1729
+ kind: "leader.forwardMessage", recipientBindingKey: ownership.recipientBindingKey, sourceUpdateId: source.updateId
1730
+ }).deliveryId) ||
1731
+ !recipientCurrent())
1732
+ throw new Error("Workspace Restore acceptance source or delivery changed.");
1733
+ const evidence = { ...source, recipient,
1734
+ ...(delivery ? { kind: "forwarded", deliveryId: delivery.deliveryId, recipientBindingKey: delivery.recipientBindingKey }
1735
+ : { kind: "completed" }) };
1736
+ const expected = store.list().find(value => value.request.operationId === request.operationId);
1737
+ if (!expected || !isDeepStrictEqual(expected.request, request) || !expected.routing ||
1738
+ !recipientCurrent())
1739
+ throw new Error("Workspace Restore dispatch proof is unavailable.");
1740
+ let accepted;
1741
+ let failure;
1742
+ const acceptanceAuthority = { ...authority, isCurrent: recipientCurrent };
1743
+ try {
1744
+ accepted = store.recordSourceAcceptance(expected, evidence, acceptanceAuthority);
1745
+ }
1746
+ catch (error) {
1747
+ failure = { error };
1748
+ }
1749
+ if (!recipientCurrent())
1750
+ throw new Error("Workspace Restore acceptance authority changed.");
1751
+ // A lost rename reply observes only the exact full retained proof; it never resends the accepted message.
1752
+ if (!accepted) {
1753
+ const observed = store.list().find(value => value.request.operationId === request.operationId);
1754
+ if (observed && isDeepStrictEqual(observed.request, request) && isDeepStrictEqual(observed.executor, authority.executor) &&
1755
+ observed.operatorUserId === operatorUserId && observed.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence)))
1756
+ accepted = observed;
1757
+ }
1758
+ if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
1759
+ accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence)) ||
1760
+ !recipientCurrent())
1761
+ throw failure?.error ?? new Error("Workspace Restore acceptance was not published.");
1762
+ return { ...source, completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) };
1763
+ };
1764
+ try {
1765
+ await input.dispatch(recipient, recipientCurrent, recordAcceptance, message => recordAcceptance(message));
1766
+ }
1767
+ catch (error) {
1768
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-dispatch" });
1769
+ }
1770
+ // Positive worker ACKs own settlement and cleanup under a subsequent admission.
1771
+ return "protected";
1772
+ } : undefined;
701
1773
  const retryPendingRerouteCleanup = async (cleanup, assertExecutionCurrent) => {
702
1774
  if (cleanup.kind === "unbound") {
703
- return closeReroutedUnboundTopic(cleanup.target, cleanup.messageId, assertExecutionCurrent);
1775
+ return closeReroutedUnboundTopic(cleanup.target, cleanup.messageId, assertExecutionCurrent, cleanup.temporaryThread);
704
1776
  }
705
1777
  if (cleanup.kind === "previous-leader") {
706
1778
  return closePreviousLeaderThread(cleanup.target, assertExecutionCurrent);
@@ -711,60 +1783,701 @@ export function createTelegramInboundRouteRuntime(deps) {
711
1783
  const dispatchPendingRerouteMessages = async (pending, messages, ctx) => {
712
1784
  if (pending.dispatchKind === "command" && dispatchReroutedCommandMessages) {
713
1785
  await dispatchReroutedCommandMessages(messages, ctx);
714
- if (pending.sourceTarget.threadId === undefined) {
715
- for (const message of messages)
716
- Updates.reportTelegramUpdateCompleted(message);
1786
+ // Every command chooser deferred its exact original, whether the tab came from Telegram or the bridge.
1787
+ // A queued command retains its separately reported receipt; this cannot settle accepted queue custody.
1788
+ for (const message of messages)
1789
+ Updates.reportTelegramUpdateCompleted(message);
1790
+ return;
1791
+ }
1792
+ await promptEnqueue(messages, ctx);
1793
+ };
1794
+ const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query)) => {
1795
+ assertExecutionCurrent();
1796
+ const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
1797
+ assertExecutionCurrent();
1798
+ if (dismissed) {
1799
+ removePendingReroute(rerouteId);
1800
+ await deps.answerCallbackQuery(query.id, successMessage);
1801
+ return;
1802
+ }
1803
+ pending.finalizeMessage = successMessage;
1804
+ await deps.answerCallbackQuery(query.id, `${successMessage} Chooser cleanup is still pending. Try again.`);
1805
+ };
1806
+ const isTemporaryReroute = (pending) => !!pending.temporaryThread || pending.temporaryMembership === true || isTemporaryTabTarget(pending.sourceTarget);
1807
+ const isTemporaryThreadForwardIssued = (pending) => {
1808
+ try {
1809
+ const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
1810
+ const store = deps.getWorkspaceRestoreStore?.();
1811
+ if ((entry || pending.temporaryMembership) && !store)
1812
+ return true;
1813
+ const stored = entry && store?.listTemporaryThreads().find(value => value.token === entry.token);
1814
+ const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages);
1815
+ return !!stored?.forwardedInputs?.some(input => input.updateIds.some(id => updateIds.includes(id)));
1816
+ }
1817
+ catch {
1818
+ return true;
1819
+ }
1820
+ };
1821
+ /** Publishes the durable one-time Forward fact for this chooser's exact group; false means nothing may be sent. */
1822
+ const issueTemporaryThreadForward = (pending, ctx) => {
1823
+ const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
1824
+ const cap = captureTemporaryThreadAuthority(ctx);
1825
+ if (!entry || !cap)
1826
+ return false;
1827
+ const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
1828
+ try {
1829
+ const current = cap.store.listTemporaryThreads().find(value => value.token === entry.token);
1830
+ const adopted = current && cap.isCurrent() ? cap.adopt(current) : undefined;
1831
+ return !!adopted && cap.isCurrent() && !!cap.store.recordTemporaryThreadForwardIssued(adopted, group, cap.authority) && cap.isCurrent();
1832
+ }
1833
+ catch (error) {
1834
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-forward-issue" });
1835
+ return false;
1836
+ }
1837
+ };
1838
+ const forwardPendingRerouteMessages = async (pending, instanceId, threadId, ctx, assertExecutionCurrent, recordForwardAcceptance) => {
1839
+ assertExecutionCurrent?.();
1840
+ const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
1841
+ if (!forwardMessage || pending.foreignForwardIssued)
1842
+ return false;
1843
+ const target = { ...pending.sourceTarget, threadId };
1844
+ const liveOwnership = deps.getTargetOwnership?.(target);
1845
+ if (!liveOwnership || liveOwnership.instanceId !== instanceId ||
1846
+ !liveOwnership.ownerGeneration || !liveOwnership.recipientBindingKey)
1847
+ return false;
1848
+ const ownership = structuredClone(liveOwnership);
1849
+ const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
1850
+ // Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
1851
+ // even after restart. Any refusal or publication failure sends nothing and keeps the input held.
1852
+ if (!recordForwardAcceptance && isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
1853
+ if (isTemporaryThreadForwardIssued(pending))
1854
+ pending.foreignForwardIssued = true;
1855
+ return false;
1856
+ }
1857
+ if (!recordForwardAcceptance && isTemporaryReroute(pending))
1858
+ pending.foreignForwardIssued = true;
1859
+ const outcomes = await Promise.allSettled(messages.map((message) => forwardMessage({
1860
+ message,
1861
+ ownership,
1862
+ ctx,
1863
+ })));
1864
+ assertExecutionCurrent?.();
1865
+ if (!isDeepStrictEqual(deps.getTargetOwnership?.(target), ownership))
1866
+ return false;
1867
+ pending.messages = pending.messages.filter((_, index) => {
1868
+ const outcome = outcomes[index];
1869
+ if (outcome?.status === "fulfilled" &&
1870
+ outcome.value.status === "accepted") {
1871
+ // Restore publishes acceptance before reporting completion; cleanup still waits for journal disposition.
1872
+ const expectedSource = recordForwardAcceptance?.(pending.messages[index], outcome.value.delivery);
1873
+ assertExecutionCurrent?.();
1874
+ Updates.reportTelegramUpdateCompleted(pending.messages[index], expectedSource);
1875
+ return false;
1876
+ }
1877
+ if (outcome?.status === "rejected") {
1878
+ deps.recordRuntimeEvent?.("bus", outcome.reason, {
1879
+ phase: "reroute-foreign-forward",
1880
+ instanceId,
1881
+ threadId,
1882
+ messageIndex: index,
1883
+ });
1884
+ }
1885
+ return true;
1886
+ });
1887
+ if (pending.messages.length === 0)
1888
+ pending.foreignForwardIssued = false;
1889
+ return pending.messages.length === 0;
1890
+ };
1891
+ const temporaryCleanupTimers = new Map();
1892
+ const cancelTemporaryThreadCleanup = (token) => {
1893
+ const scheduled = temporaryCleanupTimers.get(token);
1894
+ if (!scheduled)
1895
+ return;
1896
+ clearTimeout(scheduled.timer);
1897
+ temporaryCleanupTimers.delete(token);
1898
+ scheduled.settle();
1899
+ };
1900
+ /**
1901
+ * One cleanup attempt after the quiet period. Cancellation facts are only a precondition: fresh authority, proof,
1902
+ * absence of chooser and journal custody, and the ordinary protection checks must all hold again. The attempt is
1903
+ * durably issued once; a skipped or unknown removal keeps the entry protecting the tab across restart.
1904
+ */
1905
+ const runTemporaryThreadCleanup = async (token, cap) => {
1906
+ const store = cap.store, inspect = deps.inspectRestoreSourceAbandonment;
1907
+ if (!inspect || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent())
1908
+ return;
1909
+ await deps.runWorkspaceOperation({ operationId: `temporary-thread-cleanup-${randomBytes(16).toString("hex")}`,
1910
+ operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
1911
+ if (!cap.isCurrent())
1912
+ return;
1913
+ let entry = store.listTemporaryThreads().find(value => value.token === token && value.phase === "created" && !!value.target);
1914
+ const target = entry?.target;
1915
+ if (!entry || !target || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target) ||
1916
+ entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey)
1917
+ return;
1918
+ const owner = `telegram-owner:${cap.operatorUserId}`, cancelled = entry.cancelledInputs ?? [];
1919
+ const cancelledCurrent = () => cancelled.every(input => input.updateIds.every(updateId => {
1920
+ const evidence = inspect(updateId, input.journalBindingKey);
1921
+ return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
1922
+ evidence.operatorAuthorityId === owner;
1923
+ }));
1924
+ if (!cancelledCurrent())
1925
+ return;
1926
+ entry = cap.adopt(entry);
1927
+ if (!entry || !cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry))
1928
+ return;
1929
+ const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
1930
+ if (!issued || !cap.isCurrent())
1931
+ return;
1932
+ entry = issued.entry;
1933
+ const issuedEntry = entry;
1934
+ const assertCurrent = () => {
1935
+ if (!cap.isCurrent() || !cancelledCurrent() || !store.isTemporaryThreadCleanupCurrent(issuedEntry, cap.authority))
1936
+ throw new Error("Temporary Thread cleanup authority or exact grant evidence changed.");
1937
+ };
1938
+ if (!await closeReroutedUnboundTopic(target, undefined, assertCurrent, entry) || !cap.isCurrent())
1939
+ return;
1940
+ store.retireTemporaryThread(entry, cap.authority);
1941
+ });
1942
+ };
1943
+ /** Starts the quiet period only when every known group is resolved; a new input or authority change denies it. */
1944
+ const scheduleTemporaryThreadCleanup = (target, ctx) => {
1945
+ const cap = captureTemporaryThreadAuthority(ctx);
1946
+ if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target))
1947
+ return;
1948
+ let entry;
1949
+ try {
1950
+ entry = cap.store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
1951
+ }
1952
+ catch (error) {
1953
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" });
1954
+ return;
1955
+ }
1956
+ if (!entry || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) ||
1957
+ entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey)
1958
+ return;
1959
+ const token = entry.token;
1960
+ cancelTemporaryThreadCleanup(token);
1961
+ const requested = deps.temporaryThreadCleanupDelayMs;
1962
+ const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
1963
+ let settle = () => undefined;
1964
+ const task = new Promise(resolve => { settle = resolve; });
1965
+ const timer = setTimeout(() => {
1966
+ if (temporaryCleanupTimers.get(token)?.timer !== timer)
1967
+ return;
1968
+ temporaryCleanupTimers.delete(token);
1969
+ runTemporaryThreadCleanup(token, cap)
1970
+ .catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" }); })
1971
+ .finally(settle);
1972
+ }, delay);
1973
+ timer.unref?.();
1974
+ temporaryCleanupTimers.set(token, { timer, settle });
1975
+ restoreSettlementTasks.add(task);
1976
+ void task.finally(() => restoreSettlementTasks.delete(task));
1977
+ };
1978
+ const recordCancelledTemporaryThreadInput = (pending, ctx, isCurrent) => {
1979
+ const store = deps.getWorkspaceRestoreStore?.(), target = pending.sourceTarget;
1980
+ if (!store || target.threadId === undefined)
1981
+ return !pending.temporaryThread;
1982
+ let entry = store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
1983
+ if (!entry)
1984
+ return !pending.temporaryThread;
1985
+ const cap = captureTemporaryThreadAuthority(ctx, isCurrent), inspect = deps.inspectRestoreSourceAbandonment;
1986
+ if (!cap || !inspect || cap.operatorUserId !== entry.operatorUserId || cap.journalBindingKey !== entry.source.journalBindingKey)
1987
+ return false;
1988
+ const { operatorUserId, journalBindingKey } = cap, current = cap.isCurrent;
1989
+ const input = { journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
1990
+ if (!current())
1991
+ return false;
1992
+ entry = cap.adopt(entry);
1993
+ if (!entry || !current())
1994
+ return false;
1995
+ const recorded = store.recordTemporaryThreadInputCancellation(entry, input, cap.authority, updateId => inspect(updateId, journalBindingKey));
1996
+ if (!recorded || !current() || !recorded.cancelledInputs?.some(value => isDeepStrictEqual(value, input)))
1997
+ return false;
1998
+ const observed = store.listTemporaryThreads().find(value => isDeepStrictEqual(value, recorded));
1999
+ return !!observed && input.updateIds.every(updateId => {
2000
+ const evidence = inspect(updateId, journalBindingKey);
2001
+ return evidence?.journalBindingKey === journalBindingKey && evidence.updateId === updateId &&
2002
+ evidence.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
2003
+ }) && current();
2004
+ };
2005
+ const retireCancellationChooser = async (id, pending, isCurrent) => {
2006
+ if (!pending.abandonment?.result || !isCurrent() || !deps.editInteractiveMessage || pending.chooserMessageId === undefined)
2007
+ return false;
2008
+ await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⛔️ Routing cancelled.</b>", "html", { inline_keyboard: [] });
2009
+ if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
2010
+ return false;
2011
+ removePendingReroute(id);
2012
+ return true;
2013
+ };
2014
+ const isReviewText = (message) => {
2015
+ if (!message || message.chat?.type !== "private" || !Number.isSafeInteger(message.chat.id) ||
2016
+ !Number.isSafeInteger(message.from?.id) || message.from?.is_bot !== false ||
2017
+ !Number.isSafeInteger(message.message_id) || message.message_id <= 0 ||
2018
+ !Number.isSafeInteger(message.message_thread_id) || message.message_thread_id <= 0 ||
2019
+ typeof message.text !== "string" || !message.text.trim() || message.text.trim().startsWith("/"))
2020
+ return false;
2021
+ const fields = message;
2022
+ return ["media_group_id", "business_connection_id", "photo", "video", "audio", "voice", "document", "animation", "sticker", "contact", "location", "venue", "poll", "dice", "story"].every(key => fields[key] === undefined);
2023
+ };
2024
+ const reviewMessage = (entry) => {
2025
+ const message = entry.update.message;
2026
+ if (entry.state !== "pending" || entry.preApprovalExcluded || entry.inputClaim || entry.inputProvenance ||
2027
+ entry.queueOwner || entry.queueReceiptId || entry.queueHandoff || entry.failure ||
2028
+ Object.keys(entry.update).some(key => key !== "update_id" && key !== "message") ||
2029
+ !isReviewText(message) || Reflect.has(message, "pi_telegram_source_update_id"))
2030
+ return undefined;
2031
+ return message;
2032
+ };
2033
+ const needsHistoricalReview = (message) => {
2034
+ if (!deps.threadStore)
2035
+ throw new Error("Historical routing requires a current Thread snapshot.");
2036
+ if (deps.threadStore.getBotState().threadMode === "disabled")
2037
+ return true;
2038
+ return !getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).some(record => record.target.chatId === message.chat.id && record.target.threadId === message.message_thread_id);
2039
+ };
2040
+ /** New-world restart: forget previous-instance Restore/temporary state, then one silent delete per positively disposable tab. */
2041
+ const forgetPreviousWorld = async (input, captureTransport) => {
2042
+ const result = { forgotten: 0, deleted: 0 };
2043
+ const { ctx, signal, journalBindingKey, isCurrent: preparedCurrent } = input;
2044
+ const transport = captureTransport ? captureTransport(ctx) : () => true;
2045
+ const cap = transport && captureTemporaryThreadAuthority(ctx, () => !signal.aborted && preparedCurrent() && transport() &&
2046
+ deps.hasWorkspaceRestoreAuthority?.() === true);
2047
+ const threads = deps.threadStore;
2048
+ if (!cap?.isCurrent() || cap.journalBindingKey !== journalBindingKey || !threads || !deps.runWorkspaceOperation)
2049
+ return result;
2050
+ try {
2051
+ await deps.runWorkspaceOperation({ operationId: `new-world-${randomBytes(16).toString("hex")}`,
2052
+ operationKind: "workspace.forget-previous-world", scopes: [{ kind: "profile" }] }, async () => {
2053
+ if (!cap.isCurrent())
2054
+ return;
2055
+ await threads.load();
2056
+ if (!cap.isCurrent())
2057
+ return;
2058
+ const self = cap.authority.executor.instanceId;
2059
+ const disposable = cap.store.listTemporaryThreads().flatMap(entry => entry.operatorUserId === cap.operatorUserId &&
2060
+ entry.executor.instanceId !== self && entry.phase === "created" && entry.target &&
2061
+ cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" ? [entry] : []);
2062
+ const forgotten = cap.store.forgetPreviousWorld(cap.authority);
2063
+ if (!forgotten)
2064
+ return;
2065
+ result.forgotten = forgotten.operations.length + forgotten.temporaryThreads.length;
2066
+ for (const entry of disposable) {
2067
+ const target = entry.target;
2068
+ if (!cap.isCurrent() || !deps.callApi)
2069
+ break;
2070
+ await threads.load();
2071
+ const same = (value) => value?.chatId === target.chatId && value.threadId === target.threadId;
2072
+ if (!cap.isCurrent() || threads.listWorkspaceBindings().some(value => same(value.target)) ||
2073
+ threads.list().some(record => record.status === "active" && same(record.target)) ||
2074
+ cap.store.listTemporaryThreads().some(entry => same(entry.target)) ||
2075
+ cap.store.list().some(({ request }) => same(request.target) || same(request.binding.target)))
2076
+ continue;
2077
+ try {
2078
+ const required = [...new Set([cap.journalBindingKey, entry.source.journalBindingKey,
2079
+ ...Threads.getTelegramTemporaryThreadInputs(entry).map(input => input.journalBindingKey)])];
2080
+ if (deps.inspectTemporaryThreadSources?.(target, required)?.length !== 0 || !cap.isCurrent())
2081
+ continue;
2082
+ // Exactly one attempt; the entry is already forgotten, so failure or silence leaves the tab without retry.
2083
+ if (await deps.callApi("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId }, { maxAttempts: 1, retrySafety: "non-idempotent" }) === true)
2084
+ result.deleted++;
2085
+ }
2086
+ catch (error) {
2087
+ deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-tab-delete" });
2088
+ }
2089
+ }
2090
+ });
2091
+ }
2092
+ catch (error) {
2093
+ deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-forget" });
2094
+ }
2095
+ return result;
2096
+ };
2097
+ /** Historical temporary sources await the cold policy; warm bound/unknown sources never fall into ordinary dispatch. */
2098
+ const isRecordedTemporaryThreadInputHeld = (entry, ctx, signal, historical = false) => {
2099
+ const store = deps.getWorkspaceRestoreStore?.(), binding = deps.getAdmissionJournalBinding?.();
2100
+ if (!store || !binding)
2101
+ return false;
2102
+ const members = store.listTemporaryThreads().filter(temporary => Threads.getTelegramTemporaryThreadInputs(temporary).some(input => input.journalBindingKey === binding && input.updateIds.includes(entry.updateId)));
2103
+ const cap = captureTemporaryThreadAuthority(ctx, () => !signal.aborted);
2104
+ return members.some(temporary => {
2105
+ if (!cap?.isCurrent())
2106
+ return true;
2107
+ const observation = store.inspectTemporaryThreadTarget(temporary, cap.authority);
2108
+ return historical || !observation || observation.kind !== "temporary";
2109
+ });
2110
+ };
2111
+ const shouldHoldPendingInput = async (entry, ctx, signal) => {
2112
+ const binding = deps.getAdmissionJournalBinding?.();
2113
+ const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true && deps.getAdmissionJournalBinding?.() === binding;
2114
+ if (!current())
2115
+ throw new Error("Temporary Thread source generation ended.");
2116
+ const held = isRecordedTemporaryThreadInputHeld(entry, ctx, signal);
2117
+ if (!current())
2118
+ throw new Error("Temporary Thread source authority changed.");
2119
+ return held;
2120
+ };
2121
+ const shouldReviewHistoricalInput = async (entry, ctx, signal) => {
2122
+ if (signal.aborted)
2123
+ throw new Error("Historical routing generation ended.");
2124
+ const message = entry.update.message;
2125
+ const unsupported = !reviewMessage(entry);
2126
+ const retainable = unsupported && entry.state === "pending" && !entry.preApprovalExcluded && !entry.inputClaim && !entry.inputProvenance &&
2127
+ !entry.queueOwner && !entry.queueReceiptId && !entry.queueHandoff && !entry.failure &&
2128
+ Object.keys(entry.update).every(key => key === "update_id" || key === "message") &&
2129
+ message?.chat?.type === "private" && Number.isSafeInteger(message.chat.id) &&
2130
+ Number.isSafeInteger(message.from?.id) && message.from?.is_bot === false &&
2131
+ Number.isSafeInteger(message.message_id) && message.message_id > 0 &&
2132
+ Number.isSafeInteger(message.message_thread_id) && message.message_thread_id > 0 &&
2133
+ !Reflect.has(message, "pi_telegram_source_update_id") && !Reflect.has(message, "business_connection_id");
2134
+ const temporary = isRecordedTemporaryThreadInputHeld(entry, ctx, signal, true);
2135
+ if (!retainable && temporary)
2136
+ return true;
2137
+ if (!retainable && unsupported)
2138
+ return false;
2139
+ if (signal.aborted)
2140
+ throw new Error("Historical routing generation ended.");
2141
+ const epoch = deps.getCurrentLeaderEpoch?.();
2142
+ const binding = deps.getAdmissionJournalBinding?.();
2143
+ const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true &&
2144
+ epoch !== undefined && deps.getCurrentLeaderEpoch?.() === epoch && !!binding && deps.getAdmissionJournalBinding?.() === binding;
2145
+ if (!current() || !deps.threadStore)
2146
+ throw new Error("Historical routing authority is unavailable.");
2147
+ await deps.threadStore.load();
2148
+ if (!current())
2149
+ throw new Error("Historical routing authority changed.");
2150
+ // Raw unsupported startup originals stay protected even when a bound target outlives forgotten Restore membership.
2151
+ // Holding protects evidence, not sender authorization; owner-bearing sources keep their existing paths.
2152
+ return retainable ? "retain" : needsHistoricalReview(message);
2153
+ };
2154
+ const recoveryPreview = (source, owner, chatId, binding) => {
2155
+ const entry = source.original;
2156
+ const message = reviewMessage(entry);
2157
+ if (!message || message.chat.id !== chatId || message.from.id !== owner)
2158
+ return undefined;
2159
+ for (const pending of pendingUnboundReroutes.values()) {
2160
+ if (pending.abandonment?.journalBindingKey === binding &&
2161
+ Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(entry.updateId) &&
2162
+ // Revoked carriers describe a past attempt, not a live selection veto.
2163
+ // Current claim/entry authority still comes from the worker and journal CAS.
2164
+ pending.messages.some(value => Updates.getTelegramUpdateExecutionFence(value)?.signal.aborted !== true) &&
2165
+ (pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.foreignRetry || pending.cleanup ||
2166
+ pending.abandonment.running))
2167
+ return undefined;
2168
+ }
2169
+ const text = Array.from(message.text.replace(/\s+/gu, " ").trim());
2170
+ return escapeHtml(text.slice(0, 80).join("")) + (text.length > 80 ? "…" : "");
2171
+ };
2172
+ const expireRoutingInput = async (source, ctx, signal) => {
2173
+ const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
2174
+ const message = source.original.update.message;
2175
+ const current = () => !signal.aborted && source.isCurrent() && operator !== undefined && epoch !== undefined &&
2176
+ deps.isContextActive?.(ctx) === true && deps.configStore.getAllowedUserId() === operator &&
2177
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
2178
+ source.original.routingInput?.operatorUserId === operator && message?.from?.id === operator && message.from.is_bot !== true && message.chat?.type === "private";
2179
+ if (!current() || !deps.runWorkspaceOperation)
2180
+ return;
2181
+ await deps.runWorkspaceOperation({ operationId: `routing-expiry-${randomBytes(16).toString("hex")}`,
2182
+ operationKind: "workspace.expire-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
2183
+ if (!current())
2184
+ return;
2185
+ const restores = deps.getWorkspaceRestoreStore?.();
2186
+ if (deps.getWorkspaceRestoreStore && !restores)
2187
+ return;
2188
+ if (restores?.list().some(intent => intent.request.source.journalBindingKey === source.journalBindingKey &&
2189
+ intent.request.source.updateIds.includes(source.original.updateId)))
2190
+ return;
2191
+ for (const pending of pendingUnboundReroutes.values()) {
2192
+ if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
2193
+ continue;
2194
+ if (pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.workspaceRestore || pending.foreignRetry || pending.cleanup || pending.abandonment?.running)
2195
+ return;
2196
+ }
2197
+ const result = source.expire();
2198
+ if (!result)
2199
+ return;
2200
+ // Retire local controls after the source ACK. Telegram UI failure cannot undo retention or trigger deletion.
2201
+ for (const [id, pending] of pendingUnboundReroutes) {
2202
+ if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
2203
+ continue;
2204
+ removePendingReroute(id);
2205
+ if (signal.aborted || deps.isContextActive?.(ctx) !== true || deps.configStore.getAllowedUserId() !== operator ||
2206
+ deps.getCurrentLeaderEpoch?.() !== epoch || deps.getAdmissionJournalBinding?.() !== source.journalBindingKey)
2207
+ continue;
2208
+ if (deps.editInteractiveMessage && pending.chooserMessageId !== undefined) {
2209
+ try {
2210
+ await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, `<b>${TELEGRAM_ROUTING_CHOICE_EXPIRED}</b>`, "html", { inline_keyboard: [] });
2211
+ }
2212
+ catch (error) {
2213
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" });
2214
+ }
2215
+ }
717
2216
  }
718
- return;
719
- }
720
- await promptEnqueue(messages, ctx);
2217
+ });
721
2218
  };
722
- const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query)) => {
723
- assertExecutionCurrent();
724
- const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
725
- assertExecutionCurrent();
726
- if (dismissed) {
727
- removePendingReroute(rerouteId);
728
- await deps.answerCallbackQuery(query.id, successMessage);
2219
+ const renderCancellationReview = async (view, isCurrent) => {
2220
+ if (!isCurrent())
729
2221
  return;
2222
+ const data = (action) => `${TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX}${view.id}:${action}`;
2223
+ const rows = [[{ text: "⬆️ Main menu", callback_data: "menu:back" }]];
2224
+ const lines = ["<b>☑️ Review pending cancellations:</b>", "",
2225
+ "Finish stopping routing retries to retain the original privately. No new delivery is started; previously accepted work may continue. No Telegram message or Thread is deleted."];
2226
+ if (!view.sources.length)
2227
+ lines.push("", "No supported pending cancellations on this page.");
2228
+ for (const [index, source] of view.sources.entries()) {
2229
+ lines.push("", `<b>${index + 1}. ${source.result ? "⛔️ Routing cancelled." : "Protected input"}</b>`, source.preview);
2230
+ if (!source.result)
2231
+ rows.push([{ text: `❌ Finish cancellation ${index + 1}`, callback_data: data(`retry:${index}`) }]);
730
2232
  }
731
- pending.finalizeMessage = successMessage;
732
- await deps.answerCallbackQuery(query.id, `${successMessage} Chooser cleanup is still pending. Try again.`);
2233
+ if (view.unsupported)
2234
+ lines.push("", "Other protected inputs are not available in this view and remain untouched.");
2235
+ if (view.nextAfterUpdateId !== undefined)
2236
+ rows.push([{ text: "🟣 More", callback_data: data("more") }]);
2237
+ rows.push([{ text: "🔄 Refresh", callback_data: data("refresh") }]);
2238
+ await deps.editInteractiveMessage(view.target.chatId, view.messageId, lines.join("\n"), "html", { inline_keyboard: rows });
733
2239
  };
734
- const forwardPendingRerouteMessages = async (pending, instanceId, threadId, ctx, assertExecutionCurrent) => {
735
- assertExecutionCurrent?.();
736
- const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
737
- if (!forwardMessage)
2240
+ const handleCancellationReview = async (query, ctx) => {
2241
+ const matches = (view) => query.message?.message_id === view.messageId &&
2242
+ query.message.chat.id === view.target.chatId &&
2243
+ (query.message.message_thread_id === undefined || query.message.message_thread_id === view.target.threadId);
2244
+ if (query.data?.startsWith(TELEGRAM_RETIRED_HISTORICAL_REVIEW_PREFIX)) {
2245
+ await deps.answerCallbackQuery(query.id, "🚫 This control is no longer available.");
2246
+ return true;
2247
+ }
2248
+ const ownsReview = query.data?.startsWith(TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX) === true;
2249
+ const prefix = TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX;
2250
+ if (!ownsReview && cancellationReview && matches(cancellationReview)) {
2251
+ if (cancellationReview.running && cancellationReview.isCurrent()) {
2252
+ await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
2253
+ return true;
2254
+ }
2255
+ cancellationReview = undefined;
738
2256
  return false;
739
- const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
740
- const outcomes = await Promise.allSettled(messages.map((message) => forwardMessage({
741
- message,
742
- ownership: { instanceId },
743
- ctx,
744
- })));
745
- assertExecutionCurrent?.();
746
- pending.messages = pending.messages.filter((_, index) => {
747
- const outcome = outcomes[index];
748
- if (outcome?.status === "fulfilled" &&
749
- outcome.value.status === "accepted") {
750
- if (pending.dispatchKind === "command" && pending.sourceTarget.threadId === undefined) {
751
- Updates.reportTelegramUpdateCompleted(pending.messages[index]);
2257
+ }
2258
+ if (!ownsReview)
2259
+ return false;
2260
+ const unavailable = async () => { await deps.answerCallbackQuery(query.id, "🚫 Cancellation review expired or unavailable. Open Status again."); return true; };
2261
+ const execution = Updates.getTelegramUpdateExecutionFence(query);
2262
+ const chatId = query.message?.chat.id;
2263
+ const messageId = query.message?.message_id;
2264
+ if (execution?.isCurrent() !== true || deps.isContextActive?.(ctx) !== true ||
2265
+ !deps.editInteractiveMessage || !deps.runWorkspaceOperation || query.message?.chat.type !== "private" ||
2266
+ typeof chatId !== "number" || !Number.isSafeInteger(chatId) ||
2267
+ typeof messageId !== "number" || !Number.isSafeInteger(messageId) || messageId <= 0)
2268
+ return unavailable();
2269
+ const owner = deps.configStore.getAllowedUserId();
2270
+ const journalBindingKey = deps.getAdmissionJournalBinding?.();
2271
+ const epoch = deps.getCurrentLeaderEpoch?.();
2272
+ if (owner === undefined || query.from.id !== owner || query.from.is_bot || !journalBindingKey || epoch === undefined)
2273
+ return unavailable();
2274
+ const current = () => execution.isCurrent() && deps.isContextActive?.(ctx) === true &&
2275
+ deps.configStore.getAllowedUserId() === owner && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
2276
+ deps.getCurrentLeaderEpoch?.() === epoch;
2277
+ const parsed = query.data.slice(prefix.length).match(/^([a-z0-9]+):(retry:(\d+)|refresh|more)$/);
2278
+ const prior = cancellationReview;
2279
+ const open = query.data === `${prefix}open`;
2280
+ if (!open && (!parsed || !prior || parsed[1] !== prior.id || !matches(prior) || !prior.isCurrent()))
2281
+ return unavailable();
2282
+ if (prior?.running && prior.isCurrent()) {
2283
+ await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
2284
+ return true;
2285
+ }
2286
+ let view = prior;
2287
+ let selected;
2288
+ try {
2289
+ if (open || parsed?.[2] === "refresh" || parsed?.[2] === "more") {
2290
+ if (parsed?.[2] === "more" && prior?.nextAfterUpdateId === undefined)
2291
+ return unavailable();
2292
+ const next = { id: randomBytes(16).toString("hex"),
2293
+ target: { chatId, ...(query.message.message_thread_id !== undefined ? { threadId: query.message.message_thread_id } : {}) },
2294
+ messageId, ownerUserId: owner, journalBindingKey, sources: [], unsupported: false,
2295
+ isCurrent: () => cancellationReview === next && current() };
2296
+ cancellationReview = view = next;
2297
+ const page = Updates.inspectTelegramAbandoningUpdates(query, { journalBindingKey, isCurrent: next.isCurrent,
2298
+ ...(parsed?.[2] === "more" ? { afterUpdateId: prior.nextAfterUpdateId } : {}) });
2299
+ if (!page) {
2300
+ if (cancellationReview === next)
2301
+ cancellationReview = undefined;
2302
+ return unavailable();
752
2303
  }
753
- return false;
2304
+ for (const source of page.sources) {
2305
+ const preview = recoveryPreview(source, owner, next.target.chatId, journalBindingKey);
2306
+ if (preview === undefined) {
2307
+ next.unsupported = true;
2308
+ continue;
2309
+ }
2310
+ if (next.sources.length === 5) {
2311
+ next.nextAfterUpdateId = next.sources.at(-1).original.updateId;
2312
+ break;
2313
+ }
2314
+ next.sources.push({ ...source, preview });
2315
+ }
2316
+ next.nextAfterUpdateId ??= page.nextAfterUpdateId;
2317
+ next.running = true;
2318
+ await renderCancellationReview(next, next.isCurrent);
2319
+ if (next.isCurrent())
2320
+ await deps.answerCallbackQuery(query.id);
754
2321
  }
755
- if (outcome?.status === "rejected") {
756
- deps.recordRuntimeEvent?.("bus", outcome.reason, {
757
- phase: "reroute-foreign-forward",
758
- instanceId,
759
- threadId,
760
- messageIndex: index,
2322
+ else {
2323
+ if (!view || !parsed?.[3] || !parsed[2].startsWith("retry:"))
2324
+ return unavailable();
2325
+ selected = view.sources[Number(parsed[3])];
2326
+ if (!selected)
2327
+ return unavailable();
2328
+ const activeView = view;
2329
+ const source = selected;
2330
+ const isCurrent = () => current() && activeView.isCurrent() &&
2331
+ recoveryPreview(source, owner, activeView.target.chatId, journalBindingKey) !== undefined;
2332
+ view.running = true;
2333
+ await deps.runWorkspaceOperation({ operationId: `workspace-cancellation-recovery:${query.id}`,
2334
+ operationKind: "workspace.recover-unbound-cancellation", scopes: [{ kind: "profile" }] }, async () => {
2335
+ if (!isCurrent())
2336
+ return;
2337
+ source.result ??= source.retry({ operatorAuthorityId: `telegram-owner:${owner}`, isCurrent });
2338
+ if (!source.result) {
2339
+ await unavailable();
2340
+ return;
2341
+ }
2342
+ for (const [id, pending] of pendingUnboundReroutes) {
2343
+ if (pending.abandonment?.ownerUserId !== owner || pending.abandonment.journalBindingKey !== journalBindingKey ||
2344
+ !Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
2345
+ continue;
2346
+ pending.abandonment.result = source.result;
2347
+ pending.abandonment.attempted = true;
2348
+ try {
2349
+ await retireCancellationChooser(id, pending, isCurrent);
2350
+ }
2351
+ catch (error) {
2352
+ deps.recordRuntimeEvent?.("routing", error, { phase: "recovered-chooser-cleanup" });
2353
+ }
2354
+ }
2355
+ await renderCancellationReview(activeView, isCurrent);
2356
+ if (isCurrent())
2357
+ await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
761
2358
  });
762
2359
  }
2360
+ }
2361
+ catch (error) {
2362
+ deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation-recovery" });
2363
+ if (current())
2364
+ await deps.answerCallbackQuery(query.id, selected?.result
2365
+ ? "⚠️ Routing cancelled; view update failed. Retry the same button."
2366
+ : "⚠️ Recovery incomplete; inputs stay protected. Check /telegram-status --debug.");
2367
+ }
2368
+ finally {
2369
+ if (view)
2370
+ view.running = false;
2371
+ }
2372
+ return true;
2373
+ };
2374
+ const handleUnboundRerouteCancelCallback = async (query, ctx) => {
2375
+ if (!query.data?.startsWith(TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX))
2376
+ return false;
2377
+ const rerouteId = query.data.match(/^reroutecancel:([a-z0-9]+)$/)?.[1];
2378
+ const pending = rerouteId ? pendingUnboundReroutes.get(rerouteId) : undefined;
2379
+ const cancellation = pending?.abandonment;
2380
+ const source = pending?.messages[0];
2381
+ const execution = Updates.getTelegramUpdateExecutionFence(query);
2382
+ const sourceExecution = Updates.getTelegramUpdateExecutionFence(source);
2383
+ const isAuthorityCurrent = () => !!cancellation && execution?.isCurrent() === true &&
2384
+ sourceExecution?.signal.aborted === false && deps.isContextActive?.(ctx) === true &&
2385
+ query.from.id === cancellation.ownerUserId && !query.from.is_bot &&
2386
+ deps.configStore.getAllowedUserId() === cancellation.ownerUserId &&
2387
+ deps.getAdmissionJournalBinding?.() === cancellation.journalBindingKey &&
2388
+ deps.getCurrentLeaderEpoch?.() === cancellation.leaderEpoch;
2389
+ const isCurrent = () => isAuthorityCurrent() && !!pending && !!cancellation && !!source &&
2390
+ pendingUnboundReroutes.get(rerouteId) === pending && pending.abandonment === cancellation &&
2391
+ matchesRerouteChooser(pending, query) && query.message?.chat.type === "private" &&
2392
+ source.from?.id === cancellation.ownerUserId &&
2393
+ pending.messages.length === 1 &&
2394
+ !pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching &&
2395
+ !pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage;
2396
+ if (!isCurrent() || !deps.runWorkspaceOperation || !deps.editInteractiveMessage) {
2397
+ // A chooser from a previous process (new world) or an expired one simply reads as expired.
2398
+ await deps.answerCallbackQuery(query.id, pending ? "🚫 Message route is unavailable for cancellation." : TELEGRAM_ROUTING_CHOICE_EXPIRED);
763
2399
  return true;
764
- });
765
- return pending.messages.length === 0;
2400
+ }
2401
+ if (cancellation.running) {
2402
+ await deps.answerCallbackQuery(query.id, "⏳ Cancellation is already in progress.");
2403
+ return true;
2404
+ }
2405
+ cancellation.running = true;
2406
+ try {
2407
+ await deps.runWorkspaceOperation({ operationId: `workspace-reroute-cancel:${query.id}`,
2408
+ operationKind: "workspace.cancel-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
2409
+ if (!isCurrent())
2410
+ return;
2411
+ if (!cancellation.result) {
2412
+ const wasAttempted = cancellation.attempted;
2413
+ cancellation.attempted = true;
2414
+ const result = Updates.abandonTelegramDeferredUpdate(source, {
2415
+ operatorAuthorityId: `telegram-owner:${cancellation.ownerUserId}`, isCurrent,
2416
+ });
2417
+ if (!result) {
2418
+ cancellation.attempted = wasAttempted;
2419
+ await deps.answerCallbackQuery(query.id, "🚫 Message routing cannot be cancelled now.");
2420
+ return;
2421
+ }
2422
+ cancellation.result = result;
2423
+ }
2424
+ if (!isCurrent())
2425
+ return;
2426
+ if (!recordCancelledTemporaryThreadInput(pending, ctx, isCurrent)) {
2427
+ if (isAuthorityCurrent())
2428
+ await deps.answerCallbackQuery(query.id, "⚠️ Routing cancelled; Thread cleanup remains protected. Retry Cancel routing.");
2429
+ return;
2430
+ }
2431
+ if (await retireCancellationChooser(rerouteId, pending, isCurrent) && isAuthorityCurrent()) {
2432
+ scheduleTemporaryThreadCleanup(pending.sourceTarget, ctx);
2433
+ await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
2434
+ }
2435
+ });
2436
+ }
2437
+ catch (error) {
2438
+ deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation", rerouteId });
2439
+ if (isCurrent())
2440
+ await deps.answerCallbackQuery(query.id, cancellation.result ? "⚠️ Routing cancelled; chooser update failed. Retry Cancel routing." :
2441
+ "⚠️ Cancellation incomplete. Retry Cancel routing.");
2442
+ }
2443
+ finally {
2444
+ cancellation.running = false;
2445
+ }
2446
+ return true;
2447
+ };
2448
+ const withPendingRerouteSelection = async (rerouteId, query, operation) => {
2449
+ Updates.assertTelegramUpdateExecutionCurrent(query);
2450
+ const pending = pendingUnboundReroutes.get(rerouteId);
2451
+ if (pending?.foreignForwardIssued && matchesRerouteChooser(pending, query)) {
2452
+ await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
2453
+ return true;
2454
+ }
2455
+ if (pending && matchesRerouteChooser(pending, query) &&
2456
+ (pending.abandonment?.attempted || pending.abandonment?.running || pending.messages.some(message => Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === false))) {
2457
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
2458
+ return true;
2459
+ }
2460
+ const releases = [];
2461
+ try {
2462
+ if (pending && matchesRerouteChooser(pending, query)) {
2463
+ try {
2464
+ for (const message of pending.messages)
2465
+ releases.push(Updates.acquireTelegramUpdateRouting(message));
2466
+ }
2467
+ catch (error) {
2468
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
2469
+ await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
2470
+ return true;
2471
+ }
2472
+ }
2473
+ return await operation();
2474
+ }
2475
+ finally {
2476
+ for (const release of releases)
2477
+ release();
2478
+ }
766
2479
  };
767
- const handleUnboundRerouteRestoreMenuCallback = async (query, _ctx) => {
2480
+ const executeUnboundRerouteRestoreMenuCallback = async (query, _ctx) => {
768
2481
  const parsed = parseTelegramUnboundRerouteRestoreMenuCallbackData(query.data);
769
2482
  if (!parsed)
770
2483
  return false;
@@ -778,28 +2491,47 @@ export function createTelegramInboundRouteRuntime(deps) {
778
2491
  !deps.threadStore ||
779
2492
  !pending ||
780
2493
  !matchesRerouteChooser(pending, query) ||
2494
+ (parsed.root && pending.rootChooserText === undefined) ||
781
2495
  expirePendingCommand(parsed.rerouteId, pending)) {
782
- await deps.answerCallbackQuery(query.id, "Message route expired.");
2496
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
783
2497
  return true;
784
2498
  }
785
- if (pending.sourceTarget.threadId === undefined) {
2499
+ if (parsed.restore && pending.sourceTarget.threadId === undefined) {
786
2500
  await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
787
2501
  return true;
788
2502
  }
789
2503
  await deps.threadStore.load();
790
2504
  assertExecutionCurrent();
791
2505
  const activeRecords = getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.());
792
- const replyMarkup = buildTelegramUnboundRerouteRestoreChooserMarkup(parsed.rerouteId, activeRecords, deps.getDisplayTitle);
2506
+ const replyMarkup = parsed.root
2507
+ ? buildTelegramUnboundRerouteChooserMarkup(parsed.rerouteId, activeRecords, {
2508
+ canRestore: pending.sourceTarget.threadId !== undefined, canCancel: !!pending.abandonment,
2509
+ getDisplayTitle: deps.getDisplayTitle,
2510
+ })
2511
+ : parsed.restore
2512
+ ? buildTelegramUnboundRerouteRestoreChooserMarkup(parsed.rerouteId, activeRecords, deps.getDisplayTitle)
2513
+ : { inline_keyboard: activeRecords.map(record => [{
2514
+ text: `↪️ ${getTelegramThreadRecordLabel(record, deps.getDisplayTitle)}`,
2515
+ callback_data: formatTelegramUnboundRerouteCallbackData(parsed.rerouteId, record.target.threadId),
2516
+ }]) };
2517
+ const chooserText = parsed.root
2518
+ ? pending.rootChooserText
2519
+ : parsed.restore ? formatTelegramUnboundRerouteRestoreChooserText()
2520
+ : "<b>🧵 Reroute:</b>\n\nChoose the thread that should handle this input:";
2521
+ if (!parsed.root)
2522
+ replyMarkup.inline_keyboard.unshift([{
2523
+ text: "⬆️ Back", callback_data: `${TELEGRAM_UNBOUND_REROUTE_ROOT_CALLBACK_PREFIX}${parsed.rerouteId}`,
2524
+ }]);
793
2525
  if (deps.editInteractiveMessage) {
794
- await deps.editInteractiveMessage(chatId, messageId, formatTelegramUnboundRerouteRestoreChooserText(), "html", replyMarkup);
2526
+ await deps.editInteractiveMessage(chatId, messageId, chooserText, "html", replyMarkup);
795
2527
  }
796
2528
  else if (deps.sendInteractiveMessage) {
797
- const chooserId = await deps.sendInteractiveMessage(chatId, formatTelegramUnboundRerouteRestoreChooserText(), "html", replyMarkup, { target: pending.sourceTarget, replyToMessageId: messageId });
2529
+ const chooserId = await deps.sendInteractiveMessage(chatId, chooserText, "html", replyMarkup, { target: pending.sourceTarget, replyToMessageId: messageId });
798
2530
  assertExecutionCurrent();
799
2531
  rememberRerouteChooser(parsed.rerouteId, chooserId);
800
2532
  }
801
2533
  assertExecutionCurrent();
802
- await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
2534
+ await deps.answerCallbackQuery(query.id, parsed.root ? "Choose routing mode." : parsed.restore ? "Choose instance to restore." : "Choose target thread.");
803
2535
  return true;
804
2536
  };
805
2537
  const executeUnboundRerouteCallbackOperation = async (query, ctx) => {
@@ -812,25 +2544,31 @@ export function createTelegramInboundRouteRuntime(deps) {
812
2544
  const pending = pendingUnboundReroutes.get(parsed.rerouteId);
813
2545
  if (typeof chatId !== "number" || !deps.threadStore || !pending ||
814
2546
  !matchesRerouteChooser(pending, query) || expirePendingCommand(parsed.rerouteId, pending)) {
815
- await deps.answerCallbackQuery(query.id, "Message route expired.");
2547
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
816
2548
  return true;
817
2549
  }
818
2550
  await deps.threadStore.load();
819
2551
  assertExecutionCurrent();
820
2552
  if (pendingUnboundReroutes.get(parsed.rerouteId) !== pending ||
821
2553
  expirePendingCommand(parsed.rerouteId, pending)) {
822
- await deps.answerCallbackQuery(query.id, "Message route expired.");
2554
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
823
2555
  return true;
824
2556
  }
825
2557
  if (pending.finalizeMessage) {
826
2558
  await finalizePendingReroute(parsed.rerouteId, pending, query, pending.finalizeMessage, assertExecutionCurrent);
827
2559
  return true;
828
2560
  }
2561
+ if (pending.workspaceRestore && (!restoreWorkspace || !parsed.useNewSlot ||
2562
+ pending.workspaceRestore.record.target.threadId !== parsed.threadId)) {
2563
+ await deps.answerCallbackQuery(query.id, "🚫 Restore is already selected. Use the original Restore choice.");
2564
+ return true;
2565
+ }
829
2566
  if (pending.foreignRetry) {
830
2567
  const retry = pending.foreignRetry;
831
2568
  const allForwarded = await forwardPendingRerouteMessages(pending, retry.instanceId, retry.threadId, ctx, assertExecutionCurrent);
832
2569
  if (!allForwarded) {
833
- await deps.answerCallbackQuery(query.id, "Target thread is unavailable; retrying will send only remaining messages.");
2570
+ await deps.answerCallbackQuery(query.id, pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
2571
+ "Target thread is unavailable; retrying will send only remaining messages.");
834
2572
  return true;
835
2573
  }
836
2574
  pending.foreignRetry = undefined;
@@ -847,7 +2585,11 @@ export function createTelegramInboundRouteRuntime(deps) {
847
2585
  await finalizePendingReroute(parsed.rerouteId, pending, query, "Thread cleanup completed.", assertExecutionCurrent);
848
2586
  return true;
849
2587
  }
850
- const record = getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).find((candidate) => candidate.target.chatId === chatId &&
2588
+ if (!pending.workspaceRestore && isTemporaryReroute(pending) && isTemporaryThreadForwardIssued(pending)) {
2589
+ await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
2590
+ return true;
2591
+ }
2592
+ const record = pending.workspaceRestore?.record ?? getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).find((candidate) => candidate.target.chatId === chatId &&
851
2593
  candidate.target.threadId === parsed.threadId);
852
2594
  if (!record) {
853
2595
  await deps.answerCallbackQuery(query.id, "Thread is not active yet.");
@@ -867,113 +2609,120 @@ export function createTelegramInboundRouteRuntime(deps) {
867
2609
  await deps.answerCallbackQuery(query.id, "🚫 Selected thread is already the destination.");
868
2610
  return true;
869
2611
  }
870
- if (parsed.useNewSlot && sourceTarget && isRerouteTargetProtected(sourceTarget)) {
2612
+ // A source's own temporary tab is its Restore destination; only other protection refuses it.
2613
+ if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
2614
+ isRerouteTargetProtected(sourceTarget, undefined, pending.temporaryThread ?? findCreatedTemporaryThread(sourceTarget), pending)) {
871
2615
  await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
872
2616
  return true;
873
2617
  }
874
- pending.destinationSelected = true;
875
- pending.selectionAttempted = true;
876
- pending.pauseExpiry?.();
2618
+ // Restore has one controller. Without it or its negotiated authority, refuse before selection so the chooser keeps
2619
+ // its other routes and Cancel.
2620
+ if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
2621
+ (!restoreWorkspace || deps.hasWorkspaceRestoreAuthority?.() !== true)) {
2622
+ await deps.answerCallbackQuery(query.id, "🚫 Thread restore is unavailable right now. The message stays pending; choose another route or cancel.");
2623
+ return true;
2624
+ }
877
2625
  const currentInstanceId = deps.getCurrentInstanceId?.();
878
2626
  const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
879
2627
  const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(record, leaderProfileKey, currentInstanceId);
880
- if (parsed.useNewSlot && !isCurrentLeaderRecord) {
881
- if (!record.slot || !/^[A-Z]$/u.test(record.slot)) {
882
- await deps.answerCallbackQuery(query.id, "Follower thread slot authority is unavailable.");
883
- return true;
884
- }
885
- if (!sourceTarget ||
886
- !deps.replaceFollowerThreadTarget ||
887
- !deps.foreignOwnedUpdateForwarder?.forwardMessage) {
888
- await deps.answerCallbackQuery(query.id, "Follower thread restore is not available yet.");
889
- return true;
890
- }
891
- assertExecutionCurrent();
892
- const replaced = await deps.replaceFollowerThreadTarget({
893
- record,
894
- target: sourceTarget,
895
- oldTarget: record.target,
896
- });
897
- assertExecutionCurrent();
898
- if (!replaced) {
899
- await deps.answerCallbackQuery(query.id, "Follower thread is unavailable.");
900
- return true;
2628
+ if (!parsed.useNewSlot && record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord &&
2629
+ !deps.foreignOwnedUpdateForwarder?.forwardMessage) {
2630
+ if (pending.routingOperatorUserId === undefined) {
2631
+ pending.destinationSelected = true;
2632
+ pending.selectionAttempted = true;
2633
+ pending.pauseExpiry?.();
901
2634
  }
902
- const nowMs = Date.now();
903
- const slot = record.slot;
904
- const threadName = getRestoredThreadName(record, slot);
905
- assertExecutionCurrent();
906
- deps.threadStore.markStaleByTarget(record.target, "deleted", "Follower thread was replaced by restore source.");
907
- deps.threadStore.upsert({
908
- ...record,
909
- target: sourceTarget,
910
- status: "active",
911
- syncStatus: "open",
912
- updatedAtMs: nowMs,
913
- threadName,
914
- lastSyncObservedAtMs: nowMs,
915
- lastReconcileAction: "follower-thread-restore",
916
- rerouteConfirmedAtMs: nowMs,
917
- });
918
- await deps.threadStore.persist();
919
- assertExecutionCurrent();
920
- if (deps.callApi) {
2635
+ await deps.answerCallbackQuery(query.id, "Open that thread and resend the message there.");
2636
+ return true;
2637
+ }
2638
+ if (pending.routingOperatorUserId !== undefined && (pending.routingOperatorUserId !== query.from.id ||
2639
+ pending.routingOperatorUserId !== deps.configStore.getAllowedUserId())) {
2640
+ await deps.answerCallbackQuery(query.id, "🚫 Message route authority changed.");
2641
+ return true;
2642
+ }
2643
+ // Freeze only a validated actual destination, inside the same admission as its effect. Menu browsing,
2644
+ // unavailable recipients and refused Restore capability never turn a waiting source into a selected grant.
2645
+ try {
2646
+ const release = Updates.acquireTelegramUpdateRouting(pending.messages[0], true, Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages));
2647
+ release();
2648
+ }
2649
+ catch (error) {
2650
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
2651
+ await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
2652
+ return true;
2653
+ }
2654
+ pending.destinationSelected = true;
2655
+ pending.selectionAttempted = true;
2656
+ pending.pauseExpiry?.();
2657
+ if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
2658
+ const selection = pending.workspaceRestore ??= { operationId: `restore-${randomBytes(16).toString("hex")}`,
2659
+ record: structuredClone(record), messages: [...pending.messages] };
2660
+ let active = true;
2661
+ const ownerUserId = deps.configStore.getAllowedUserId();
2662
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
2663
+ const admissionScope = deps.getAdmissionScope?.();
2664
+ const journalBinding = deps.getAdmissionJournalBinding?.();
2665
+ const selectionCurrent = () => {
921
2666
  try {
922
2667
  assertExecutionCurrent();
923
- await deps.callApi("editForumTopic", {
924
- chat_id: sourceTarget.chatId,
925
- message_thread_id: sourceTarget.threadId,
926
- name: deps.getDisplayTitle?.(sourceTarget) ??
927
- Threads.getTelegramTopicTitleForThreadName(threadName, slot),
928
- });
929
- assertExecutionCurrent();
930
2668
  }
931
- catch (renameError) {
932
- deps.recordRuntimeEvent?.("telegram", renameError, {
933
- phase: "follower-topic-reroute-restore-rename",
934
- chatId: sourceTarget.chatId,
935
- threadId: sourceTarget.threadId,
936
- slot: record.slot,
937
- });
2669
+ catch {
2670
+ return false;
938
2671
  }
939
- }
940
- const cleanup = {
941
- kind: "replaced-follower",
942
- target: record.target,
943
- instanceId: record.instanceId,
2672
+ return ownerUserId === query.from.id && deps.configStore.getAllowedUserId() === ownerUserId &&
2673
+ deps.getCurrentLeaderEpoch?.() === leaderEpoch && deps.getAdmissionScope?.() === admissionScope &&
2674
+ deps.getAdmissionJournalBinding?.() === journalBinding && deps.isContextActive?.(ctx) !== false &&
2675
+ pendingUnboundReroutes.get(parsed.rerouteId) === pending && pending.workspaceRestore === selection;
944
2676
  };
945
- const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, sourceTarget.threadId, ctx, assertExecutionCurrent);
946
- if (!allForwarded) {
947
- pending.foreignRetry = {
948
- instanceId: record.instanceId,
949
- threadId: sourceTarget.threadId,
950
- cleanup,
951
- };
952
- await deps.answerCallbackQuery(query.id, "Thread restored; retrying will send only remaining messages before old-thread cleanup.");
953
- return true;
2677
+ const current = () => active && selectionCurrent();
2678
+ const assertRestoreCurrent = () => {
2679
+ assertExecutionCurrent();
2680
+ if (!current())
2681
+ throw new Error("Stale Workspace Restore callback.");
2682
+ };
2683
+ try {
2684
+ await restoreWorkspace({ operationId: selection.operationId, record: structuredClone(selection.record),
2685
+ target: { ...sourceTarget }, messages: [...selection.messages], ctx, isCurrent: current,
2686
+ async dispatch(recipient, isRecipientCurrent, recordForwardAcceptance, recordLocalAcceptance) {
2687
+ const assertRecipientCurrent = () => {
2688
+ assertRestoreCurrent();
2689
+ if (!isRecipientCurrent())
2690
+ throw new Error("Workspace Restore recipient authority changed.");
2691
+ };
2692
+ assertRecipientCurrent();
2693
+ if (recipient.kind === "leader") {
2694
+ if (recipient.instanceId !== currentInstanceId)
2695
+ return false;
2696
+ const routed = cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId);
2697
+ const messages = pending.dispatchKind === "command" ? routed.map((message, index) => Updates.bindTelegramUpdateCompletionAcceptance(message, () => recordLocalAcceptance(pending.messages[index]))) : routed;
2698
+ await dispatchPendingRerouteMessages(pending, messages, ctx);
2699
+ assertRecipientCurrent();
2700
+ return true;
2701
+ }
2702
+ return forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
2703
+ },
2704
+ });
954
2705
  }
955
- const cleanupComplete = await retryPendingRerouteCleanup(cleanup, assertExecutionCurrent);
956
- if (!cleanupComplete) {
957
- pending.cleanup = cleanup;
958
- await deps.answerCallbackQuery(query.id, "Thread restored, but old-thread cleanup is still pending. Try again.");
959
- return true;
2706
+ finally {
2707
+ active = false;
960
2708
  }
961
- await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
2709
+ assertExecutionCurrent();
2710
+ if (!selectionCurrent())
2711
+ return true;
2712
+ await deps.answerCallbackQuery(query.id, "🚫 Restore is pending; this button will not resend uncertain input.");
962
2713
  return true;
963
2714
  }
964
2715
  if (record.instanceId &&
965
2716
  record.instanceId !== currentInstanceId &&
966
2717
  !isCurrentLeaderRecord) {
967
- if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
968
- await deps.answerCallbackQuery(query.id, "Open that thread and resend the message there.");
969
- return true;
970
- }
971
2718
  const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertExecutionCurrent);
972
2719
  if (!allForwarded) {
973
- await deps.answerCallbackQuery(query.id, "Target thread is unavailable; retrying will send only remaining messages.");
2720
+ await deps.answerCallbackQuery(query.id, pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
2721
+ "Target thread is unavailable; retrying will send only remaining messages.");
974
2722
  return true;
975
2723
  }
976
- const cleanupComplete = await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent);
2724
+ // A temporary tab is removed only after every input in it resolves, never by one Forward.
2725
+ const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent, pending.temporaryThread);
977
2726
  if (!cleanupComplete && sourceTarget) {
978
2727
  pending.cleanup = {
979
2728
  kind: "unbound",
@@ -981,88 +2730,25 @@ export function createTelegramInboundRouteRuntime(deps) {
981
2730
  ...(typeof sourceMessageId === "number"
982
2731
  ? { messageId: sourceMessageId }
983
2732
  : {}),
2733
+ ...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
984
2734
  };
985
2735
  await deps.answerCallbackQuery(query.id, "Message routed, but thread cleanup is still pending. Try again.");
986
2736
  return true;
987
2737
  }
988
2738
  await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
2739
+ if (sourceTarget && isTemporaryTabTarget(sourceTarget))
2740
+ scheduleTemporaryThreadCleanup(sourceTarget, ctx);
989
2741
  return true;
990
2742
  }
991
- if (sourceTarget &&
992
- isCurrentLeaderRecord &&
993
- parsed.useNewSlot &&
994
- (record.target.chatId !== sourceTarget.chatId ||
995
- record.target.threadId !== sourceTarget.threadId)) {
996
- const slot = deps.threadStore.allocateSlot(leaderProfileKey ?? record.profileKey, getNextTelegramSlotPreference(record.slot), undefined, { excludeCurrentRecord: true });
997
- if (!slot) {
998
- await deps.answerCallbackQuery(query.id, TELEGRAM_SLOT_CAPACITY_MESSAGE);
999
- return true;
1000
- }
1001
- deps.threadStore.markStaleByTarget(record.target, "deleted", "Current leader thread was replaced by a new-slot reroute source.");
1002
- const nowMs = Date.now();
1003
- const threadName = getRestoredThreadName(record, slot);
1004
- deps.threadStore.upsert({
1005
- ...record,
1006
- target: sourceTarget,
1007
- status: "active",
1008
- updatedAtMs: nowMs,
1009
- threadName,
1010
- instanceId: currentInstanceId,
1011
- slot,
1012
- lastReconcileAction: "reroute-new-slot",
1013
- rerouteConfirmedAtMs: nowMs,
1014
- });
1015
- await deps.threadStore.persist();
1016
- assertExecutionCurrent();
1017
- deps.setCurrentLeaderIdentity?.({
1018
- target: sourceTarget,
1019
- slot,
1020
- threadName,
1021
- });
1022
- if (deps.callApi) {
1023
- try {
1024
- assertExecutionCurrent();
1025
- await deps.callApi("editForumTopic", {
1026
- chat_id: sourceTarget.chatId,
1027
- message_thread_id: sourceTarget.threadId,
1028
- name: deps.getDisplayTitle?.(sourceTarget) ??
1029
- Threads.getTelegramTopicTitleForThreadName(threadName, slot),
1030
- });
1031
- assertExecutionCurrent();
1032
- }
1033
- catch (renameError) {
1034
- deps.recordRuntimeEvent?.("telegram", renameError, {
1035
- phase: "leader-topic-reroute-reclaim-rename",
1036
- chatId: sourceTarget.chatId,
1037
- threadId: sourceTarget.threadId,
1038
- slot,
1039
- });
1040
- }
1041
- }
1042
- deps.recordRuntimeEvent?.("bus", "Bus leader reclaimed reroute source thread", {
1043
- phase: "leader-topic-reroute-reclaim",
1044
- chatId: sourceTarget.chatId,
1045
- threadId: sourceTarget.threadId,
1046
- staleThreadId: record.target.threadId,
1047
- slot,
1048
- });
1049
- await dispatchPendingRerouteMessages(pending, cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId), ctx);
1050
- pending.messages = [];
1051
- const cleanupComplete = await closePreviousLeaderThread(record.target, assertExecutionCurrent);
1052
- if (!cleanupComplete) {
1053
- pending.cleanup = {
1054
- kind: "previous-leader",
1055
- target: record.target,
1056
- };
1057
- await deps.answerCallbackQuery(query.id, "Thread restored, but old-thread cleanup is still pending. Try again.");
1058
- return true;
1059
- }
1060
- await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
2743
+ // Local handlers and queue admission consume the same one-time group grant as follower forwarding.
2744
+ if (isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
2745
+ await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
1061
2746
  return true;
1062
2747
  }
1063
2748
  await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
1064
2749
  pending.messages = [];
1065
- const cleanupComplete = await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent);
2750
+ // A temporary tab is removed only after every input in it resolves, never by one Forward.
2751
+ const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent, pending.temporaryThread);
1066
2752
  if (!cleanupComplete && sourceTarget) {
1067
2753
  pending.cleanup = {
1068
2754
  kind: "unbound",
@@ -1070,29 +2756,35 @@ export function createTelegramInboundRouteRuntime(deps) {
1070
2756
  ...(typeof sourceMessageId === "number"
1071
2757
  ? { messageId: sourceMessageId }
1072
2758
  : {}),
2759
+ ...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
1073
2760
  };
1074
2761
  await deps.answerCallbackQuery(query.id, "Message routed, but thread cleanup is still pending. Try again.");
1075
2762
  return true;
1076
2763
  }
1077
2764
  await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
2765
+ if (sourceTarget && isTemporaryTabTarget(sourceTarget))
2766
+ scheduleTemporaryThreadCleanup(sourceTarget, ctx);
1078
2767
  return true;
1079
2768
  };
1080
- const executeUnboundRerouteCallback = (query, ctx) => {
1081
- if (!parseTelegramUnboundRerouteCallbackData(query.data) ||
1082
- !deps.runWorkspaceOperation) {
1083
- return executeUnboundRerouteCallbackOperation(query, ctx);
1084
- }
1085
- return deps.runWorkspaceOperation({
1086
- operationId: `workspace-reroute:${query.id}`,
1087
- operationKind: "workspace.route-unbound-thread",
1088
- scopes: [{ kind: "profile" }],
1089
- }, () => executeUnboundRerouteCallbackOperation(query, ctx));
2769
+ const executeUnboundRerouteCallback = async (query, ctx) => {
2770
+ const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
2771
+ if (!parsed)
2772
+ return false;
2773
+ return withPendingRerouteSelection(parsed.rerouteId, query, () => {
2774
+ if (!deps.runWorkspaceOperation)
2775
+ return executeUnboundRerouteCallbackOperation(query, ctx);
2776
+ return deps.runWorkspaceOperation({
2777
+ operationId: `workspace-reroute:${query.id}`,
2778
+ operationKind: "workspace.route-unbound-thread",
2779
+ scopes: [{ kind: "profile" }],
2780
+ }, () => executeUnboundRerouteCallbackOperation(query, ctx));
2781
+ });
1090
2782
  };
1091
2783
  const handleUnboundRerouteCallback = async (query, ctx) => {
1092
2784
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
1093
2785
  const pending = parsed && pendingUnboundReroutes.get(parsed.rerouteId);
1094
2786
  if (!parsed || !pending || pending.dispatchKind !== "command" ||
1095
- pending.sourceTarget.threadId !== undefined || !matchesRerouteChooser(pending, query)) {
2787
+ (pending.sourceTarget.threadId !== undefined && !pending.temporaryThread) || !matchesRerouteChooser(pending, query)) {
1096
2788
  return executeUnboundRerouteCallback(query, ctx);
1097
2789
  }
1098
2790
  if (pending.dispatching) {
@@ -1116,7 +2808,12 @@ export function createTelegramInboundRouteRuntime(deps) {
1116
2808
  const callbackHandler = async (query, ctx) => {
1117
2809
  const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query);
1118
2810
  assertExecutionCurrent();
1119
- if (await handleUnboundRerouteRestoreMenuCallback(query, ctx))
2811
+ if (await handleCancellationReview(query, ctx))
2812
+ return;
2813
+ if (await handleUnboundRerouteCancelCallback(query, ctx))
2814
+ return;
2815
+ const restore = parseTelegramUnboundRerouteRestoreMenuCallbackData(query.data);
2816
+ if (restore && await withPendingRerouteSelection(restore.rerouteId, query, () => executeUnboundRerouteRestoreMenuCallback(query, ctx)))
1120
2817
  return;
1121
2818
  if (await handleUnboundRerouteCallback(query, ctx))
1122
2819
  return;
@@ -1514,7 +3211,59 @@ export function createTelegramInboundRouteRuntime(deps) {
1514
3211
  reportQueueAdmission(messages, turn.admissionReceipts ?? []);
1515
3212
  });
1516
3213
  };
1517
- const sendUnboundRerouteChooserNow = async (messages, _ctx, reportDeferred = true) => {
3214
+ const recordUnboundTemporaryThreadInput = async (messages, ctx) => {
3215
+ const message = messages[0], target = message && Updates.getTelegramMessageTarget(message);
3216
+ const store = deps.getWorkspaceRestoreStore?.();
3217
+ if (!target?.threadId || !store)
3218
+ return false;
3219
+ let found = store.listTemporaryThreads().find(entry => entry.phase === "created" && entry.target &&
3220
+ entry.target.chatId === target.chatId && entry.target.threadId === target.threadId);
3221
+ const key = formatTelegramTargetKey(target), implicit = implicitThreadCreations.get(key);
3222
+ if (!found && !implicit)
3223
+ return false;
3224
+ const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(messages);
3225
+ const operatorUserId = deps.configStore.getAllowedUserId();
3226
+ const cap = captureTemporaryThreadAuthority(ctx, () => messages.every(source => Updates.getTelegramUpdateExecutionFence(source)?.isCurrent() === true &&
3227
+ source.chat.type === "private" && source.chat.id === target.chatId && source.message_thread_id === target.threadId &&
3228
+ source.from?.id === operatorUserId && source.from?.is_bot === false));
3229
+ if (!found && implicit) {
3230
+ if (updateIds.length !== 1) {
3231
+ implicitThreadCreations.delete(key);
3232
+ return false;
3233
+ }
3234
+ if (!deps.runWorkspaceOperation || !cap || implicit.ctx !== ctx || implicit.operatorUserId !== cap.operatorUserId ||
3235
+ implicit.journalBindingKey !== cap.journalBindingKey || !isDeepStrictEqual(implicit.executor, cap.authority.executor) ||
3236
+ implicit.generation !== deps.getSessionGeneration?.() || implicit.scope !== deps.getAdmissionScope?.() ||
3237
+ !updateIds.length || updateIds.some(id => id <= implicit.updateId) || !cap.isCurrent()) {
3238
+ implicitThreadCreations.delete(key);
3239
+ return false;
3240
+ }
3241
+ found = store.registerImplicitTemporaryThread({ journalBindingKey: cap.journalBindingKey, updateIds }, { chatId: target.chatId, threadId: target.threadId }, randomBytes(16).toString("hex"), {
3242
+ ...cap.authority, isCurrent: () => cap.isCurrent() && implicitThreadCreations.get(key) === implicit,
3243
+ });
3244
+ if (!found || !cap.isCurrent())
3245
+ throw new Error("Implicit Telegram Thread registration was not acknowledged; source remains protected.");
3246
+ implicitThreadCreations.delete(key);
3247
+ }
3248
+ if (!found)
3249
+ return false;
3250
+ cancelTemporaryThreadCleanup(found.token);
3251
+ if (!deps.runWorkspaceOperation || !cap || cap.operatorUserId !== found.operatorUserId ||
3252
+ cap.journalBindingKey !== found.source.journalBindingKey || !updateIds.length || !cap.isCurrent()) {
3253
+ throw new Error("Temporary Thread input membership requires exact current source authority.");
3254
+ }
3255
+ // The caller already holds the profile Workspace admission (the non-reentrant gate): no nested operation here.
3256
+ let entry = store.listTemporaryThreads().find(value => value.token === found.token &&
3257
+ isDeepStrictEqual(value.source, found.source) && isDeepStrictEqual(value.target, target));
3258
+ if (!entry)
3259
+ throw new Error("Temporary Thread input target changed before membership publication.");
3260
+ entry = cap.adopt(entry);
3261
+ if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInput(entry, { journalBindingKey: cap.journalBindingKey, updateIds }, cap.authority) || !cap.isCurrent()) {
3262
+ throw new Error("Temporary Thread input membership was not acknowledged; the source remains protected.");
3263
+ }
3264
+ return true;
3265
+ };
3266
+ const sendUnboundRerouteChooserNow = async (messages, ctx, reportDeferred = true) => {
1518
3267
  const message = messages[0];
1519
3268
  if (!message || !deps.threadStore)
1520
3269
  return;
@@ -1540,16 +3289,46 @@ export function createTelegramInboundRouteRuntime(deps) {
1540
3289
  .join("\n\n"), { parseMode: "HTML", target: sourceTarget });
1541
3290
  return;
1542
3291
  }
1543
- const rerouteId = storePendingUnboundReroute(messages);
3292
+ const command = messages.length === 1 ? getKnownTelegramAllTabCommand(Media.extractFirstTelegramMessageText(messages).trim()) : undefined;
3293
+ const rerouteId = storePendingUnboundReroute(messages, command ? "command" : "prompt");
1544
3294
  if (reportDeferred) {
1545
3295
  for (const source of messages) {
1546
3296
  Updates.reportTelegramUpdateDeferred(source);
1547
3297
  }
1548
3298
  }
1549
- const text = formatTelegramUnboundRerouteChooserText(activeRecords, {
1550
- includeGuidance,
1551
- });
1552
- const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, { canRestore: sourceTarget !== undefined, getDisplayTitle: deps.getDisplayTitle });
3299
+ const inTemporaryThread = await recordUnboundTemporaryThreadInput(messages, ctx);
3300
+ const pending = pendingUnboundReroutes.get(rerouteId);
3301
+ if (inTemporaryThread)
3302
+ pending.temporaryMembership = true;
3303
+ if (messages.length === 1 && sourceTarget &&
3304
+ message.chat.type === "private" && typeof message.text === "string" && message.text.trim() &&
3305
+ deps.runWorkspaceOperation && deps.editInteractiveMessage &&
3306
+ deps.getAdmissionJournalBinding && deps.getCurrentLeaderEpoch &&
3307
+ Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true && deps.isContextActive?.(ctx) === true) {
3308
+ const ownerUserId = deps.configStore.getAllowedUserId();
3309
+ const journalBindingKey = deps.getAdmissionJournalBinding();
3310
+ if (ownerUserId !== undefined && message.from?.id === ownerUserId && journalBindingKey &&
3311
+ Updates.supportsTelegramDeferredAbandonment(message, journalBindingKey)) {
3312
+ const leaderEpoch = deps.getCurrentLeaderEpoch();
3313
+ if (leaderEpoch !== undefined)
3314
+ pending.abandonment = { ownerUserId, journalBindingKey, leaderEpoch };
3315
+ }
3316
+ }
3317
+ if (command && sourceTarget && !inTemporaryThread && includeGuidance && deps.callApi) {
3318
+ // Telegram can create a private tab before delivering an All command not listed in the bot menu.
3319
+ // Naming that unbound tab is not creation evidence or authority to delete it.
3320
+ Updates.createTelegramUpdateExecutionFenceGuard(message)();
3321
+ try {
3322
+ await deps.callApi("editForumTopic", { chat_id: sourceTarget.chatId, message_thread_id: sourceTarget.threadId, name: `/${command.name}` });
3323
+ }
3324
+ catch (error) {
3325
+ deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-command-title" });
3326
+ }
3327
+ Updates.createTelegramUpdateExecutionFenceGuard(message)();
3328
+ }
3329
+ const text = formatTelegramTemporaryThreadChooserText(command?.name);
3330
+ pending.rootChooserText = text;
3331
+ const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, { canRestore: sourceTarget !== undefined, canCancel: !!pending.abandonment, getDisplayTitle: deps.getDisplayTitle });
1553
3332
  if (deps.sendInteractiveMessage) {
1554
3333
  const chooserId = await deps.sendInteractiveMessage(message.chat.id, text, "html", replyMarkup, sourceTarget
1555
3334
  ? { target: sourceTarget, replyToMessageId: message.message_id }
@@ -1575,7 +3354,13 @@ export function createTelegramInboundRouteRuntime(deps) {
1575
3354
  const messages = [...(existing?.messages ?? []), message];
1576
3355
  const timer = setTimeout(() => {
1577
3356
  pendingUnboundRerouteMediaGroups.delete(groupKey);
1578
- void sendUnboundRerouteChooserNow(messages, ctx, false);
3357
+ // A media group's timer runs outside any request, so it takes the same profile admission as a single message.
3358
+ const send = () => sendUnboundRerouteChooserNow(messages, ctx, false);
3359
+ const task = deps.runWorkspaceOperation
3360
+ ? deps.runWorkspaceOperation({ operationId: `workspace-unbound-group:${groupKey}:${randomBytes(8).toString("hex")}`,
3361
+ operationKind: "workspace.route-unbound-thread", scopes: [{ kind: "profile" }] }, send)
3362
+ : send();
3363
+ void task.catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-media-group-chooser" }); });
1579
3364
  }, 1200);
1580
3365
  timer.unref?.();
1581
3366
  pendingUnboundRerouteMediaGroups.set(groupKey, { messages, timer });
@@ -1594,6 +3379,84 @@ export function createTelegramInboundRouteRuntime(deps) {
1594
3379
  }
1595
3380
  return undefined;
1596
3381
  };
3382
+ /**
3383
+ * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
3384
+ * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
3385
+ */
3386
+ const sendAllTabTemporaryInputChooser = async (command, commandText, message, ctx) => {
3387
+ const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
3388
+ const execution = Updates.getTelegramUpdateExecutionFence(message);
3389
+ const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
3390
+ if (!cap || !deps.runWorkspaceOperation || !deps.callApi || updateId === undefined ||
3391
+ message.chat.type !== "private" || message.chat.id !== cap.operatorUserId || message.from?.id !== cap.operatorUserId ||
3392
+ !cap.isCurrent())
3393
+ return false;
3394
+ const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
3395
+ const source = { journalBindingKey, updateId };
3396
+ const find = () => store.listTemporaryThreads().find(entry => entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId);
3397
+ let entry;
3398
+ await deps.runWorkspaceOperation({ operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
3399
+ operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
3400
+ if (!current())
3401
+ return;
3402
+ let found = find();
3403
+ found = found && cap.adopt(found);
3404
+ if (found) {
3405
+ entry = found;
3406
+ return;
3407
+ }
3408
+ const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
3409
+ entry = reservation?.entry;
3410
+ if (!reservation?.reserved || !current())
3411
+ return;
3412
+ let created;
3413
+ try {
3414
+ // The single creation attempt; any failure leaves `creating` as an unknown outcome, never a retry.
3415
+ created = await deps.callApi("createForumTopic", { chat_id: operatorUserId, name: command ? `/${command.name}` : "New chat" });
3416
+ }
3417
+ catch (error) {
3418
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-create" });
3419
+ return;
3420
+ }
3421
+ const threadId = created?.message_thread_id;
3422
+ if (!current() || typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0)
3423
+ return;
3424
+ entry = store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority) ?? entry;
3425
+ });
3426
+ if (!current() || !entry)
3427
+ throw new Error("Temporary Thread authority changed; the input remains retryable.");
3428
+ const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
3429
+ ...message, text: commandText, caption: undefined
3430
+ });
3431
+ if (entry.phase !== "created" || !entry.target) {
3432
+ Updates.reportTelegramUpdateDeferred(commandMessage);
3433
+ await deps.sendTextReply(message.chat.id, message.message_id, "<b>⚠️ The routing tab for this command could not be confirmed.</b> It stays held and was not sent to Pi.", { parseMode: "HTML" });
3434
+ return true;
3435
+ }
3436
+ const target = entry.target;
3437
+ const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
3438
+ const rerouteId = storePendingUnboundReroute([commandMessage], command ? "command" : "prompt", target);
3439
+ const pending = pendingUnboundReroutes.get(rerouteId);
3440
+ pending.temporaryThread = entry;
3441
+ Updates.reportTelegramUpdateDeferred(commandMessage);
3442
+ // Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
3443
+ if (deps.editInteractiveMessage && Updates.supportsTelegramDeferredAbandonment(commandMessage, journalBindingKey)) {
3444
+ pending.abandonment = { ownerUserId: operatorUserId, journalBindingKey, leaderEpoch: epoch };
3445
+ }
3446
+ pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
3447
+ try {
3448
+ if (!deps.sendInteractiveMessage)
3449
+ throw new Error("Temporary Thread chooser publication is unavailable.");
3450
+ const chooserId = await deps.sendInteractiveMessage(target.chatId, pending.rootChooserText, "html", buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, { canRestore: true, canCancel: !!pending.abandonment,
3451
+ getDisplayTitle: deps.getDisplayTitle }), { target });
3452
+ rememberRerouteChooser(rerouteId, chooserId);
3453
+ }
3454
+ catch (error) {
3455
+ removePendingReroute(rerouteId);
3456
+ throw error;
3457
+ }
3458
+ return true;
3459
+ };
1597
3460
  const sendAllTabCommandChooser = async (command, commandText, message, options = {}) => {
1598
3461
  if (!deps.threadStore)
1599
3462
  return false;
@@ -1609,6 +3472,7 @@ export function createTelegramInboundRouteRuntime(deps) {
1609
3472
  const rerouteId = storePendingUnboundReroute([commandMessage], "command");
1610
3473
  Updates.reportTelegramUpdateDeferred(commandMessage);
1611
3474
  const text = formatTelegramAllTabMenuChooserText(command.name);
3475
+ pendingUnboundReroutes.get(rerouteId).rootChooserText = text;
1612
3476
  const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, { canRestore: typeof message.message_thread_id === "number", getDisplayTitle: deps.getDisplayTitle });
1613
3477
  let chooserId;
1614
3478
  try {
@@ -1802,11 +3666,26 @@ export function createTelegramInboundRouteRuntime(deps) {
1802
3666
  assertExecutionCurrent();
1803
3667
  await deps.handleTelegramTopicLifecycleUpdate?.(lifecycle, ctx);
1804
3668
  assertExecutionCurrent();
3669
+ const key = formatTelegramTargetKey(lifecycle.target);
1805
3670
  if (lifecycle.kind !== "created" || !deps.threadStore) {
3671
+ implicitThreadCreations.delete(key);
1806
3672
  return;
1807
3673
  }
1808
3674
  await deps.threadStore.load();
1809
3675
  assertExecutionCurrent();
3676
+ const message = lifecycle.message, created = message.forum_topic_created;
3677
+ const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
3678
+ const cap = captureTemporaryThreadAuthority(ctx, () => Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true);
3679
+ if (created && typeof created === "object" && "is_name_implicit" in created && created.is_name_implicit === true &&
3680
+ cap && deps.runWorkspaceOperation && updateId !== undefined && !Updates.isTelegramHistoricalInput(message) &&
3681
+ message.chat.type === "private" && message.chat.id === cap.operatorUserId && message.from?.id === cap.operatorUserId &&
3682
+ message.from.is_bot === false && cap.isCurrent() && implicitThreadCreations.size < 100) {
3683
+ implicitThreadCreations.set(key, { ctx, updateId, operatorUserId: cap.operatorUserId,
3684
+ journalBindingKey: cap.journalBindingKey, executor: structuredClone(cap.authority.executor),
3685
+ generation: deps.getSessionGeneration?.(), scope: deps.getAdmissionScope?.() });
3686
+ }
3687
+ else
3688
+ implicitThreadCreations.delete(key);
1810
3689
  };
1811
3690
  // Answer the guest query immediately so the agent-end edit can replace the
1812
3691
  // early ACK once the turn settles. The ACK is the first placeholder frame and
@@ -1836,7 +3715,8 @@ export function createTelegramInboundRouteRuntime(deps) {
1836
3715
  }
1837
3716
  assertExecutionCurrent();
1838
3717
  }
1839
- const text = guestMessage.text ?? "";
3718
+ // Media messages carry the user's text as a caption.
3719
+ const text = guestMessage.text ?? guestMessage.caption ?? "";
1840
3720
  const gm = guestMessage;
1841
3721
  // Build telegram prefix with guest context
1842
3722
  const chatRaw = gm.chat;
@@ -1878,8 +3758,12 @@ export function createTelegramInboundRouteRuntime(deps) {
1878
3758
  : "";
1879
3759
  // Download files, run inbound handlers
1880
3760
  const guestMsg = guestMessage;
3761
+ // Guest message IDs belong to the peer's chat; scope files to that peer so they cannot collide with bot-chat files.
3762
+ const guestScope = resolveTelegramGuestFileScope({ chatType, chat: chatRaw, from: fromRaw, replyFrom: replyFromRaw,
3763
+ guestBotCallerUser, guestBotCallerChat, ownerUserId });
3764
+ const downloadGuestFile = (fileId, fileName, source) => deps.downloadFile(fileId, fileName, source ? { ...source, scope: guestScope } : source);
1881
3765
  const replyFiles = guestMsg.reply_to_message
1882
- ? await Media.downloadTelegramMessageFiles([guestMsg.reply_to_message], { downloadFile: deps.downloadFile })
3766
+ ? await Media.downloadTelegramMessageFiles([guestMsg.reply_to_message], { downloadFile: downloadGuestFile })
1883
3767
  : [];
1884
3768
  assertExecutionCurrent();
1885
3769
  const processedReply = replyFiles.length > 0
@@ -1887,7 +3771,7 @@ export function createTelegramInboundRouteRuntime(deps) {
1887
3771
  : undefined;
1888
3772
  assertExecutionCurrent();
1889
3773
  const files = await Media.downloadTelegramMessageFiles([guestMsg], {
1890
- downloadFile: deps.downloadFile,
3774
+ downloadFile: downloadGuestFile,
1891
3775
  });
1892
3776
  assertExecutionCurrent();
1893
3777
  const processed = await deps.inboundHandlerRuntime.process(files, text, ctx);
@@ -1957,7 +3841,7 @@ export function createTelegramInboundRouteRuntime(deps) {
1957
3841
  deps.updateStatus(ctx);
1958
3842
  requestDispatchNextQueuedTelegramTurn(ctx);
1959
3843
  };
1960
- return Updates.createTelegramPairedUpdateRuntime({
3844
+ const runtime = Updates.createTelegramPairedUpdateRuntime({
1961
3845
  getAllowedUserId: deps.configStore.getAllowedUserId,
1962
3846
  getCurrentInstanceId: deps.getCurrentInstanceId,
1963
3847
  getMessageOwnership: deps.getMessageOwnership,
@@ -1999,8 +3883,14 @@ export function createTelegramInboundRouteRuntime(deps) {
1999
3883
  const records = deps.threadStore.list();
2000
3884
  const bindings = getTelegramRoutableThreadRecords(records, deps.getLiveThreadTargets?.());
2001
3885
  const command = getKnownTelegramAllTabCommand(text);
2002
- // Returning before deferral lets the admission worker terminally settle expired replay.
2003
- if (command && command.name !== "thread" && isTelegramAllTabCommandExpired(message))
3886
+ const [sourceUpdateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
3887
+ const presented = sourceUpdateId !== undefined && deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().some(entry => entry.source.updateId === sourceUpdateId && entry.source.journalBindingKey === deps.getAdmissionJournalBinding?.()) === true;
3888
+ // Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
3889
+ if (command && command.name !== "thread" && !presented && isTelegramAllTabCommandExpired(message))
3890
+ return;
3891
+ if (command?.name !== "thread" && (bindings.length > 0 || presented) &&
3892
+ typeof message.text === "string" && message.text.trim() &&
3893
+ await sendAllTabTemporaryInputChooser(command, text, message, ctx))
2004
3894
  return;
2005
3895
  if (bindings.length > 0 && command && command.name !== "thread") {
2006
3896
  if (await sendAllTabCommandChooser(command, text, message, {
@@ -2057,6 +3947,12 @@ export function createTelegramInboundRouteRuntime(deps) {
2057
3947
  }
2058
3948
  await deps.threadStore.load();
2059
3949
  assertExecutionCurrent();
3950
+ if (Updates.isTelegramHistoricalInput(message, entry => reviewMessage(entry) !== undefined) && isReviewText(message) &&
3951
+ deps.getCurrentLeaderEpoch?.() !== undefined && needsHistoricalReview(message)) {
3952
+ if (!Updates.reportTelegramHistoricalRoutingReview(message))
3953
+ throw new Error("Historical routing hold is unavailable.");
3954
+ return;
3955
+ }
2060
3956
  if (deps.threadStore.getBotState().threadMode === "disabled") {
2061
3957
  await textDispatch.handleMessage(message, ctx);
2062
3958
  return;
@@ -2140,12 +4036,8 @@ export function createTelegramInboundRouteRuntime(deps) {
2140
4036
  }
2141
4037
  const command = getKnownTelegramAllTabCommand(text);
2142
4038
  if (command && hasAnyRoutableThread) {
2143
- if (await sendAllTabCommandChooser(command, text, message, {
2144
- target: { chatId: target.chatId, threadId: target.threadId },
2145
- replyToSource: true,
2146
- })) {
2147
- return;
2148
- }
4039
+ await sendUnboundRerouteChooser(message, ctx);
4040
+ return;
2149
4041
  }
2150
4042
  if (leaderProfileKey && deps.callApi) {
2151
4043
  const currentLeaderRecord = records.find((record) => {
@@ -2231,12 +4123,12 @@ export function createTelegramInboundRouteRuntime(deps) {
2231
4123
  return;
2232
4124
  }
2233
4125
  const identityThreadName = priorLeaderIdentity?.threadName &&
2234
- Threads.isTelegramTopicThreadNameValidForSlot(priorLeaderIdentity.threadName, slot)
4126
+ ThreadNaming.isTelegramTopicThreadNameValidForSlot(priorLeaderIdentity.threadName, slot)
2235
4127
  ? priorLeaderIdentity.threadName
2236
4128
  : undefined;
2237
4129
  const threadName = priorLeaderRecord?.threadName ??
2238
4130
  identityThreadName ??
2239
- Threads.chooseTelegramThreadName({ slot }) ??
4131
+ ThreadNaming.chooseTelegramThreadName({ slot }) ??
2240
4132
  "Pi";
2241
4133
  deps.threadStore.upsert({
2242
4134
  profileKey: leaderProfileKey,
@@ -2284,6 +4176,13 @@ export function createTelegramInboundRouteRuntime(deps) {
2284
4176
  }, operation);
2285
4177
  },
2286
4178
  });
4179
+ return { ...runtime, expireRoutingInput, shouldReviewHistoricalInput, shouldHoldPendingInput, forgetPreviousWorld, beforeQueueReceiptPublished, onQueueReceiptCommitted, onQueueReceiptCompleted, onUpdateCompleted,
4180
+ onWorkspaceRestoreRecipientObserved(follower, isCurrent, ctx) {
4181
+ if (ctx !== undefined)
4182
+ return observeRestoreSettlement({ kind: "recipient", follower, isCurrent }, ctx);
4183
+ return undefined;
4184
+ },
4185
+ async waitForRestoreSettlement() { await Promise.all([...restoreSettlementTasks]); } };
2287
4186
  }
2288
4187
  export function createTelegramAssistantOutputAuthorityRuntime(deps) {
2289
4188
  const getCurrentTarget = () => {