@llblab/pi-telegram 0.46.0 → 0.47.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 (199) hide show
  1. package/BACKLOG.md +0 -1
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +2 -2
  4. package/dist/api/activity.d.ts +6 -0
  5. package/dist/api/activity.js +6 -0
  6. package/dist/api/commands.d.ts +6 -0
  7. package/dist/api/commands.js +6 -0
  8. package/dist/api/delivery.d.ts +6 -0
  9. package/dist/api/delivery.js +6 -0
  10. package/dist/api/inbound.d.ts +6 -0
  11. package/dist/api/inbound.js +6 -0
  12. package/dist/api/keyboard.d.ts +6 -0
  13. package/dist/api/keyboard.js +6 -0
  14. package/dist/api/outbound.d.ts +6 -0
  15. package/dist/api/outbound.js +6 -0
  16. package/dist/api/sections.d.ts +7 -0
  17. package/dist/api/sections.js +6 -0
  18. package/dist/api/status.d.ts +6 -0
  19. package/dist/api/status.js +6 -0
  20. package/dist/api/updates.d.ts +6 -0
  21. package/dist/api/updates.js +6 -0
  22. package/dist/api/voice.d.ts +6 -0
  23. package/dist/api/voice.js +6 -0
  24. package/dist/index.d.ts +6 -0
  25. package/dist/index.js +6 -0
  26. package/dist/lib/activity-verbosity.d.ts +52 -0
  27. package/dist/lib/activity-verbosity.js +596 -0
  28. package/dist/lib/activity.d.ts +220 -0
  29. package/dist/lib/activity.js +574 -0
  30. package/dist/lib/agent-messages.d.ts +28 -0
  31. package/dist/lib/agent-messages.js +86 -0
  32. package/dist/lib/bindings.d.ts +250 -0
  33. package/dist/lib/bindings.js +936 -0
  34. package/dist/lib/bus-api.d.ts +18 -0
  35. package/dist/lib/bus-api.js +254 -0
  36. package/dist/lib/bus-follower.d.ts +464 -0
  37. package/dist/lib/bus-follower.js +1686 -0
  38. package/dist/lib/bus-leader.d.ts +292 -0
  39. package/dist/lib/bus-leader.js +2242 -0
  40. package/dist/lib/bus-transport.d.ts +64 -0
  41. package/dist/lib/bus-transport.js +140 -0
  42. package/dist/lib/bus.d.ts +518 -0
  43. package/dist/lib/bus.js +2055 -0
  44. package/dist/lib/channel-posts.d.ts +170 -0
  45. package/dist/lib/channel-posts.js +611 -0
  46. package/dist/lib/command-templates.d.ts +70 -0
  47. package/dist/lib/command-templates.js +744 -0
  48. package/dist/lib/commands.d.ts +541 -0
  49. package/dist/lib/commands.js +1155 -0
  50. package/dist/lib/config.d.ts +252 -0
  51. package/dist/lib/config.js +889 -0
  52. package/dist/lib/delivery.d.ts +163 -0
  53. package/dist/lib/delivery.js +542 -0
  54. package/dist/lib/extension.d.ts +7 -0
  55. package/dist/lib/extension.js +1608 -0
  56. package/dist/lib/generative-app-worker.mjs +104 -0
  57. package/dist/lib/generative-apps.d.ts +212 -0
  58. package/dist/lib/generative-apps.js +1006 -0
  59. package/dist/lib/inbound.d.ts +91 -0
  60. package/dist/lib/inbound.js +502 -0
  61. package/dist/lib/journal.d.ts +665 -0
  62. package/dist/lib/journal.js +3701 -0
  63. package/dist/lib/keyboard.d.ts +23 -0
  64. package/dist/lib/keyboard.js +37 -0
  65. package/dist/lib/lifecycle.d.ts +157 -0
  66. package/dist/lib/lifecycle.js +395 -0
  67. package/dist/lib/locks.d.ts +164 -0
  68. package/dist/lib/locks.js +1208 -0
  69. package/dist/lib/logging.d.ts +50 -0
  70. package/dist/lib/logging.js +274 -0
  71. package/dist/lib/media.d.ts +181 -0
  72. package/dist/lib/media.js +602 -0
  73. package/dist/lib/menu-model.d.ts +217 -0
  74. package/dist/lib/menu-model.js +663 -0
  75. package/dist/lib/menu-queue.d.ts +37 -0
  76. package/dist/lib/menu-queue.js +408 -0
  77. package/dist/lib/menu-settings.d.ts +116 -0
  78. package/dist/lib/menu-settings.js +595 -0
  79. package/dist/lib/menu-status.d.ts +32 -0
  80. package/dist/lib/menu-status.js +112 -0
  81. package/dist/lib/menu-thinking.d.ts +29 -0
  82. package/dist/lib/menu-thinking.js +82 -0
  83. package/dist/lib/menu.d.ts +171 -0
  84. package/dist/lib/menu.js +323 -0
  85. package/dist/lib/model.d.ts +127 -0
  86. package/dist/lib/model.js +409 -0
  87. package/dist/lib/outbound-attachments.d.ts +241 -0
  88. package/dist/lib/outbound-attachments.js +639 -0
  89. package/dist/lib/outbound-buttons.d.ts +69 -0
  90. package/dist/lib/outbound-buttons.js +248 -0
  91. package/dist/lib/outbound-markup.d.ts +42 -0
  92. package/dist/lib/outbound-markup.js +678 -0
  93. package/dist/lib/outbound-voice.d.ts +55 -0
  94. package/dist/lib/outbound-voice.js +152 -0
  95. package/dist/lib/outbound.d.ts +185 -0
  96. package/dist/lib/outbound.js +516 -0
  97. package/dist/lib/ownership.d.ts +77 -0
  98. package/dist/lib/ownership.js +174 -0
  99. package/dist/lib/paths.d.ts +40 -0
  100. package/dist/lib/paths.js +94 -0
  101. package/dist/lib/pi.d.ts +72 -0
  102. package/dist/lib/pi.js +121 -0
  103. package/dist/lib/polling.d.ts +351 -0
  104. package/dist/lib/polling.js +1196 -0
  105. package/dist/lib/preview.d.ts +192 -0
  106. package/dist/lib/preview.js +614 -0
  107. package/dist/lib/prompt-templates.d.ts +24 -0
  108. package/dist/lib/prompt-templates.js +118 -0
  109. package/dist/lib/prompts.d.ts +57 -0
  110. package/dist/lib/prompts.js +167 -0
  111. package/dist/lib/queue.d.ts +766 -0
  112. package/dist/lib/queue.js +1965 -0
  113. package/dist/lib/recovery.d.ts +86 -0
  114. package/dist/lib/recovery.js +285 -0
  115. package/dist/lib/rendering.d.ts +20 -0
  116. package/dist/lib/rendering.js +983 -0
  117. package/dist/lib/replies.d.ts +214 -0
  118. package/dist/lib/replies.js +737 -0
  119. package/dist/lib/routing.d.ts +207 -0
  120. package/dist/lib/routing.js +2282 -0
  121. package/dist/lib/runtime.d.ts +172 -0
  122. package/dist/lib/runtime.js +402 -0
  123. package/dist/lib/sections.d.ts +165 -0
  124. package/dist/lib/sections.js +327 -0
  125. package/dist/lib/setup.d.ts +82 -0
  126. package/dist/lib/setup.js +150 -0
  127. package/dist/lib/skills.d.ts +8 -0
  128. package/dist/lib/skills.js +12 -0
  129. package/dist/lib/status.d.ts +442 -0
  130. package/dist/lib/status.js +1006 -0
  131. package/dist/lib/sync.d.ts +179 -0
  132. package/dist/lib/sync.js +782 -0
  133. package/dist/lib/target.d.ts +20 -0
  134. package/dist/lib/target.js +27 -0
  135. package/dist/lib/telegram-api.d.ts +541 -0
  136. package/dist/lib/telegram-api.js +1159 -0
  137. package/dist/lib/text-groups.d.ts +89 -0
  138. package/dist/lib/text-groups.js +317 -0
  139. package/dist/lib/thread-cleanup-manager.d.ts +288 -0
  140. package/dist/lib/thread-cleanup-manager.js +560 -0
  141. package/dist/lib/thread-display.d.ts +46 -0
  142. package/dist/lib/thread-display.js +257 -0
  143. package/dist/lib/thread-naming.d.ts +46 -0
  144. package/dist/lib/thread-naming.js +78 -0
  145. package/dist/lib/thread-reconciler.d.ts +239 -0
  146. package/dist/lib/thread-reconciler.js +644 -0
  147. package/dist/lib/threads.d.ts +621 -0
  148. package/dist/lib/threads.js +3679 -0
  149. package/dist/lib/time-injection.d.ts +15 -0
  150. package/dist/lib/time-injection.js +56 -0
  151. package/dist/lib/turns.d.ts +109 -0
  152. package/dist/lib/turns.js +500 -0
  153. package/dist/lib/updates.d.ts +1290 -0
  154. package/dist/lib/updates.js +3634 -0
  155. package/dist/lib/voice.d.ts +117 -0
  156. package/dist/lib/voice.js +174 -0
  157. package/dist/lib/workspace-admission.d.ts +264 -0
  158. package/dist/lib/workspace-admission.js +1136 -0
  159. package/dist/lib/workspace-retirement.d.ts +219 -0
  160. package/dist/lib/workspace-retirement.js +587 -0
  161. package/dist/lib/workspace-slots.d.ts +30 -0
  162. package/dist/lib/workspace-slots.js +54 -0
  163. package/dist/package.json +126 -0
  164. package/dist/pi-telegram/index.js +1 -0
  165. package/dist/skills/generated-control-surface/SKILL.md +107 -0
  166. package/dist/skills/generated-control-surface/references/capability-adapters.md +27 -0
  167. package/dist/skills/generated-control-surface/references/layout-and-state.md +37 -0
  168. package/dist/skills/generative-apps/SKILL.md +115 -0
  169. package/dist/skills/show-me/SKILL.md +166 -0
  170. package/dist/skills/show-me/references/telegram-surfaces.md +43 -0
  171. package/dist/skills/telegram-bridge/SKILL.md +129 -0
  172. package/dist/skills/telegram-bridge/references/configuration.md +15 -0
  173. package/dist/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
  174. package/dist/skills/telegram-bridge/references/diagnosis.md +18 -0
  175. package/docs/architecture.md +1 -1
  176. package/docs/compact-matrix-literal.md +10 -3
  177. package/docs/generative-apps.md +15 -13
  178. package/docs/multi-instance-bus.md +4 -2
  179. package/docs/outbound.md +1 -1
  180. package/docs/public-api.md +2 -2
  181. package/lib/bindings.ts +89 -6
  182. package/lib/bus-follower.ts +7 -3
  183. package/lib/bus-leader.ts +44 -19
  184. package/lib/bus.ts +4 -1
  185. package/lib/commands.ts +2 -2
  186. package/lib/config.ts +14 -7
  187. package/lib/delivery.ts +38 -6
  188. package/lib/extension.ts +19 -0
  189. package/lib/generative-apps.ts +379 -13
  190. package/lib/menu-settings.ts +21 -5
  191. package/lib/outbound-markup.ts +16 -7
  192. package/lib/telegram-api.ts +12 -1
  193. package/lib/thread-display.ts +96 -18
  194. package/package.json +56 -13
  195. package/scripts/build-dist.mjs +44 -0
  196. package/scripts/measure-bus.mjs +8 -1
  197. package/scripts/measure-workspace.mjs +8 -1
  198. package/skills/generative-apps/SKILL.md +1 -1
  199. package/skills/telegram-bridge/SKILL.md +1 -1
@@ -0,0 +1,3679 @@
1
+ /**
2
+ * Telegram thread binding helpers
3
+ * Zones: multi-instance bus, Telegram UI threads, volatile extension state
4
+ * Owns current live instance-binding to Telegram UI thread mappings backed by Bot API ForumTopic/message_thread_id transport
5
+ */
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
8
+ import { chmod, mkdir, readFile, rename, unlink, writeFile, } from "node:fs/promises";
9
+ import { dirname, posix, resolve } from "node:path";
10
+ import { isDeepStrictEqual } from "node:util";
11
+ import { isTelegramApiCommitUnknownError, TelegramApiCommitUnknownError, } from "./telegram-api.js";
12
+ import { withTelegramFileTransaction } from "./locks.js";
13
+ import * as ThreadReconciler from "./thread-reconciler.js";
14
+ import { planTelegramWorkspaceSlotAllocation, TELEGRAM_WORKSPACE_SLOTS, } from "./workspace-slots.js";
15
+ import { resolveAgentDir, resolveTelegramProfileTempFilePath, } from "./paths.js";
16
+ const TELEGRAM_WORKSPACE_KEY_MAX_LENGTH = 180;
17
+ const TELEGRAM_SESSION_ID_MAX_LENGTH = 256;
18
+ export function normalizeTelegramSessionId(sessionId) {
19
+ if (typeof sessionId !== "string")
20
+ return undefined;
21
+ const normalized = sessionId.trim();
22
+ return normalized && Buffer.byteLength(normalized, "utf8") <=
23
+ TELEGRAM_SESSION_ID_MAX_LENGTH
24
+ ? normalized
25
+ : undefined;
26
+ }
27
+ export function createTelegramSessionKey(sessionId) {
28
+ const normalized = normalizeTelegramSessionId(sessionId);
29
+ return normalized
30
+ ? createHash("sha256").update(normalized).digest("hex")
31
+ : undefined;
32
+ }
33
+ export function normalizeTelegramWorkspacePath(cwd) {
34
+ const trimmed = cwd.trim();
35
+ if (!trimmed)
36
+ return undefined;
37
+ const normalized = (trimmed.startsWith("/")
38
+ ? posix.normalize(trimmed)
39
+ : resolve(trimmed).replaceAll("\\", "/"));
40
+ const withoutTrailingSeparators = normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
41
+ return process.platform === "win32"
42
+ ? withoutTrailingSeparators.replace(/^([A-Z]):/u, (_, drive) => `${drive.toLowerCase()}:`)
43
+ : withoutTrailingSeparators;
44
+ }
45
+ export function createTelegramWorkspaceDirectoryKey(cwd) {
46
+ const normalized = normalizeTelegramWorkspacePath(cwd);
47
+ if (!normalized)
48
+ return undefined;
49
+ const readable = normalized
50
+ .replace(/[^\p{L}\p{N}._-]+/gu, "-")
51
+ .replace(/^-+|-+$/gu, "") || "root";
52
+ const candidate = `--${readable}--`;
53
+ if (candidate.length <= TELEGRAM_WORKSPACE_KEY_MAX_LENGTH)
54
+ return candidate;
55
+ const digest = createHash("sha256")
56
+ .update(normalized)
57
+ .digest("hex")
58
+ .slice(0, 12);
59
+ const prefixLength = TELEGRAM_WORKSPACE_KEY_MAX_LENGTH - digest.length - 5;
60
+ return `--${readable.slice(0, prefixLength)}-${digest}--`;
61
+ }
62
+ function createTelegramWorkspaceInstanceSlot(ordinal) {
63
+ if (!Number.isSafeInteger(ordinal) || ordinal < 0)
64
+ return undefined;
65
+ let value = ordinal + 1;
66
+ let slot = "";
67
+ while (value > 0) {
68
+ value -= 1;
69
+ slot = String.fromCharCode(97 + (value % 26)) + slot;
70
+ value = Math.floor(value / 26);
71
+ }
72
+ return slot;
73
+ }
74
+ function createTelegramWorkspaceBindingIdentityWithKey(cwd, workspaceKey, ordinal, sessionId) {
75
+ const instanceSlot = createTelegramWorkspaceInstanceSlot(ordinal);
76
+ if (!instanceSlot)
77
+ return undefined;
78
+ const normalizedSessionId = sessionId === undefined
79
+ ? undefined
80
+ : normalizeTelegramSessionId(sessionId);
81
+ const sessionKey = normalizedSessionId
82
+ ? createTelegramSessionKey(normalizedSessionId)
83
+ : undefined;
84
+ if (sessionId !== undefined && (!normalizedSessionId || !sessionKey)) {
85
+ return undefined;
86
+ }
87
+ const legacyBindingKey = instanceSlot === "a"
88
+ ? workspaceKey
89
+ : `${workspaceKey}${instanceSlot}`;
90
+ return {
91
+ cwd,
92
+ workspaceKey,
93
+ ...(normalizedSessionId && sessionKey
94
+ ? { sessionId: normalizedSessionId, sessionKey }
95
+ : {}),
96
+ instanceSlot,
97
+ bindingKey: sessionKey
98
+ ? `${legacyBindingKey}-s-${sessionKey}`
99
+ : legacyBindingKey,
100
+ };
101
+ }
102
+ export function createTelegramWorkspaceBindingIdentity(cwd, ordinal = 0, sessionId) {
103
+ const normalized = normalizeTelegramWorkspacePath(cwd);
104
+ const workspaceKey = normalized
105
+ ? createTelegramWorkspaceDirectoryKey(normalized)
106
+ : undefined;
107
+ if (!normalized || !workspaceKey)
108
+ return undefined;
109
+ return createTelegramWorkspaceBindingIdentityWithKey(normalized, workspaceKey, ordinal, sessionId);
110
+ }
111
+ const TELEGRAM_THREAD_RESERVATION_TTL_MS = 15 * 60 * 1000;
112
+ function getNextMonotonicSlot(records, reservations, pendingProvisions, nowMs, lastSlot) {
113
+ let cursorCode;
114
+ if (lastSlot && /^[A-Z]$/.test(lastSlot)) {
115
+ cursorCode = lastSlot.charCodeAt(0);
116
+ }
117
+ else {
118
+ cursorCode = "A".charCodeAt(0) - 1;
119
+ for (const record of records.values()) {
120
+ if (!record.slot || !isCurrentThreadRecord(record))
121
+ continue;
122
+ cursorCode = Math.max(cursorCode, record.slot.charCodeAt(0));
123
+ }
124
+ for (const reservation of reservations) {
125
+ if (reservation.expiresAtMs !== undefined &&
126
+ reservation.expiresAtMs <= nowMs)
127
+ continue;
128
+ if (!reservation.slot)
129
+ continue;
130
+ cursorCode = Math.max(cursorCode, reservation.slot.charCodeAt(0));
131
+ }
132
+ for (const provision of pendingProvisions) {
133
+ if (provision.status !== "ambiguous" &&
134
+ provision.expiresAtMs !== undefined &&
135
+ provision.expiresAtMs <= nowMs)
136
+ continue;
137
+ if (!provision.slot)
138
+ continue;
139
+ cursorCode = Math.max(cursorCode, provision.slot.charCodeAt(0));
140
+ }
141
+ }
142
+ let code = cursorCode + 1;
143
+ if (code > "Z".charCodeAt(0))
144
+ code = "A".charCodeAt(0);
145
+ for (let attempt = 0; attempt < 26; attempt++) {
146
+ const candidate = String.fromCharCode(code);
147
+ if (!isTelegramTopicTargetSlotOccupied(candidate, records, reservations, pendingProvisions, nowMs)) {
148
+ return candidate;
149
+ }
150
+ code += 1;
151
+ if (code > "Z".charCodeAt(0))
152
+ code = "A".charCodeAt(0);
153
+ }
154
+ return undefined;
155
+ }
156
+ export function reconcileTelegramFreshAllocationCursor(store, nowMs = Date.now()) {
157
+ const currentCursor = store.getBotState().lastSlot;
158
+ const slottedRecords = store
159
+ .list()
160
+ .filter((record) => !!record.slot && /^[A-Z]$/.test(record.slot));
161
+ if (slottedRecords.some((record) => record.slot === currentCursor)) {
162
+ return false;
163
+ }
164
+ const latestLiveRecord = slottedRecords.reduce((latest, record) => {
165
+ if (!latest)
166
+ return record;
167
+ if (record.createdAtMs !== latest.createdAtMs) {
168
+ return record.createdAtMs > latest.createdAtMs ? record : latest;
169
+ }
170
+ return record.updatedAtMs > latest.updatedAtMs ? record : latest;
171
+ }, undefined);
172
+ const nextCursor = latestLiveRecord?.slot;
173
+ if (nextCursor === currentCursor)
174
+ return false;
175
+ store.setBotState({
176
+ lastSlot: nextCursor,
177
+ updatedAtMs: nowMs,
178
+ lastReconcileAction: "live-cursor-realignment",
179
+ });
180
+ return true;
181
+ }
182
+ export function createTelegramCleanupTargetProtection(store, departingRecord) {
183
+ const records = store.list();
184
+ const reservations = store.listReservations?.() ?? [];
185
+ const provisions = store.listPendingProvisions?.() ?? [];
186
+ const intents = store.listPendingCleanups?.() ?? [];
187
+ // Persistence may reconstruct keys in another order and omit undefined
188
+ // optional fields; neither changes the authority represented by a snapshot.
189
+ const sameSnapshot = (left, right) => isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
190
+ return (target, action) => {
191
+ for (const record of store.list()) {
192
+ if (!targetMatches(record.target, target))
193
+ continue;
194
+ // A persisted shutdown intent may retire only its original pre-intent
195
+ // binding. Registration/rebinding after that intent supersedes it.
196
+ const intent = action.kind === "close-delete-graceful-shutdown-topic"
197
+ ? intents.find((candidate) => candidate.id === action.cleanupIntentId && candidate.runtimeGeneration === action.runtimeGeneration)
198
+ : undefined;
199
+ const expectedDeparting = departingRecord ?? (intent && records.find((candidate) => candidate.instanceId === intent.instanceId && targetMatches(candidate.target, intent.target) &&
200
+ candidate.updatedAtMs <= intent.requestedAtMs));
201
+ if (expectedDeparting && "instanceId" in action &&
202
+ (action.kind === "close-delete-previous-leader-topic" || action.instanceId === expectedDeparting.instanceId) &&
203
+ sameSnapshot(record, expectedDeparting)) {
204
+ if (action.kind === "close-delete-previous-leader-topic" || action.kind === "close-stale-replaced-topic")
205
+ continue;
206
+ if (action.kind === "close-delete-graceful-shutdown-topic" &&
207
+ store.listPendingCleanups?.().some((intent) => intent.id === action.cleanupIntentId &&
208
+ intent.instanceId === action.instanceId && intent.runtimeGeneration === action.runtimeGeneration &&
209
+ targetMatches(intent.target, target)))
210
+ continue;
211
+ }
212
+ if (record.status === "active" || record.status === "starting" || record.status === "pending" || record.status === "probe-required")
213
+ return true;
214
+ }
215
+ for (const reservation of store.listReservations?.() ?? []) {
216
+ if (!targetMatches(reservation.target, target))
217
+ continue;
218
+ if (action.kind !== "close-delete-reserved-topic" || !reservations.some((initial) => sameSnapshot(initial, reservation)))
219
+ return true;
220
+ }
221
+ for (const provision of store.listPendingProvisions?.() ?? []) {
222
+ if (!provision.target || !targetMatches(provision.target, target))
223
+ continue;
224
+ if (action.kind !== "close-delete-expired-pending-provision-topic" || provision.id !== action.pendingProvisionId ||
225
+ !provisions.some((initial) => sameSnapshot(initial, provision)))
226
+ return true;
227
+ }
228
+ return false;
229
+ };
230
+ }
231
+ /** Pending creation evidence cannot bypass an exact target's unresolved cleanup or closure. */
232
+ export function assertTelegramPendingTopicRecoveryAllowed(store, target) {
233
+ if (!store.listPendingProvisions().some((entry) => entry.target && targetMatches(entry.target, target)))
234
+ return;
235
+ if (store.listPendingCleanups().some((entry) => targetMatches(entry.target, target)) ||
236
+ store.listSyncObservations().some((entry) => targetMatches(entry.target, target) && entry.syncStatus === "closed")) {
237
+ throw new Error("Telegram pending topic requires reconciliation before recovery.");
238
+ }
239
+ }
240
+ /** Settle creation-title evidence with the exact Workspace claim; caller fences and persists. */
241
+ export function commitTelegramWorkspaceProvisionBinding(input) {
242
+ assertTelegramPendingTopicRecoveryAllowed(input.store, input.binding.target);
243
+ const pending = input.store.listPendingProvisions().filter((provision) => provision.target && targetMatches(provision.target, input.binding.target) &&
244
+ provision.slot === input.binding.slot &&
245
+ (provision.instanceId === input.instanceId || provision.profileKey === input.profileKey));
246
+ const titles = new Set(pending.map((provision) => provision.displayTitle)
247
+ .filter((title) => title !== undefined));
248
+ if (input.displayTitle !== undefined)
249
+ titles.add(input.displayTitle);
250
+ if (titles.size > 1)
251
+ throw new Error("Telegram Workspace creation title evidence conflicts.");
252
+ const displayTitle = titles.values().next().value;
253
+ const committed = input.store.upsertWorkspaceBinding(input.binding, input.instanceId);
254
+ if (!committed)
255
+ throw new Error("Telegram Workspace binding claim changed.");
256
+ if (displayTitle !== undefined &&
257
+ !input.store.setWorkspaceDisplayTitle(committed, displayTitle)) {
258
+ throw new Error("Telegram Workspace display title commit changed binding.");
259
+ }
260
+ for (const provision of pending)
261
+ input.store.removePendingProvision(provision.id);
262
+ return displayTitle === undefined ? committed : { ...committed, displayTitle };
263
+ }
264
+ function hashString(value) {
265
+ let hash = 2166136261;
266
+ for (let index = 0; index < value.length; index += 1) {
267
+ hash ^= value.charCodeAt(index);
268
+ hash = Math.imul(hash, 16777619);
269
+ }
270
+ return hash >>> 0;
271
+ }
272
+ function getWorkspaceHint(cwd) {
273
+ if (!cwd)
274
+ return undefined;
275
+ const parts = cwd.split("/").filter(Boolean);
276
+ const last = parts.at(-1)?.trim();
277
+ if (!last)
278
+ return undefined;
279
+ return (last
280
+ .replace(/[^\p{L}\p{N}._-]+/gu, " ")
281
+ .trim()
282
+ .slice(0, 32) || undefined);
283
+ }
284
+ export function createTelegramThreadName(input) {
285
+ const workspace = getWorkspaceHint(input.cwd);
286
+ const roleMark = input.role === "leader"
287
+ ? "Leader"
288
+ : input.role === "follower"
289
+ ? "Follower"
290
+ : undefined;
291
+ const slot = input.slot ? `Thread ${input.slot}` : undefined;
292
+ const peerSalt = input.peers?.slice().sort().join("|") ?? "";
293
+ const fallback = `Instance ${hashString(`${input.seed}|${input.cwd ?? ""}|${input.role ?? ""}|${peerSalt}|${input.slot ?? ""}`)
294
+ .toString(36)
295
+ .slice(0, 4)}`;
296
+ return ([slot, workspace, roleMark].filter(Boolean).join(" ").slice(0, 96) ||
297
+ fallback);
298
+ }
299
+ export function getTelegramStatePath(agentDir = resolveAgentDir(), profileName) {
300
+ return resolveTelegramProfileTempFilePath("state", "json", agentDir, profileName);
301
+ }
302
+ export function getTelegramTopicTargetsPath(agentDir = resolveAgentDir(), profileName) {
303
+ return getTelegramStatePath(agentDir, profileName);
304
+ }
305
+ const TELEGRAM_LEADER_SESSION_HANDOFF_KEY = "__piTelegramLeaderSessionHandoff";
306
+ export const TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS = 30_000;
307
+ export function getTelegramLeaderSessionHandoff() {
308
+ const value = globalThis[TELEGRAM_LEADER_SESSION_HANDOFF_KEY];
309
+ if (!value || typeof value !== "object")
310
+ return undefined;
311
+ const handoff = value;
312
+ if (typeof handoff.pid !== "number" ||
313
+ typeof handoff.instanceId !== "string" ||
314
+ typeof handoff.createdAtMs !== "number" ||
315
+ typeof handoff.profileKey !== "string" ||
316
+ typeof handoff.target?.chatId !== "number" ||
317
+ typeof handoff.target.threadId !== "number") {
318
+ return undefined;
319
+ }
320
+ return handoff;
321
+ }
322
+ export function setTelegramLeaderSessionHandoff(handoff) {
323
+ const store = globalThis;
324
+ if (!handoff)
325
+ delete store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY];
326
+ else
327
+ store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY] = handoff;
328
+ }
329
+ export function isTelegramLeaderSessionHandoffFresh(handoff, options = {}) {
330
+ if (!handoff)
331
+ return false;
332
+ const pid = options.pid ?? process.pid;
333
+ const nowMs = options.nowMs ?? Date.now();
334
+ const ttlMs = options.ttlMs ?? TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS;
335
+ return handoff.pid === pid && nowMs - handoff.createdAtMs <= ttlMs;
336
+ }
337
+ export function getTelegramThreadOwnerKey(owner) {
338
+ switch (owner.kind) {
339
+ case "leader": {
340
+ const base = owner.cwd
341
+ ? `cwd:${owner.cwd}`
342
+ : `leader:${owner.instanceId ?? "default"}`;
343
+ return owner.telegramProfile
344
+ ? `profile:${owner.telegramProfile}:${base}`
345
+ : base;
346
+ }
347
+ case "manual-follower":
348
+ return owner.telegramProfile
349
+ ? `profile:${owner.telegramProfile}:manual:${owner.instanceId}`
350
+ : `manual:${owner.instanceId}`;
351
+ case "pending-topic":
352
+ return `topic:${owner.chatId}:${owner.threadId}`;
353
+ case "legacy":
354
+ return `legacy:${owner.key}`;
355
+ }
356
+ }
357
+ export function getTelegramThreadOwnerFromProfileKey(profileKey) {
358
+ if (profileKey.startsWith("profile:")) {
359
+ const [, telegramProfile, ownerKind, ...rest] = profileKey.split(":");
360
+ const value = rest.join(":");
361
+ if (ownerKind === "cwd")
362
+ return { kind: "leader", cwd: value, telegramProfile };
363
+ if (ownerKind === "leader")
364
+ return { kind: "leader", instanceId: value, telegramProfile };
365
+ if (ownerKind === "manual")
366
+ return { kind: "manual-follower", instanceId: value, telegramProfile };
367
+ }
368
+ if (profileKey.startsWith("cwd:"))
369
+ return { kind: "leader", cwd: profileKey.slice(4) };
370
+ if (profileKey.startsWith("manual:")) {
371
+ return { kind: "manual-follower", instanceId: profileKey.slice(7) };
372
+ }
373
+ if (profileKey.startsWith("topic:")) {
374
+ const [, chatIdText, threadIdText] = profileKey.split(":");
375
+ const chatId = Number(chatIdText);
376
+ const threadId = Number(threadIdText);
377
+ if (Number.isInteger(chatId) && Number.isInteger(threadId)) {
378
+ return { kind: "pending-topic", chatId, threadId };
379
+ }
380
+ }
381
+ if (profileKey.startsWith("leader:")) {
382
+ return { kind: "leader", instanceId: profileKey.slice(7) };
383
+ }
384
+ return { kind: "legacy", key: profileKey };
385
+ }
386
+ function parseThreadOwner(value) {
387
+ if (!value || typeof value !== "object" || Array.isArray(value))
388
+ return undefined;
389
+ const record = value;
390
+ if (record.kind === "leader") {
391
+ return {
392
+ kind: "leader",
393
+ cwd: typeof record.cwd === "string" ? record.cwd : undefined,
394
+ instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined,
395
+ ...(typeof record.telegramProfile === "string"
396
+ ? { telegramProfile: record.telegramProfile }
397
+ : {}),
398
+ };
399
+ }
400
+ if (record.kind === "manual-follower" &&
401
+ typeof record.instanceId === "string") {
402
+ return {
403
+ kind: "manual-follower",
404
+ instanceId: record.instanceId,
405
+ ...(typeof record.telegramProfile === "string"
406
+ ? { telegramProfile: record.telegramProfile }
407
+ : {}),
408
+ };
409
+ }
410
+ if (record.kind === "pending-topic" &&
411
+ typeof record.chatId === "number" &&
412
+ typeof record.threadId === "number" &&
413
+ Number.isInteger(record.threadId)) {
414
+ return {
415
+ kind: "pending-topic",
416
+ chatId: record.chatId,
417
+ threadId: record.threadId,
418
+ };
419
+ }
420
+ if (record.kind === "legacy" && typeof record.key === "string") {
421
+ return { kind: "legacy", key: record.key };
422
+ }
423
+ return undefined;
424
+ }
425
+ function getRecordOwner(record) {
426
+ return (record.owner ?? getTelegramThreadOwnerFromProfileKey(record.profileKey));
427
+ }
428
+ function getRecordOwnerKey(record) {
429
+ return getTelegramThreadOwnerKey(getRecordOwner(record));
430
+ }
431
+ function cloneRecord(record) {
432
+ const owner = getRecordOwner(record);
433
+ return {
434
+ ...record,
435
+ owner: { ...owner },
436
+ profileKey: getTelegramThreadOwnerKey(owner),
437
+ target: { ...record.target },
438
+ };
439
+ }
440
+ function getPersistedThreadName(record) {
441
+ const value = typeof record.threadName === "string"
442
+ ? record.threadName
443
+ : typeof record.displayName === "string"
444
+ ? record.displayName
445
+ : undefined;
446
+ return value ? normalizeTelegramTopicTargetThreadName(value) : undefined;
447
+ }
448
+ function normalizeRecord(value) {
449
+ if (!value || typeof value !== "object" || Array.isArray(value))
450
+ return undefined;
451
+ const record = value;
452
+ const target = record.target;
453
+ const owner = parseThreadOwner(record.owner) ??
454
+ (typeof record.profileKey === "string" && record.profileKey.length > 0
455
+ ? getTelegramThreadOwnerFromProfileKey(record.profileKey)
456
+ : undefined);
457
+ if (!owner)
458
+ return undefined;
459
+ if (!target || typeof target !== "object" || Array.isArray(target))
460
+ return undefined;
461
+ const targetRecord = target;
462
+ if (typeof targetRecord.chatId !== "number" ||
463
+ typeof targetRecord.threadId !== "number" ||
464
+ !Number.isInteger(targetRecord.threadId)) {
465
+ return undefined;
466
+ }
467
+ const status = record.status;
468
+ if (status !== "active" &&
469
+ status !== "offline" &&
470
+ status !== "stale" &&
471
+ status !== "pending" &&
472
+ status !== "starting" &&
473
+ status !== "probe-required" &&
474
+ status !== "failed")
475
+ return undefined;
476
+ if (typeof record.createdAtMs !== "number" ||
477
+ typeof record.updatedAtMs !== "number")
478
+ return undefined;
479
+ const normalized = {
480
+ profileKey: getTelegramThreadOwnerKey(owner),
481
+ owner,
482
+ target: { chatId: targetRecord.chatId, threadId: targetRecord.threadId },
483
+ status,
484
+ createdAtMs: record.createdAtMs,
485
+ updatedAtMs: record.updatedAtMs,
486
+ threadName: getPersistedThreadName(record),
487
+ ...(typeof record.manualThreadName === "string" &&
488
+ normalizeTelegramTopicTargetThreadName(record.manualThreadName)
489
+ ? { manualThreadName: normalizeTelegramTopicTargetThreadName(record.manualThreadName) }
490
+ : {}),
491
+ instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined,
492
+ slot: typeof record.slot === "string" ? record.slot : undefined,
493
+ };
494
+ const syncStatus = record.syncStatus ?? record.twinStatus;
495
+ if (syncStatus === "open" ||
496
+ syncStatus === "closed" ||
497
+ syncStatus === "deleted" ||
498
+ syncStatus === "unknown") {
499
+ normalized.syncStatus = syncStatus;
500
+ }
501
+ if (typeof record.lastError === "string")
502
+ normalized.lastError = record.lastError;
503
+ const lastSyncObservedAtMs = record.lastSyncObservedAtMs ?? record.lastTwinObservedAtMs;
504
+ if (typeof lastSyncObservedAtMs === "number") {
505
+ normalized.lastSyncObservedAtMs = lastSyncObservedAtMs;
506
+ }
507
+ const lastSyncProbeAtMs = record.lastSyncProbeAtMs ?? record.lastTwinProbeAtMs;
508
+ if (typeof lastSyncProbeAtMs === "number") {
509
+ normalized.lastSyncProbeAtMs = lastSyncProbeAtMs;
510
+ }
511
+ const lastSyncError = record.lastSyncError ?? record.lastTwinError;
512
+ if (typeof lastSyncError === "string") {
513
+ normalized.lastSyncError = lastSyncError;
514
+ }
515
+ if (typeof record.lastReconcileAction === "string") {
516
+ normalized.lastReconcileAction = record.lastReconcileAction;
517
+ }
518
+ if (typeof record.rerouteConfirmedAtMs === "number") {
519
+ normalized.rerouteConfirmedAtMs = record.rerouteConfirmedAtMs;
520
+ }
521
+ return normalized;
522
+ }
523
+ function isCurrentThreadRecord(record) {
524
+ return (record.status === "active" ||
525
+ record.status === "starting" ||
526
+ record.status === "pending");
527
+ }
528
+ function isPersistedThreadRecord(record) {
529
+ return isCurrentThreadRecord(record) || record.status === "probe-required";
530
+ }
531
+ function normalizeIdentityRecord(value) {
532
+ if (!value || typeof value !== "object" || Array.isArray(value))
533
+ return undefined;
534
+ const record = value;
535
+ if (typeof record.profileKey !== "string" || record.profileKey.length === 0)
536
+ return undefined;
537
+ if (typeof record.updatedAtMs !== "number")
538
+ return undefined;
539
+ const identity = {
540
+ profileKey: record.profileKey,
541
+ updatedAtMs: record.updatedAtMs,
542
+ };
543
+ const persistedThreadName = getPersistedThreadName(record);
544
+ if (persistedThreadName) {
545
+ const threadName = normalizeTelegramTopicTargetThreadName(persistedThreadName);
546
+ if (threadName)
547
+ identity.threadName = threadName;
548
+ }
549
+ if (typeof record.slot === "string" && /^[A-Z]$/.test(record.slot)) {
550
+ identity.slot = record.slot;
551
+ }
552
+ return identity.threadName || identity.slot ? identity : undefined;
553
+ }
554
+ function cloneIdentityRecord(identity) {
555
+ return { ...identity };
556
+ }
557
+ function getWorkspaceBindingMapKey(binding) {
558
+ return `${binding.cwd}\u0000${binding.sessionId ?? ""}\u0000${binding.instanceSlot}`;
559
+ }
560
+ function normalizeWorkspaceBindingRecord(value) {
561
+ if (!value || typeof value !== "object" || Array.isArray(value))
562
+ return undefined;
563
+ const record = value;
564
+ if (typeof record.cwd !== "string" ||
565
+ typeof record.workspaceKey !== "string" ||
566
+ typeof record.instanceSlot !== "string" ||
567
+ typeof record.bindingKey !== "string" ||
568
+ typeof record.updatedAtMs !== "number") {
569
+ return undefined;
570
+ }
571
+ const cwd = normalizeTelegramWorkspacePath(record.cwd);
572
+ const sessionId = typeof record.sessionId === "string"
573
+ ? normalizeTelegramSessionId(record.sessionId)
574
+ : undefined;
575
+ const sessionKey = typeof record.sessionKey === "string"
576
+ ? record.sessionKey
577
+ : undefined;
578
+ const hasSessionFields = record.sessionId !== undefined ||
579
+ record.sessionKey !== undefined;
580
+ const expectedSessionKey = sessionId
581
+ ? createTelegramSessionKey(sessionId)
582
+ : undefined;
583
+ const legacyBindingKey = record.instanceSlot === "a"
584
+ ? record.workspaceKey
585
+ : `${record.workspaceKey}${record.instanceSlot}`;
586
+ const expectedBindingKey = expectedSessionKey
587
+ ? `${legacyBindingKey}-s-${expectedSessionKey}`
588
+ : legacyBindingKey;
589
+ if (!cwd ||
590
+ cwd !== record.cwd ||
591
+ !record.workspaceKey ||
592
+ !/^[a-z]+$/u.test(record.instanceSlot) ||
593
+ (hasSessionFields && (!sessionId || sessionKey !== expectedSessionKey)) ||
594
+ record.bindingKey !== expectedBindingKey) {
595
+ return undefined;
596
+ }
597
+ const targetValue = record.target;
598
+ if (!targetValue ||
599
+ typeof targetValue !== "object" ||
600
+ Array.isArray(targetValue)) {
601
+ return undefined;
602
+ }
603
+ const target = targetValue;
604
+ if (typeof target.chatId !== "number" ||
605
+ typeof target.threadId !== "number" ||
606
+ !Number.isInteger(target.threadId)) {
607
+ return undefined;
608
+ }
609
+ const threadName = getPersistedThreadName(record);
610
+ const manualThreadName = typeof record.manualThreadName === "string"
611
+ ? normalizeTelegramTopicTargetThreadName(record.manualThreadName)
612
+ : undefined;
613
+ const slot = typeof record.slot === "string" && /^[A-Z]$/u.test(record.slot)
614
+ ? record.slot
615
+ : undefined;
616
+ const journalBindingKeys = Array.isArray(record.journalBindingKeys) &&
617
+ record.journalBindingKeys.every((key) => typeof key === "string" && key.length > 0 && key.length <= 512)
618
+ ? Array.from(new Set(record.journalBindingKeys))
619
+ : undefined;
620
+ return {
621
+ cwd,
622
+ workspaceKey: record.workspaceKey,
623
+ ...(sessionId && sessionKey ? { sessionId, sessionKey } : {}),
624
+ instanceSlot: record.instanceSlot,
625
+ bindingKey: record.bindingKey,
626
+ ...(record.showSlotSuffix === true ? { showSlotSuffix: true } : {}),
627
+ ...(typeof record.displayTitle === "string" && record.displayTitle.trim()
628
+ ? { displayTitle: record.displayTitle }
629
+ : {}),
630
+ ...(typeof record.inactiveSinceMs === "number" &&
631
+ Number.isFinite(record.inactiveSinceMs) && record.inactiveSinceMs >= 0
632
+ ? { inactiveSinceMs: record.inactiveSinceMs }
633
+ : {}),
634
+ ...(journalBindingKeys ? { journalBindingKeys } : {}),
635
+ ...(record.journalBindingsComplete === true && journalBindingKeys
636
+ ? { journalBindingsComplete: true }
637
+ : {}),
638
+ target: { chatId: target.chatId, threadId: target.threadId },
639
+ ...(threadName ? { threadName } : {}),
640
+ ...(manualThreadName ? { manualThreadName } : {}),
641
+ ...(slot ? { slot } : {}),
642
+ updatedAtMs: record.updatedAtMs,
643
+ };
644
+ }
645
+ function cloneWorkspaceBinding(binding) {
646
+ return { ...binding, target: { ...binding.target } };
647
+ }
648
+ function normalizeWorkspaceRetirementIntent(value) {
649
+ if (!value || typeof value !== "object" || Array.isArray(value))
650
+ return undefined;
651
+ const record = value;
652
+ const binding = normalizeWorkspaceBindingRecord(record.binding);
653
+ if (typeof record.id !== "string" || !record.id ||
654
+ record.reason !== "pressure" ||
655
+ typeof record.profileKey !== "string" || !record.profileKey ||
656
+ !binding?.slot || binding.inactiveSinceMs === undefined ||
657
+ !((typeof record.leaderEpoch === "number" && Number.isFinite(record.leaderEpoch)) ||
658
+ (typeof record.leaderEpoch === "string" && record.leaderEpoch.length > 0)) ||
659
+ typeof record.requestedAtMs !== "number" || !Number.isFinite(record.requestedAtMs) ||
660
+ record.requestedAtMs < 0)
661
+ return undefined;
662
+ return {
663
+ id: record.id,
664
+ reason: record.reason,
665
+ profileKey: record.profileKey,
666
+ binding,
667
+ leaderEpoch: record.leaderEpoch,
668
+ requestedAtMs: record.requestedAtMs,
669
+ };
670
+ }
671
+ function cloneWorkspaceRetirementIntent(intent) {
672
+ return { ...intent, binding: cloneWorkspaceBinding(intent.binding) };
673
+ }
674
+ function normalizeBotStateSnapshot(value) {
675
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
676
+ return { threadMode: "unknown" };
677
+ }
678
+ const record = value;
679
+ const threadMode = record.threadMode === "enabled" || record.threadMode === "disabled"
680
+ ? record.threadMode
681
+ : "unknown";
682
+ return {
683
+ threadMode,
684
+ updatedAtMs: typeof record.updatedAtMs === "number" ? record.updatedAtMs : undefined,
685
+ lastSlot: typeof record.lastSlot === "string" && /^[A-Z]$/.test(record.lastSlot)
686
+ ? record.lastSlot
687
+ : undefined,
688
+ lastReconcileAction: typeof record.lastReconcileAction === "string"
689
+ ? record.lastReconcileAction
690
+ : undefined,
691
+ };
692
+ }
693
+ function normalizeSyncObservation(value) {
694
+ if (!value || typeof value !== "object" || Array.isArray(value))
695
+ return undefined;
696
+ const record = value;
697
+ const targetValue = record.target;
698
+ if (!targetValue ||
699
+ typeof targetValue !== "object" ||
700
+ Array.isArray(targetValue)) {
701
+ return undefined;
702
+ }
703
+ const targetRecord = targetValue;
704
+ const target = typeof targetRecord.chatId === "number" &&
705
+ typeof targetRecord.threadId === "number" &&
706
+ Number.isInteger(targetRecord.threadId)
707
+ ? { chatId: targetRecord.chatId, threadId: targetRecord.threadId }
708
+ : undefined;
709
+ const syncStatus = record.syncStatus;
710
+ if (!target ||
711
+ (syncStatus !== "open" &&
712
+ syncStatus !== "closed" &&
713
+ syncStatus !== "deleted" &&
714
+ syncStatus !== "unknown")) {
715
+ return undefined;
716
+ }
717
+ return {
718
+ target,
719
+ syncStatus,
720
+ observedAtMs: typeof record.observedAtMs === "number" ? record.observedAtMs : 0,
721
+ instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined,
722
+ slot: typeof record.slot === "string" ? record.slot : undefined,
723
+ lastSyncError: typeof record.lastSyncError === "string"
724
+ ? record.lastSyncError
725
+ : undefined,
726
+ lastReconcileAction: typeof record.lastReconcileAction === "string"
727
+ ? record.lastReconcileAction
728
+ : undefined,
729
+ };
730
+ }
731
+ function normalizePendingProvision(value) {
732
+ if (!value || typeof value !== "object" || Array.isArray(value))
733
+ return undefined;
734
+ const record = value;
735
+ const owner = record.owner;
736
+ if (owner !== "leader" && owner !== "manual-follower")
737
+ return undefined;
738
+ if (typeof record.id !== "string" || record.id.length === 0)
739
+ return undefined;
740
+ if (typeof record.instanceId !== "string" || record.instanceId.length === 0)
741
+ return undefined;
742
+ if (typeof record.startedAtMs !== "number")
743
+ return undefined;
744
+ let target;
745
+ const targetValue = record.target;
746
+ if (targetValue &&
747
+ typeof targetValue === "object" &&
748
+ !Array.isArray(targetValue)) {
749
+ const targetRecord = targetValue;
750
+ if (typeof targetRecord.chatId === "number" &&
751
+ typeof targetRecord.threadId === "number" &&
752
+ Number.isInteger(targetRecord.threadId)) {
753
+ target = { chatId: targetRecord.chatId, threadId: targetRecord.threadId };
754
+ }
755
+ }
756
+ return {
757
+ id: record.id,
758
+ owner,
759
+ instanceId: record.instanceId,
760
+ ...(typeof record.profileKey === "string"
761
+ ? { profileKey: record.profileKey }
762
+ : {}),
763
+ ...(record.status === "in-flight" || record.status === "ambiguous"
764
+ ? { status: record.status }
765
+ : {}),
766
+ ...(typeof record.threadName === "string"
767
+ ? { threadName: record.threadName }
768
+ : {}),
769
+ ...(typeof record.displayTitle === "string" && record.displayTitle.trim()
770
+ ? { displayTitle: record.displayTitle }
771
+ : {}),
772
+ ...(typeof record.slot === "string" ? { slot: record.slot } : {}),
773
+ ...(target ? { target } : {}),
774
+ startedAtMs: record.startedAtMs,
775
+ ...(typeof record.expiresAtMs === "number"
776
+ ? { expiresAtMs: record.expiresAtMs }
777
+ : {}),
778
+ ...(typeof record.leaderEpoch === "number" ||
779
+ typeof record.leaderEpoch === "string"
780
+ ? { leaderEpoch: record.leaderEpoch }
781
+ : {}),
782
+ };
783
+ }
784
+ function normalizePendingCleanup(value) {
785
+ if (!value || typeof value !== "object" || Array.isArray(value))
786
+ return undefined;
787
+ const record = value;
788
+ const targetValue = record.target;
789
+ if (!targetValue ||
790
+ typeof targetValue !== "object" ||
791
+ Array.isArray(targetValue)) {
792
+ return undefined;
793
+ }
794
+ const targetRecord = targetValue;
795
+ const owner = record.owner;
796
+ if (owner !== "leader" && owner !== "manual-follower")
797
+ return undefined;
798
+ if (typeof record.id !== "string" || record.id.length === 0)
799
+ return undefined;
800
+ if (typeof record.instanceId !== "string" || record.instanceId.length === 0)
801
+ return undefined;
802
+ if (typeof record.runtimeGeneration !== "string" ||
803
+ record.runtimeGeneration.length === 0) {
804
+ return undefined;
805
+ }
806
+ if (typeof targetRecord.chatId !== "number" ||
807
+ typeof targetRecord.threadId !== "number" ||
808
+ !Number.isInteger(targetRecord.threadId) ||
809
+ typeof record.requestedAtMs !== "number") {
810
+ return undefined;
811
+ }
812
+ return {
813
+ id: record.id,
814
+ owner,
815
+ instanceId: record.instanceId,
816
+ runtimeGeneration: record.runtimeGeneration,
817
+ ...(typeof record.profileKey === "string"
818
+ ? { profileKey: record.profileKey }
819
+ : {}),
820
+ target: {
821
+ chatId: targetRecord.chatId,
822
+ threadId: targetRecord.threadId,
823
+ },
824
+ requestedAtMs: record.requestedAtMs,
825
+ };
826
+ }
827
+ function normalizeReservation(value) {
828
+ if (!value || typeof value !== "object" || Array.isArray(value))
829
+ return undefined;
830
+ const record = value;
831
+ const targetValue = record.target;
832
+ if (!targetValue ||
833
+ typeof targetValue !== "object" ||
834
+ Array.isArray(targetValue)) {
835
+ return undefined;
836
+ }
837
+ const targetRecord = targetValue;
838
+ const target = typeof targetRecord.chatId === "number" &&
839
+ typeof targetRecord.threadId === "number" &&
840
+ Number.isInteger(targetRecord.threadId)
841
+ ? { chatId: targetRecord.chatId, threadId: targetRecord.threadId }
842
+ : undefined;
843
+ const slot = typeof record.slot === "string" ? record.slot : undefined;
844
+ const reason = typeof record.reason === "string" ? record.reason : undefined;
845
+ if (!target || !slot || !reason)
846
+ return undefined;
847
+ return {
848
+ target,
849
+ slot,
850
+ reason,
851
+ createdAtMs: typeof record.createdAtMs === "number" ? record.createdAtMs : 0,
852
+ updatedAtMs: typeof record.updatedAtMs === "number" ? record.updatedAtMs : 0,
853
+ expiresAtMs: typeof record.expiresAtMs === "number" ? record.expiresAtMs : undefined,
854
+ instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined,
855
+ lastReconcileAction: typeof record.lastReconcileAction === "string"
856
+ ? record.lastReconcileAction
857
+ : undefined,
858
+ };
859
+ }
860
+ function parseTopicTargetFile(value) {
861
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
862
+ return {
863
+ version: 1,
864
+ source: "snapshot",
865
+ writtenAtMs: 0,
866
+ bot: { threadMode: "unknown" },
867
+ threads: [],
868
+ };
869
+ }
870
+ const file = value;
871
+ if (file.version !== 1) {
872
+ return {
873
+ version: 1,
874
+ source: "snapshot",
875
+ writtenAtMs: 0,
876
+ bot: { threadMode: "unknown" },
877
+ threads: [],
878
+ };
879
+ }
880
+ const rawThreads = Array.isArray(file.threads) ? file.threads : [];
881
+ const threads = rawThreads
882
+ .map((record) => normalizeRecord(record))
883
+ .filter((record) => !!record && isPersistedThreadRecord(record));
884
+ return {
885
+ version: 1,
886
+ source: "snapshot",
887
+ writtenAtMs: typeof file.writtenAtMs === "number" ? file.writtenAtMs : 0,
888
+ bot: normalizeBotStateSnapshot(file.bot),
889
+ threads,
890
+ identities: Array.isArray(file.identities)
891
+ ? file.identities.flatMap((identity) => {
892
+ const normalized = normalizeIdentityRecord(identity);
893
+ return normalized ? [normalized] : [];
894
+ })
895
+ : [],
896
+ workspaceBindings: Array.isArray(file.workspaceBindings)
897
+ ? file.workspaceBindings.flatMap((binding) => {
898
+ const normalized = normalizeWorkspaceBindingRecord(binding);
899
+ return normalized ? [normalized] : [];
900
+ })
901
+ : [],
902
+ workspaceRetirements: Array.isArray(file.workspaceRetirements)
903
+ ? file.workspaceRetirements.flatMap((intent) => {
904
+ const normalized = normalizeWorkspaceRetirementIntent(intent);
905
+ return normalized ? [normalized] : [];
906
+ })
907
+ : [],
908
+ reservations: Array.isArray(file.reservations)
909
+ ? file.reservations.flatMap((reservation) => {
910
+ const normalized = normalizeReservation(reservation);
911
+ return normalized ? [normalized] : [];
912
+ })
913
+ : [],
914
+ pendingProvisions: Array.isArray(file.pendingProvisions)
915
+ ? file.pendingProvisions.flatMap((provision) => {
916
+ const normalized = normalizePendingProvision(provision);
917
+ return normalized ? [normalized] : [];
918
+ })
919
+ : [],
920
+ pendingCleanups: Array.isArray(file.pendingCleanups)
921
+ ? file.pendingCleanups.flatMap((intent) => {
922
+ const normalized = normalizePendingCleanup(intent);
923
+ return normalized ? [normalized] : [];
924
+ })
925
+ : [],
926
+ syncObservations: Array.isArray(file.syncObservations)
927
+ ? file.syncObservations.flatMap((observation) => {
928
+ const normalized = normalizeSyncObservation(observation);
929
+ return normalized ? [normalized] : [];
930
+ })
931
+ : [],
932
+ };
933
+ }
934
+ function getTelegramStateSemanticSnapshot(value) {
935
+ if (!value || typeof value !== "object" || Array.isArray(value))
936
+ return undefined;
937
+ const { writtenAtMs: _writtenAtMs, ...semantic } = value;
938
+ return semantic;
939
+ }
940
+ function targetMatches(left, right) {
941
+ return left.chatId === right.chatId && left.threadId === right.threadId;
942
+ }
943
+ function getTargetRecoveryHintKey(target) {
944
+ return `${target.chatId}:${target.threadId ?? "private"}`;
945
+ }
946
+ function parseFollowerRecoveryHints(value) {
947
+ const hints = new Map();
948
+ if (!value || typeof value !== "object" || Array.isArray(value))
949
+ return hints;
950
+ const liveRoster = value.liveRoster;
951
+ if (!liveRoster ||
952
+ typeof liveRoster !== "object" ||
953
+ Array.isArray(liveRoster))
954
+ return hints;
955
+ const followers = liveRoster.busFollowers;
956
+ if (!Array.isArray(followers))
957
+ return hints;
958
+ for (const follower of followers) {
959
+ if (!follower || typeof follower !== "object" || Array.isArray(follower))
960
+ continue;
961
+ const record = follower;
962
+ const target = record.target;
963
+ if (!target || typeof target !== "object" || Array.isArray(target))
964
+ continue;
965
+ const targetRecord = target;
966
+ if (typeof targetRecord.chatId !== "number")
967
+ continue;
968
+ const normalizedTarget = {
969
+ chatId: targetRecord.chatId,
970
+ ...(typeof targetRecord.threadId === "number"
971
+ ? { threadId: targetRecord.threadId }
972
+ : {}),
973
+ };
974
+ const slot = typeof targetRecord.slot === "string" && /^[A-Z]$/.test(targetRecord.slot)
975
+ ? targetRecord.slot
976
+ : typeof record.slot === "string" && /^[A-Z]$/.test(record.slot)
977
+ ? record.slot
978
+ : undefined;
979
+ const threadName = typeof targetRecord.threadName === "string"
980
+ ? targetRecord.threadName
981
+ : typeof record.threadName === "string"
982
+ ? record.threadName
983
+ : undefined;
984
+ hints.set(getTargetRecoveryHintKey(normalizedTarget), {
985
+ ...(slot ? { slot } : {}),
986
+ ...(threadName ? { threadName } : {}),
987
+ });
988
+ }
989
+ return hints;
990
+ }
991
+ function getInstanceProcessKey(instanceId) {
992
+ if (!instanceId)
993
+ return undefined;
994
+ const [pid] = instanceId.split(":", 1);
995
+ return pid && /^\d+$/.test(pid) ? pid : undefined;
996
+ }
997
+ export function isSameTelegramProcessInstance(left, right) {
998
+ const leftProcess = getInstanceProcessKey(left);
999
+ return !!leftProcess && leftProcess === getInstanceProcessKey(right);
1000
+ }
1001
+ function isPendingProvisionLiveOrTargeted(provision, nowMs) {
1002
+ if (provision.status === "ambiguous")
1003
+ return true;
1004
+ if (provision.expiresAtMs === undefined || provision.expiresAtMs > nowMs) {
1005
+ return true;
1006
+ }
1007
+ return !!provision.target;
1008
+ }
1009
+ export function createTelegramTopicTargetStore(options) {
1010
+ const getNowMs = options.getNowMs ?? Date.now;
1011
+ const captureExternalReservedSlots = () => {
1012
+ try {
1013
+ const slots = options.getExternalReservedSlots?.() ?? [];
1014
+ if (!Array.isArray(slots) ||
1015
+ slots.some((slot) => typeof slot !== "string" || !/^[A-Z]$/u.test(slot))) {
1016
+ return undefined;
1017
+ }
1018
+ return Array.from(new Set(slots));
1019
+ }
1020
+ catch {
1021
+ return undefined;
1022
+ }
1023
+ };
1024
+ let botState = { threadMode: "unknown" };
1025
+ let records = new Map();
1026
+ let identities = new Map();
1027
+ let workspaceBindings = new Map();
1028
+ let workspaceRetirements = [];
1029
+ let workspaceRetirementCommitInFlight = false;
1030
+ const hasWorkspaceRetirementConflict = (input) => workspaceRetirements.some((intent) => (input.bindingKey !== undefined && intent.binding.bindingKey === input.bindingKey) ||
1031
+ (input.cwd !== undefined && intent.binding.cwd === input.cwd) ||
1032
+ (input.target !== undefined && targetMatches(intent.binding.target, input.target)) ||
1033
+ (input.slot !== undefined && intent.binding.slot === input.slot));
1034
+ let workspaceClaims = new Map();
1035
+ let reservations = [];
1036
+ let pendingProvisions = [];
1037
+ let pendingCleanups = [];
1038
+ let syncObservations = [];
1039
+ let followerRecoveryHints = new Map();
1040
+ let loaded = false;
1041
+ let loadedPath;
1042
+ let dirty = false;
1043
+ let mutationRevision = 0;
1044
+ let statusRevision = 0;
1045
+ let persistQueue = Promise.resolve();
1046
+ let statusSnapshot = {};
1047
+ const reconcileWorkspaceSuffixExposure = () => {
1048
+ const directoryCounts = new Map();
1049
+ const exposed = new Set();
1050
+ for (const binding of workspaceBindings.values()) {
1051
+ directoryCounts.set(binding.cwd, (directoryCounts.get(binding.cwd) ?? 0) + 1);
1052
+ if (binding.showSlotSuffix)
1053
+ exposed.add(binding.cwd);
1054
+ }
1055
+ for (const binding of workspaceBindings.values()) {
1056
+ if ((directoryCounts.get(binding.cwd) ?? 0) > 1 || exposed.has(binding.cwd)) {
1057
+ binding.showSlotSuffix = true;
1058
+ }
1059
+ }
1060
+ };
1061
+ const rememberSlot = (slot, nowMs = getNowMs()) => {
1062
+ if (!slot || !/^[A-Z]$/.test(slot))
1063
+ return;
1064
+ botState = { ...botState, lastSlot: slot, updatedAtMs: nowMs };
1065
+ };
1066
+ const rememberIdentity = (record) => {
1067
+ const profileKey = getRecordOwnerKey(record);
1068
+ if (!record.threadName && !record.slot)
1069
+ return;
1070
+ identities.set(profileKey, {
1071
+ profileKey,
1072
+ ...(record.threadName ? { threadName: record.threadName } : {}),
1073
+ ...(record.slot ? { slot: record.slot } : {}),
1074
+ updatedAtMs: record.updatedAtMs,
1075
+ });
1076
+ };
1077
+ const resolveWorkspaceKey = (cwd) => {
1078
+ const known = [
1079
+ ...Array.from(workspaceBindings.values()),
1080
+ ...Array.from(workspaceClaims.values()).map((claim) => claim.identity),
1081
+ ];
1082
+ const existing = known.find((binding) => binding.cwd === cwd);
1083
+ if (existing)
1084
+ return existing.workspaceKey;
1085
+ const readable = createTelegramWorkspaceDirectoryKey(cwd);
1086
+ if (!readable)
1087
+ return undefined;
1088
+ const collision = known.some((binding) => binding.workspaceKey === readable && binding.cwd !== cwd);
1089
+ if (!collision)
1090
+ return readable;
1091
+ const digest = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
1092
+ const prefix = readable.endsWith("--") ? readable.slice(0, -2) : readable;
1093
+ return `${prefix.slice(0, TELEGRAM_WORKSPACE_KEY_MAX_LENGTH - digest.length - 3)}-${digest}--`;
1094
+ };
1095
+ const isWorkspaceTargetLive = (binding) => Array.from(records.values()).find((record) => isCurrentThreadRecord(record) &&
1096
+ targetMatches(record.target, binding.target));
1097
+ const findLegacyWorkspaceMigrationRecord = (cwd, instanceId, previousInstanceId) => {
1098
+ const processIds = new Set([instanceId, previousInstanceId].filter((value) => !!value));
1099
+ return Array.from(records.values())
1100
+ .filter((record) => {
1101
+ const owner = getRecordOwner(record);
1102
+ const belongsToWorkspace = owner.kind === "leader"
1103
+ ? owner.cwd
1104
+ ? normalizeTelegramWorkspacePath(owner.cwd) === cwd
1105
+ : !!record.instanceId && Array.from(processIds).some((processId) => isSameTelegramProcessInstance(record.instanceId, processId))
1106
+ : owner.kind === "manual-follower" &&
1107
+ (processIds.has(owner.instanceId) ||
1108
+ (!!record.instanceId && processIds.has(record.instanceId)));
1109
+ if (!belongsToWorkspace)
1110
+ return false;
1111
+ const targetBinding = Array.from(workspaceBindings.values()).find((binding) => targetMatches(binding.target, record.target));
1112
+ return !targetBinding || targetBinding.cwd === cwd;
1113
+ })
1114
+ .sort((left, right) => right.updatedAtMs - left.updatedAtMs)[0];
1115
+ };
1116
+ const getPath = () => typeof options.path === "function" ? options.path() : options.path;
1117
+ const getTelegramProfile = () => typeof options.telegramProfile === "function"
1118
+ ? options.telegramProfile()
1119
+ : options.telegramProfile;
1120
+ const scopeOwnerToActiveProfile = (owner) => {
1121
+ const telegramProfile = getTelegramProfile();
1122
+ if (!telegramProfile ||
1123
+ owner.kind === "pending-topic" ||
1124
+ owner.kind === "legacy" ||
1125
+ owner.telegramProfile) {
1126
+ return owner;
1127
+ }
1128
+ return { ...owner, telegramProfile };
1129
+ };
1130
+ const getRecoveryPath = (path) => `${path}.provision-recovery.json`;
1131
+ const readProvisionRecoveries = (path) => {
1132
+ const recoveryPath = getRecoveryPath(path);
1133
+ if (!existsSync(recoveryPath))
1134
+ return {};
1135
+ try {
1136
+ const value = JSON.parse(readFileSync(recoveryPath, "utf8"));
1137
+ return value && typeof value === "object" && !Array.isArray(value)
1138
+ ? value
1139
+ : {};
1140
+ }
1141
+ catch {
1142
+ return {};
1143
+ }
1144
+ };
1145
+ const resetForPath = (path) => {
1146
+ if (loadedPath === path)
1147
+ return;
1148
+ botState = { threadMode: "unknown" };
1149
+ records = new Map();
1150
+ identities = new Map();
1151
+ workspaceBindings = new Map();
1152
+ workspaceRetirements = [];
1153
+ workspaceRetirementCommitInFlight = false;
1154
+ workspaceClaims = new Map();
1155
+ reservations = [];
1156
+ pendingProvisions = [];
1157
+ pendingCleanups = [];
1158
+ syncObservations = [];
1159
+ followerRecoveryHints = new Map();
1160
+ statusSnapshot = {};
1161
+ loaded = false;
1162
+ dirty = false;
1163
+ loadedPath = path;
1164
+ };
1165
+ const loadFromDisk = async () => {
1166
+ const path = getPath();
1167
+ resetForPath(path);
1168
+ if (!existsSync(path)) {
1169
+ botState = { threadMode: "unknown" };
1170
+ records = new Map();
1171
+ identities = new Map();
1172
+ workspaceBindings = new Map();
1173
+ workspaceRetirements = [];
1174
+ workspaceRetirementCommitInFlight = false;
1175
+ reservations = [];
1176
+ pendingProvisions = [];
1177
+ pendingCleanups = [];
1178
+ syncObservations = [];
1179
+ followerRecoveryHints = new Map();
1180
+ loaded = true;
1181
+ return;
1182
+ }
1183
+ const revision = mutationRevision;
1184
+ const content = await readFile(path, "utf8");
1185
+ // A read begun before a local mutation must not replace the newly admitted
1186
+ // binding/cleanup state with its older disk snapshot.
1187
+ if (mutationRevision !== revision || getPath() !== path)
1188
+ return;
1189
+ const rawFile = JSON.parse(content);
1190
+ const file = parseTopicTargetFile(rawFile);
1191
+ followerRecoveryHints = parseFollowerRecoveryHints(rawFile);
1192
+ botState = file.bot;
1193
+ const scopedRecords = file.threads.map((record) => cloneRecord({
1194
+ ...record,
1195
+ owner: scopeOwnerToActiveProfile(getRecordOwner(record)),
1196
+ }));
1197
+ records = new Map(scopedRecords.map((record) => [getRecordOwnerKey(record), record]));
1198
+ identities = new Map((file.identities ?? []).map((identity) => {
1199
+ const owner = scopeOwnerToActiveProfile(getTelegramThreadOwnerFromProfileKey(identity.profileKey));
1200
+ const profileKey = getTelegramThreadOwnerKey(owner);
1201
+ return [
1202
+ profileKey,
1203
+ cloneIdentityRecord({ ...identity, profileKey }),
1204
+ ];
1205
+ }));
1206
+ workspaceBindings = new Map((file.workspaceBindings ?? []).map((binding) => [
1207
+ getWorkspaceBindingMapKey(binding),
1208
+ cloneWorkspaceBinding(binding),
1209
+ ]));
1210
+ workspaceRetirements = (file.workspaceRetirements ?? []).map(cloneWorkspaceRetirementIntent);
1211
+ reconcileWorkspaceSuffixExposure();
1212
+ for (const record of records.values())
1213
+ rememberIdentity(record);
1214
+ const nowMs = getNowMs();
1215
+ reservations = (file.reservations ?? [])
1216
+ .filter((reservation) => reservation.expiresAtMs === undefined ||
1217
+ reservation.expiresAtMs > nowMs)
1218
+ .map((reservation) => ({ ...reservation }));
1219
+ const recoveries = readProvisionRecoveries(path);
1220
+ pendingProvisions = (file.pendingProvisions ?? [])
1221
+ .filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
1222
+ .map((provision) => {
1223
+ const recovery = recoveries[provision.id];
1224
+ const recoveryMatches = recovery?.instanceId === provision.instanceId &&
1225
+ recovery.profileKey === provision.profileKey &&
1226
+ recovery.leaderEpoch === provision.leaderEpoch &&
1227
+ Number.isInteger(recovery.target?.threadId);
1228
+ return {
1229
+ ...provision,
1230
+ ...(recoveryMatches
1231
+ ? { target: { ...recovery.target }, status: "ambiguous" }
1232
+ : provision.target
1233
+ ? { target: { ...provision.target } }
1234
+ : {}),
1235
+ };
1236
+ });
1237
+ pendingCleanups = (file.pendingCleanups ?? []).map((intent) => ({
1238
+ ...intent,
1239
+ target: { ...intent.target },
1240
+ }));
1241
+ syncObservations = (file.syncObservations ?? []).map((observation) => ({
1242
+ ...observation,
1243
+ target: { ...observation.target },
1244
+ }));
1245
+ loaded = true;
1246
+ dirty = false;
1247
+ };
1248
+ const markDirty = () => {
1249
+ loaded = true;
1250
+ dirty = true;
1251
+ mutationRevision += 1;
1252
+ };
1253
+ const persistSnapshot = (invalidation) => {
1254
+ const persist = persistQueue.then(async () => {
1255
+ const path = getPath();
1256
+ if (loadedPath !== path && !dirty)
1257
+ resetForPath(path);
1258
+ if (options.canPersist && !options.canPersist()) {
1259
+ if (!invalidation)
1260
+ await loadFromDisk();
1261
+ return false;
1262
+ }
1263
+ if (!dirty || !loaded)
1264
+ await loadFromDisk();
1265
+ if (invalidation && !invalidation.isCurrent())
1266
+ return false;
1267
+ const nowMs = getNowMs();
1268
+ reservations = reservations.filter((reservation) => reservation.expiresAtMs === undefined ||
1269
+ reservation.expiresAtMs > nowMs);
1270
+ pendingProvisions = pendingProvisions.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs));
1271
+ const currentRecords = Array.from(records.values())
1272
+ .filter(isPersistedThreadRecord)
1273
+ .map(cloneRecord);
1274
+ records = new Map(currentRecords.map((record) => [
1275
+ getRecordOwnerKey(record),
1276
+ cloneRecord(record),
1277
+ ]));
1278
+ const persistedRevision = mutationRevision;
1279
+ const persistedStatusRevision = statusRevision;
1280
+ const file = {
1281
+ version: 1,
1282
+ source: "snapshot",
1283
+ writtenAtMs: nowMs,
1284
+ bot: botState,
1285
+ ...statusSnapshot,
1286
+ identities: Array.from(identities.values()).map(cloneIdentityRecord),
1287
+ workspaceBindings: Array.from(workspaceBindings.values()).map(cloneWorkspaceBinding),
1288
+ workspaceRetirements: workspaceRetirements.map(cloneWorkspaceRetirementIntent),
1289
+ reservations: reservations.map((reservation) => ({ ...reservation })),
1290
+ pendingProvisions: pendingProvisions.map((provision) => ({
1291
+ ...provision,
1292
+ ...(provision.target ? { target: { ...provision.target } } : {}),
1293
+ })),
1294
+ pendingCleanups: pendingCleanups.map((intent) => ({
1295
+ ...intent,
1296
+ target: { ...intent.target },
1297
+ })),
1298
+ syncObservations: syncObservations.map((observation) => ({
1299
+ ...observation,
1300
+ target: { ...observation.target },
1301
+ })),
1302
+ threads: currentRecords.map((record) => {
1303
+ const { profileKey: _profileKey, ...serialized } = record;
1304
+ return serialized;
1305
+ }),
1306
+ };
1307
+ if (invalidation) {
1308
+ const record = file.threads.find((record) => targetMatches(record.target, invalidation.target));
1309
+ if (!record)
1310
+ return false;
1311
+ file.threads = file.threads.filter((candidate) => candidate !== record);
1312
+ file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
1313
+ file.syncObservations.push({
1314
+ target: { ...record.target },
1315
+ syncStatus: "deleted",
1316
+ observedAtMs: nowMs,
1317
+ ...(record.instanceId ? { instanceId: record.instanceId } : {}),
1318
+ ...(record.slot ? { slot: record.slot } : {}),
1319
+ lastSyncError: invalidation.lastSyncError,
1320
+ lastReconcileAction: "mark-stale",
1321
+ });
1322
+ }
1323
+ let persistedSemanticSnapshot;
1324
+ try {
1325
+ persistedSemanticSnapshot = getTelegramStateSemanticSnapshot(JSON.parse(await readFile(path, "utf8")));
1326
+ }
1327
+ catch {
1328
+ /* missing or invalid snapshots must be replaced */
1329
+ }
1330
+ // Normalize optional fields to wire JSON; object key order is not a state change.
1331
+ if (isDeepStrictEqual(persistedSemanticSnapshot, getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file)))) &&
1332
+ mutationRevision === persistedRevision &&
1333
+ statusRevision === persistedStatusRevision) {
1334
+ loadedPath = path;
1335
+ loaded = true;
1336
+ dirty = false;
1337
+ return true;
1338
+ }
1339
+ await mkdir(dirname(path), { recursive: true });
1340
+ const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
1341
+ await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
1342
+ encoding: "utf8",
1343
+ mode: 0o600,
1344
+ });
1345
+ await chmod(tempPath, 0o600);
1346
+ try {
1347
+ if (invalidation) {
1348
+ let applied = false;
1349
+ const commit = () => {
1350
+ if (getPath() !== path || mutationRevision !== persistedRevision ||
1351
+ statusRevision !== persistedStatusRevision || !invalidation.isCurrent())
1352
+ return;
1353
+ // Fence and rename share one synchronous commit boundary. No stale
1354
+ // invalidation enters the live projection before durable commit.
1355
+ renameSync(tempPath, path);
1356
+ loadedPath = path;
1357
+ records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, invalidation.target)));
1358
+ syncObservations = file.syncObservations;
1359
+ mutationRevision += 1;
1360
+ dirty = false;
1361
+ applied = true;
1362
+ };
1363
+ if (options.commitPersist)
1364
+ options.commitPersist(commit);
1365
+ else if (!options.canPersist || options.canPersist())
1366
+ commit();
1367
+ if (!applied)
1368
+ await unlink(tempPath).catch(() => undefined);
1369
+ return applied;
1370
+ }
1371
+ if (options.commitPersist) {
1372
+ const committed = options.commitPersist(() => {
1373
+ renameSync(tempPath, path);
1374
+ chmodSync(path, 0o600);
1375
+ });
1376
+ if (!committed) {
1377
+ await loadFromDisk();
1378
+ throw new Error("Telegram thread snapshot lost exact transport ownership before commit.");
1379
+ }
1380
+ }
1381
+ else {
1382
+ await rename(tempPath, path);
1383
+ await chmod(path, 0o600);
1384
+ }
1385
+ }
1386
+ catch (error) {
1387
+ await unlink(tempPath).catch(() => undefined);
1388
+ throw error;
1389
+ }
1390
+ loadedPath = path;
1391
+ loaded = true;
1392
+ if (mutationRevision === persistedRevision)
1393
+ dirty = false;
1394
+ return true;
1395
+ });
1396
+ persistQueue = persist.then(() => undefined, () => undefined);
1397
+ return persist;
1398
+ };
1399
+ return {
1400
+ async load() {
1401
+ if (dirty)
1402
+ return;
1403
+ await loadFromDisk();
1404
+ },
1405
+ refresh() {
1406
+ const refresh = persistQueue.then(loadFromDisk);
1407
+ persistQueue = refresh.catch(() => undefined);
1408
+ return refresh;
1409
+ },
1410
+ async persist() {
1411
+ await persistSnapshot();
1412
+ },
1413
+ invalidateTarget(target, isCurrent, lastSyncError) {
1414
+ return persistSnapshot({ target, isCurrent, lastSyncError });
1415
+ },
1416
+ list() {
1417
+ return Array.from(records.values()).map(cloneRecord);
1418
+ },
1419
+ getFollowerRecoveryHintByTarget(target) {
1420
+ const hint = followerRecoveryHints.get(getTargetRecoveryHintKey(target));
1421
+ return hint ? { ...hint } : undefined;
1422
+ },
1423
+ listReservations() {
1424
+ const nowMs = getNowMs();
1425
+ return reservations
1426
+ .filter((reservation) => reservation.expiresAtMs === undefined ||
1427
+ reservation.expiresAtMs > nowMs)
1428
+ .map((reservation) => ({ ...reservation }));
1429
+ },
1430
+ listPendingProvisions() {
1431
+ const nowMs = getNowMs();
1432
+ return pendingProvisions
1433
+ .filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
1434
+ .map((provision) => ({
1435
+ ...provision,
1436
+ ...(provision.target ? { target: { ...provision.target } } : {}),
1437
+ }));
1438
+ },
1439
+ listPendingCleanups() {
1440
+ return pendingCleanups.map((intent) => ({
1441
+ ...intent,
1442
+ target: { ...intent.target },
1443
+ }));
1444
+ },
1445
+ listSyncObservations() {
1446
+ return syncObservations.map((observation) => ({
1447
+ ...observation,
1448
+ target: { ...observation.target },
1449
+ }));
1450
+ },
1451
+ reserveThread(reservation) {
1452
+ const next = { ...reservation };
1453
+ reservations = reservations.filter((existing) => existing.slot !== next.slot &&
1454
+ !targetMatches(existing.target, next.target));
1455
+ reservations.push(next);
1456
+ markDirty();
1457
+ },
1458
+ upsertPendingProvision(provision) {
1459
+ const next = {
1460
+ ...provision,
1461
+ ...(provision.target ? { target: { ...provision.target } } : {}),
1462
+ };
1463
+ pendingProvisions = pendingProvisions.filter((existing) => existing.id !== next.id);
1464
+ pendingProvisions.push(next);
1465
+ markDirty();
1466
+ },
1467
+ async recordPendingProvisionTargetRecovery(provision, target) {
1468
+ const path = getPath();
1469
+ const recoveryPath = getRecoveryPath(path);
1470
+ await mkdir(dirname(recoveryPath), { recursive: true });
1471
+ withTelegramFileTransaction(`${recoveryPath}.transaction`, () => {
1472
+ const recoveries = readProvisionRecoveries(path);
1473
+ recoveries[provision.id] = {
1474
+ instanceId: provision.instanceId,
1475
+ ...(provision.profileKey ? { profileKey: provision.profileKey } : {}),
1476
+ ...(provision.leaderEpoch !== undefined
1477
+ ? { leaderEpoch: provision.leaderEpoch }
1478
+ : {}),
1479
+ target: { ...target },
1480
+ };
1481
+ const tempPath = `${recoveryPath}.${process.pid}.${randomUUID()}.tmp`;
1482
+ writeFileSync(tempPath, `${JSON.stringify(recoveries, null, 2)}\n`, {
1483
+ encoding: "utf8",
1484
+ mode: 0o600,
1485
+ });
1486
+ renameSync(tempPath, recoveryPath);
1487
+ chmodSync(recoveryPath, 0o600);
1488
+ });
1489
+ const current = pendingProvisions.find((entry) => entry.id === provision.id &&
1490
+ entry.instanceId === provision.instanceId &&
1491
+ entry.profileKey === provision.profileKey &&
1492
+ entry.leaderEpoch === provision.leaderEpoch);
1493
+ if (!current)
1494
+ return false;
1495
+ current.target = { ...target };
1496
+ current.status = "ambiguous";
1497
+ return true;
1498
+ },
1499
+ removePendingProvision(id) {
1500
+ const before = pendingProvisions.length;
1501
+ pendingProvisions = pendingProvisions.filter((provision) => provision.id !== id);
1502
+ const changed = pendingProvisions.length !== before;
1503
+ if (changed)
1504
+ markDirty();
1505
+ return changed;
1506
+ },
1507
+ upsertPendingCleanup(intent) {
1508
+ const next = { ...intent, target: { ...intent.target } };
1509
+ pendingCleanups = pendingCleanups.filter((existing) => existing.id !== next.id);
1510
+ pendingCleanups.push(next);
1511
+ markDirty();
1512
+ },
1513
+ removePendingCleanup(id) {
1514
+ const before = pendingCleanups.length;
1515
+ pendingCleanups = pendingCleanups.filter((intent) => intent.id !== id);
1516
+ const changed = pendingCleanups.length !== before;
1517
+ if (changed)
1518
+ markDirty();
1519
+ return changed;
1520
+ },
1521
+ getBotState() {
1522
+ return Object.fromEntries(Object.entries(botState).filter(([, value]) => value !== undefined));
1523
+ },
1524
+ setBotState(state) {
1525
+ botState = { ...botState, ...state };
1526
+ markDirty();
1527
+ },
1528
+ setStatusSnapshot(snapshot) {
1529
+ if (!loadedPath)
1530
+ loadedPath = getPath();
1531
+ statusSnapshot = { ...snapshot };
1532
+ statusRevision += 1;
1533
+ },
1534
+ getByProfileKey(profileKey) {
1535
+ const ownerKey = getTelegramThreadOwnerKey(getTelegramThreadOwnerFromProfileKey(profileKey));
1536
+ const record = records.get(ownerKey) ?? records.get(profileKey);
1537
+ return record ? cloneRecord(record) : undefined;
1538
+ },
1539
+ getActiveByInstanceId(instanceId) {
1540
+ for (const record of records.values()) {
1541
+ if (record.instanceId !== instanceId)
1542
+ continue;
1543
+ if (record.status !== "active" && record.status !== "starting")
1544
+ continue;
1545
+ return cloneRecord(record);
1546
+ }
1547
+ return undefined;
1548
+ },
1549
+ getIdentityByProfileKey(profileKey) {
1550
+ const ownerKey = getTelegramThreadOwnerKey(getTelegramThreadOwnerFromProfileKey(profileKey));
1551
+ const identity = identities.get(ownerKey) ?? identities.get(profileKey);
1552
+ return identity ? cloneIdentityRecord(identity) : undefined;
1553
+ },
1554
+ forgetIdentityByProfileKey(profileKey) {
1555
+ const ownerKey = getTelegramThreadOwnerKey(getTelegramThreadOwnerFromProfileKey(profileKey));
1556
+ const removedOwner = identities.delete(ownerKey);
1557
+ const removedProfile = identities.delete(profileKey);
1558
+ if (!removedOwner && !removedProfile)
1559
+ return false;
1560
+ markDirty();
1561
+ return true;
1562
+ },
1563
+ listWorkspaceBindings() {
1564
+ return Array.from(workspaceBindings.values()).map(cloneWorkspaceBinding);
1565
+ },
1566
+ listWorkspaceRetirementIntents() {
1567
+ return workspaceRetirements.map(cloneWorkspaceRetirementIntent);
1568
+ },
1569
+ commitWorkspaceJournalEvidence(expected, journalBindingKeys, complete) {
1570
+ if (workspaceRetirementCommitInFlight || hasWorkspaceRetirementConflict(expected)) {
1571
+ return undefined;
1572
+ }
1573
+ const key = getWorkspaceBindingMapKey(expected);
1574
+ const current = workspaceBindings.get(key);
1575
+ if (!current || !isDeepStrictEqual(current, expected) ||
1576
+ !journalBindingKeys.every((bindingKey) => typeof bindingKey === "string" && bindingKey.length > 0 && bindingKey.length <= 512))
1577
+ return undefined;
1578
+ const keys = Array.from(new Set(journalBindingKeys));
1579
+ if ((current.journalBindingsComplete === true) === complete &&
1580
+ isDeepStrictEqual(current.journalBindingKeys ?? [], keys)) {
1581
+ return cloneWorkspaceBinding(current);
1582
+ }
1583
+ const next = { ...current, updatedAtMs: getNowMs() };
1584
+ if (keys.length)
1585
+ next.journalBindingKeys = keys;
1586
+ else
1587
+ delete next.journalBindingKeys;
1588
+ if (complete)
1589
+ next.journalBindingsComplete = true;
1590
+ else
1591
+ delete next.journalBindingsComplete;
1592
+ workspaceBindings.set(key, next);
1593
+ markDirty();
1594
+ return cloneWorkspaceBinding(next);
1595
+ },
1596
+ upsertWorkspaceRetirementIntent(intent) {
1597
+ const next = normalizeWorkspaceRetirementIntent(intent);
1598
+ if (!next)
1599
+ return false;
1600
+ const current = workspaceBindings.get(getWorkspaceBindingMapKey(next.binding));
1601
+ if (!current || !isDeepStrictEqual(current, next.binding))
1602
+ return false;
1603
+ const sameId = workspaceRetirements.find((candidate) => candidate.id === next.id);
1604
+ if (sameId)
1605
+ return isDeepStrictEqual(sameId, next);
1606
+ if (workspaceRetirements.some((candidate) => candidate.binding.bindingKey === next.binding.bindingKey ||
1607
+ candidate.binding.slot === next.binding.slot ||
1608
+ targetMatches(candidate.binding.target, next.binding.target)))
1609
+ return false;
1610
+ workspaceRetirements.push(cloneWorkspaceRetirementIntent(next));
1611
+ markDirty();
1612
+ return true;
1613
+ },
1614
+ removeWorkspaceRetirementIntent(expected) {
1615
+ const index = workspaceRetirements.findIndex((candidate) => isDeepStrictEqual(candidate, expected));
1616
+ if (index < 0)
1617
+ return false;
1618
+ workspaceRetirements.splice(index, 1);
1619
+ markDirty();
1620
+ return true;
1621
+ },
1622
+ async replaceWorkspaceRetirementIntent(expected, replacement, isCurrent) {
1623
+ if (workspaceRetirementCommitInFlight || !isCurrent())
1624
+ return false;
1625
+ const previous = normalizeWorkspaceRetirementIntent(expected);
1626
+ const next = normalizeWorkspaceRetirementIntent(replacement);
1627
+ if (!previous || !next || previous.id !== next.id ||
1628
+ previous.reason !== next.reason || previous.profileKey !== next.profileKey ||
1629
+ previous.requestedAtMs !== next.requestedAtMs ||
1630
+ !isDeepStrictEqual(previous.binding, next.binding))
1631
+ return false;
1632
+ const binding = workspaceBindings.get(getWorkspaceBindingMapKey(previous.binding));
1633
+ const intentIndex = workspaceRetirements.findIndex((candidate) => isDeepStrictEqual(candidate, previous));
1634
+ if (!binding || !isDeepStrictEqual(binding, previous.binding) || intentIndex < 0) {
1635
+ return false;
1636
+ }
1637
+ if (isDeepStrictEqual(previous, next))
1638
+ return true;
1639
+ workspaceRetirementCommitInFlight = true;
1640
+ workspaceRetirements[intentIndex] = cloneWorkspaceRetirementIntent(next);
1641
+ markDirty();
1642
+ const restoreInMemory = () => {
1643
+ workspaceRetirements = workspaceRetirements.filter((candidate) => candidate.id !== previous.id);
1644
+ workspaceRetirements.push(cloneWorkspaceRetirementIntent(previous));
1645
+ markDirty();
1646
+ };
1647
+ try {
1648
+ if (!isCurrent()) {
1649
+ restoreInMemory();
1650
+ return false;
1651
+ }
1652
+ const persisted = await persistSnapshot();
1653
+ if (!persisted) {
1654
+ restoreInMemory();
1655
+ return false;
1656
+ }
1657
+ return true;
1658
+ }
1659
+ catch (error) {
1660
+ try {
1661
+ await loadFromDisk();
1662
+ }
1663
+ catch {
1664
+ restoreInMemory();
1665
+ throw error;
1666
+ }
1667
+ if (workspaceRetirements.some((candidate) => isDeepStrictEqual(candidate, next))) {
1668
+ return true;
1669
+ }
1670
+ if (!workspaceRetirements.some((candidate) => isDeepStrictEqual(candidate, previous))) {
1671
+ restoreInMemory();
1672
+ }
1673
+ throw error;
1674
+ }
1675
+ finally {
1676
+ workspaceRetirementCommitInFlight = false;
1677
+ }
1678
+ },
1679
+ async commitInactiveWorkspaceCleanup(expected, isCurrent) {
1680
+ if (workspaceRetirementCommitInFlight || !isCurrent())
1681
+ return false;
1682
+ const cleanupSnapshot = "bindingUpdatedAtMs" in expected ? expected : undefined;
1683
+ const normalized = cleanupSnapshot ? undefined : normalizeWorkspaceBindingRecord(expected);
1684
+ const cleanupSessionId = cleanupSnapshot?.sessionId === undefined
1685
+ ? undefined
1686
+ : normalizeTelegramSessionId(cleanupSnapshot.sessionId);
1687
+ const cleanupSessionKey = cleanupSessionId
1688
+ ? createTelegramSessionKey(cleanupSessionId)
1689
+ : undefined;
1690
+ if (cleanupSnapshot && (!cleanupSnapshot.cwd || !cleanupSnapshot.workspaceKey ||
1691
+ !cleanupSnapshot.instanceSlot || !cleanupSnapshot.slot || !cleanupSnapshot.bindingKey ||
1692
+ ((cleanupSnapshot.sessionId !== undefined || cleanupSnapshot.sessionKey !== undefined) &&
1693
+ (!cleanupSessionId || cleanupSnapshot.sessionKey !== cleanupSessionKey)) ||
1694
+ !Number.isSafeInteger(cleanupSnapshot.inactiveSinceMs) ||
1695
+ !Number.isSafeInteger(cleanupSnapshot.bindingUpdatedAtMs) ||
1696
+ !Number.isSafeInteger(cleanupSnapshot.target.chatId) ||
1697
+ !Number.isSafeInteger(cleanupSnapshot.target.threadId) || cleanupSnapshot.target.threadId <= 0))
1698
+ return false;
1699
+ if (!cleanupSnapshot && (!normalized?.slot || normalized.inactiveSinceMs === undefined))
1700
+ return false;
1701
+ const mapKey = getWorkspaceBindingMapKey((cleanupSnapshot ?? normalized));
1702
+ const binding = workspaceBindings.get(mapKey);
1703
+ if (!binding)
1704
+ return isCurrent();
1705
+ const exact = cleanupSnapshot ? binding.cwd === cleanupSnapshot.cwd &&
1706
+ binding.workspaceKey === cleanupSnapshot.workspaceKey &&
1707
+ binding.sessionId === cleanupSessionId && binding.sessionKey === cleanupSessionKey &&
1708
+ binding.instanceSlot === cleanupSnapshot.instanceSlot && binding.slot === cleanupSnapshot.slot &&
1709
+ binding.bindingKey === cleanupSnapshot.bindingKey && targetMatches(binding.target, cleanupSnapshot.target) &&
1710
+ binding.inactiveSinceMs === cleanupSnapshot.inactiveSinceMs &&
1711
+ binding.updatedAtMs === cleanupSnapshot.bindingUpdatedAtMs : isDeepStrictEqual(binding, normalized);
1712
+ if (!exact)
1713
+ return false;
1714
+ const nowMs = getNowMs();
1715
+ const targetOrSlotMatches = (candidate) => (candidate.target && targetMatches(candidate.target, binding.target)) || candidate.slot === binding.slot;
1716
+ const protectedLocally = Array.from(records.values()).some((record) => isCurrentThreadRecord(record) && targetOrSlotMatches(record)) ||
1717
+ Array.from(workspaceClaims.values()).some((claim) => claim.identity.bindingKey === binding.bindingKey || claim.identity.slot === binding.slot) ||
1718
+ reservations.some((reservation) => (reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) && targetOrSlotMatches(reservation)) ||
1719
+ pendingProvisions.some((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs) && targetOrSlotMatches(provision)) ||
1720
+ pendingCleanups.some(targetOrSlotMatches) || workspaceRetirements.some((intent) => intent.binding.bindingKey === binding.bindingKey || targetOrSlotMatches(intent.binding));
1721
+ if (protectedLocally)
1722
+ return false;
1723
+ workspaceRetirementCommitInFlight = true;
1724
+ workspaceBindings.delete(mapKey);
1725
+ markDirty();
1726
+ const restore = () => { workspaceBindings.set(mapKey, cloneWorkspaceBinding(binding)); markDirty(); };
1727
+ try {
1728
+ if (!isCurrent()) {
1729
+ restore();
1730
+ return false;
1731
+ }
1732
+ if (!await persistSnapshot()) {
1733
+ restore();
1734
+ return false;
1735
+ }
1736
+ return true;
1737
+ }
1738
+ catch (error) {
1739
+ try {
1740
+ await loadFromDisk();
1741
+ }
1742
+ catch {
1743
+ restore();
1744
+ throw error;
1745
+ }
1746
+ if (!workspaceBindings.has(mapKey))
1747
+ return true;
1748
+ if (!isDeepStrictEqual(workspaceBindings.get(mapKey), binding))
1749
+ restore();
1750
+ throw error;
1751
+ }
1752
+ finally {
1753
+ workspaceRetirementCommitInFlight = false;
1754
+ }
1755
+ },
1756
+ async commitWorkspaceRetirement(expected, isCurrent) {
1757
+ if (workspaceRetirementCommitInFlight || !isCurrent())
1758
+ return false;
1759
+ const normalized = normalizeWorkspaceRetirementIntent(expected);
1760
+ if (!normalized)
1761
+ return false;
1762
+ const mapKey = getWorkspaceBindingMapKey(normalized.binding);
1763
+ const binding = workspaceBindings.get(mapKey);
1764
+ const intentIndex = workspaceRetirements.findIndex((candidate) => isDeepStrictEqual(candidate, normalized));
1765
+ if (!binding || !isDeepStrictEqual(binding, normalized.binding) || intentIndex < 0) {
1766
+ return false;
1767
+ }
1768
+ const nowMs = getNowMs();
1769
+ const targetOrSlotMatches = (candidate) => (candidate.target && targetMatches(candidate.target, binding.target)) ||
1770
+ candidate.slot === binding.slot;
1771
+ const locallyProtected = Array.from(records.values()).some((record) => isCurrentThreadRecord(record) && targetOrSlotMatches(record)) ||
1772
+ Array.from(workspaceClaims.values()).some((claim) => claim.identity.bindingKey === binding.bindingKey ||
1773
+ claim.identity.slot === binding.slot) ||
1774
+ reservations.some((reservation) => (reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) &&
1775
+ targetOrSlotMatches(reservation)) ||
1776
+ pendingProvisions.some((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs) &&
1777
+ targetOrSlotMatches(provision)) ||
1778
+ pendingCleanups.some(targetOrSlotMatches) ||
1779
+ workspaceRetirements.some((candidate, index) => index !== intentIndex &&
1780
+ (candidate.binding.bindingKey === binding.bindingKey ||
1781
+ targetOrSlotMatches(candidate.binding)));
1782
+ if (locallyProtected)
1783
+ return false;
1784
+ const previousRetirements = workspaceRetirements.map(cloneWorkspaceRetirementIntent);
1785
+ workspaceRetirementCommitInFlight = true;
1786
+ workspaceBindings.delete(mapKey);
1787
+ workspaceRetirements.splice(intentIndex, 1);
1788
+ markDirty();
1789
+ const restoreInMemory = () => {
1790
+ workspaceBindings.set(mapKey, cloneWorkspaceBinding(binding));
1791
+ workspaceRetirements = previousRetirements.map(cloneWorkspaceRetirementIntent);
1792
+ markDirty();
1793
+ };
1794
+ try {
1795
+ if (!isCurrent()) {
1796
+ restoreInMemory();
1797
+ return false;
1798
+ }
1799
+ const persisted = await persistSnapshot();
1800
+ if (!persisted) {
1801
+ restoreInMemory();
1802
+ return false;
1803
+ }
1804
+ return true;
1805
+ }
1806
+ catch (error) {
1807
+ try {
1808
+ await loadFromDisk();
1809
+ }
1810
+ catch {
1811
+ restoreInMemory();
1812
+ throw error;
1813
+ }
1814
+ const committed = !workspaceBindings.has(mapKey) &&
1815
+ !workspaceRetirements.some((candidate) => candidate.id === normalized.id);
1816
+ if (committed)
1817
+ return true;
1818
+ if (!workspaceBindings.has(mapKey) ||
1819
+ !workspaceRetirements.some((candidate) => candidate.id === normalized.id)) {
1820
+ restoreInMemory();
1821
+ }
1822
+ throw error;
1823
+ }
1824
+ finally {
1825
+ workspaceRetirementCommitInFlight = false;
1826
+ }
1827
+ },
1828
+ captureWorkspaceSlotOccupancy(getExternalProtection, options) {
1829
+ const nowMs = getNowMs();
1830
+ const bindings = Array.from(workspaceBindings.values()).map((binding) => {
1831
+ const slot = binding.slot?.toLowerCase() ?? "";
1832
+ const targetOrSlotMatches = (candidate) => (candidate.target && targetMatches(candidate.target, binding.target)) ||
1833
+ (!!binding.slot && candidate.slot === binding.slot);
1834
+ const locallyProtected = Array.from(records.values()).some((record) => isCurrentThreadRecord(record) && targetOrSlotMatches(record)) ||
1835
+ Array.from(workspaceClaims.values()).some((claim) => claim.identity.bindingKey === binding.bindingKey ||
1836
+ (!!binding.slot && claim.identity.slot === binding.slot)) ||
1837
+ reservations.some((reservation) => (reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) &&
1838
+ targetOrSlotMatches(reservation)) ||
1839
+ pendingProvisions.some((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs) &&
1840
+ targetOrSlotMatches(provision)) ||
1841
+ pendingCleanups.some((intent) => targetOrSlotMatches(intent)) ||
1842
+ workspaceRetirements.some((intent) => !isDeepStrictEqual(intent, options?.expectedRetirement) &&
1843
+ (intent.binding.bindingKey === binding.bindingKey ||
1844
+ targetOrSlotMatches(intent.binding)));
1845
+ const externalProtection = getExternalProtection(cloneWorkspaceBinding(binding));
1846
+ const externalStates = [
1847
+ externalProtection.liveOwner,
1848
+ externalProtection.acceptedWork,
1849
+ externalProtection.deliveryAuthority,
1850
+ ];
1851
+ const hasValidInactivity = binding.inactiveSinceMs !== undefined &&
1852
+ Number.isFinite(binding.inactiveSinceMs) &&
1853
+ binding.inactiveSinceMs >= 0 && binding.inactiveSinceMs <= nowMs;
1854
+ const protection = locallyProtected || externalStates.includes("protected")
1855
+ ? "protected"
1856
+ : externalStates.every((state) => state === "clear") && hasValidInactivity
1857
+ ? "eligible"
1858
+ : "unknown";
1859
+ return {
1860
+ bindingKey: binding.bindingKey,
1861
+ slot,
1862
+ ...(binding.inactiveSinceMs !== undefined
1863
+ ? { inactiveSinceMs: binding.inactiveSinceMs }
1864
+ : {}),
1865
+ protection,
1866
+ };
1867
+ });
1868
+ const localReservedSlots = [
1869
+ ...Array.from(workspaceClaims.values()).map((claim) => claim.identity.slot),
1870
+ ...Array.from(records.values()).filter(isCurrentThreadRecord).map((record) => record.slot),
1871
+ ...reservations.filter((reservation) => reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs).map((reservation) => reservation.slot),
1872
+ ...pendingProvisions.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs)).map((provision) => provision.slot),
1873
+ ].filter((slot) => !!slot && /^[A-Z]$/u.test(slot))
1874
+ .map((slot) => slot.toLowerCase());
1875
+ const externalReservedSlots = captureExternalReservedSlots();
1876
+ const reservedSlots = externalReservedSlots
1877
+ ? [
1878
+ ...localReservedSlots,
1879
+ ...externalReservedSlots.map((slot) => slot.toLowerCase()),
1880
+ ]
1881
+ : [...localReservedSlots, "invalid"];
1882
+ return { bindings, reservedSlots };
1883
+ },
1884
+ hasWorkspaceBinding(cwd, sessionId) {
1885
+ const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
1886
+ const normalizedSessionId = sessionId === undefined
1887
+ ? undefined
1888
+ : normalizeTelegramSessionId(sessionId);
1889
+ if (!normalizedCwd || (sessionId !== undefined && !normalizedSessionId)) {
1890
+ return false;
1891
+ }
1892
+ return Array.from(workspaceBindings.values()).some((binding) => binding.cwd === normalizedCwd &&
1893
+ binding.sessionId === normalizedSessionId);
1894
+ },
1895
+ setWorkspaceDisplayTitle(expected, title) {
1896
+ if (hasWorkspaceRetirementConflict(expected))
1897
+ return false;
1898
+ const key = getWorkspaceBindingMapKey(expected);
1899
+ const current = workspaceBindings.get(key);
1900
+ if (!current || !isDeepStrictEqual(current, expected) || !title.trim() ||
1901
+ title.length > 128)
1902
+ return false;
1903
+ if (current.displayTitle === title)
1904
+ return true;
1905
+ workspaceBindings.set(key, { ...current, displayTitle: title });
1906
+ markDirty();
1907
+ return true;
1908
+ },
1909
+ markWorkspaceBindingInactiveByTarget(target, inactiveSinceMs = getNowMs()) {
1910
+ if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0)
1911
+ return false;
1912
+ for (const [key, binding] of workspaceBindings) {
1913
+ if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined)
1914
+ continue;
1915
+ workspaceBindings.set(key, { ...binding, inactiveSinceMs });
1916
+ markDirty();
1917
+ return true;
1918
+ }
1919
+ return false;
1920
+ },
1921
+ markWorkspaceBindingActiveByTarget(target) {
1922
+ if (hasWorkspaceRetirementConflict({ target }))
1923
+ return false;
1924
+ for (const [key, binding] of workspaceBindings) {
1925
+ if (!targetMatches(binding.target, target) || binding.inactiveSinceMs === undefined)
1926
+ continue;
1927
+ const next = { ...binding };
1928
+ delete next.inactiveSinceMs;
1929
+ workspaceBindings.set(key, next);
1930
+ markDirty();
1931
+ return true;
1932
+ }
1933
+ return false;
1934
+ },
1935
+ getWorkspaceBinding(cwd, instanceSlot = "a", sessionId) {
1936
+ const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
1937
+ const normalizedSessionId = sessionId === undefined
1938
+ ? undefined
1939
+ : normalizeTelegramSessionId(sessionId);
1940
+ if (!normalizedCwd || !/^[a-z]+$/u.test(instanceSlot) ||
1941
+ (sessionId !== undefined && !normalizedSessionId))
1942
+ return undefined;
1943
+ const mapKey = getWorkspaceBindingMapKey({
1944
+ cwd: normalizedCwd,
1945
+ instanceSlot,
1946
+ ...(normalizedSessionId ? { sessionId: normalizedSessionId } : {}),
1947
+ });
1948
+ const binding = workspaceBindings.get(mapKey);
1949
+ return binding ? cloneWorkspaceBinding(binding) : undefined;
1950
+ },
1951
+ claimWorkspaceIdentity(cwd, instanceId, previousInstanceId, options) {
1952
+ if (workspaceRetirementCommitInFlight)
1953
+ return undefined;
1954
+ const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
1955
+ const normalizedSessionId = options?.sessionId === undefined
1956
+ ? undefined
1957
+ : normalizeTelegramSessionId(options.sessionId);
1958
+ if (!normalizedCwd || !instanceId ||
1959
+ (options?.sessionId !== undefined && !normalizedSessionId) ||
1960
+ hasWorkspaceRetirementConflict({ cwd: normalizedCwd }))
1961
+ return undefined;
1962
+ const externalReservedSlots = captureExternalReservedSlots();
1963
+ if (!externalReservedSlots) {
1964
+ options?.onCapacityUnavailable?.();
1965
+ return undefined;
1966
+ }
1967
+ const externalReservedSlotKeys = externalReservedSlots.map((slot) => slot.toLowerCase());
1968
+ for (const claim of workspaceClaims.values()) {
1969
+ if (claim.instanceId !== instanceId)
1970
+ continue;
1971
+ if (claim.identity.cwd !== normalizedCwd ||
1972
+ claim.identity.sessionId !== normalizedSessionId ||
1973
+ !claim.identity.slot ||
1974
+ externalReservedSlotKeys.includes(claim.identity.slot.toLowerCase())) {
1975
+ return undefined;
1976
+ }
1977
+ if (options?.existingBindingOnly &&
1978
+ !workspaceBindings.has(getWorkspaceBindingMapKey(claim.identity)))
1979
+ return undefined;
1980
+ return { ...claim.identity };
1981
+ }
1982
+ const workspaceKey = resolveWorkspaceKey(normalizedCwd);
1983
+ if (!workspaceKey)
1984
+ return undefined;
1985
+ let legacyRecord = normalizedSessionId
1986
+ ? undefined
1987
+ : findLegacyWorkspaceMigrationRecord(normalizedCwd, instanceId, previousInstanceId);
1988
+ const legacyTarget = legacyRecord?.target;
1989
+ const targetBinding = legacyTarget
1990
+ ? Array.from(workspaceBindings.values()).find((binding) => binding.cwd === normalizedCwd &&
1991
+ targetMatches(binding.target, legacyTarget))
1992
+ : undefined;
1993
+ let capacityUnavailable = false;
1994
+ const claimIdentity = (identity) => {
1995
+ const mapKey = getWorkspaceBindingMapKey(identity);
1996
+ const existingClaim = workspaceClaims.get(mapKey);
1997
+ if (existingClaim) {
1998
+ return existingClaim.instanceId === instanceId
1999
+ ? { ...existingClaim.identity }
2000
+ : undefined;
2001
+ }
2002
+ const binding = workspaceBindings.get(mapKey);
2003
+ const liveRecord = binding
2004
+ ? isWorkspaceTargetLive(binding)
2005
+ : undefined;
2006
+ if (liveRecord &&
2007
+ liveRecord.instanceId !== instanceId &&
2008
+ liveRecord.instanceId !== previousInstanceId) {
2009
+ return undefined;
2010
+ }
2011
+ const retainedTarget = binding?.target ?? legacyRecord?.target;
2012
+ const retainedSlot = binding?.slot ?? legacyRecord?.slot;
2013
+ const otherBindings = Array.from(workspaceBindings.values())
2014
+ .filter((other) => other.bindingKey !== identity.bindingKey && other.slot)
2015
+ .map((other) => ({
2016
+ bindingKey: other.bindingKey,
2017
+ slot: other.slot.toLowerCase(),
2018
+ protection: "unknown",
2019
+ }));
2020
+ const nowMs = getNowMs();
2021
+ const reservedSlots = [
2022
+ ...Array.from(workspaceClaims.values()).map((claim) => claim.identity.slot),
2023
+ ...Array.from(records.values())
2024
+ .filter((record) => isCurrentThreadRecord(record) &&
2025
+ !(retainedTarget && targetMatches(record.target, retainedTarget)))
2026
+ .map((record) => record.slot),
2027
+ ...reservations.filter((reservation) => reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs).map((reservation) => reservation.slot),
2028
+ ...pendingProvisions.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs)).map((provision) => provision.slot),
2029
+ ...externalReservedSlots,
2030
+ ].filter((slot) => !!slot).map((slot) => slot.toLowerCase());
2031
+ const retainedSlotKey = retainedSlot?.toLowerCase();
2032
+ if (retainedSlotKey && reservedSlots.includes(retainedSlotKey))
2033
+ return undefined;
2034
+ const retainedSlotConflicts = !!retainedSlotKey &&
2035
+ otherBindings.some((other) => other.slot === retainedSlotKey);
2036
+ let slot = retainedSlotConflicts
2037
+ ? Array.from(TELEGRAM_WORKSPACE_SLOTS).find((candidate) => !reservedSlots.includes(candidate) &&
2038
+ !otherBindings.some((other) => other.slot === candidate))?.toUpperCase()
2039
+ : retainedSlot;
2040
+ if (!slot) {
2041
+ const allocation = planTelegramWorkspaceSlotAllocation({
2042
+ bindings: otherBindings,
2043
+ reservedSlots,
2044
+ nowMs,
2045
+ });
2046
+ if (allocation.kind === "blocked" && allocation.reason === "invalid-state") {
2047
+ return undefined;
2048
+ }
2049
+ if (allocation.kind !== "free") {
2050
+ capacityUnavailable = true;
2051
+ return undefined;
2052
+ }
2053
+ slot = allocation.slot.toUpperCase();
2054
+ }
2055
+ if (!slot || !/^[A-Z]$/u.test(slot))
2056
+ return undefined;
2057
+ const claimedIdentity = { ...identity, slot };
2058
+ workspaceClaims.set(mapKey, {
2059
+ identity: claimedIdentity,
2060
+ instanceId,
2061
+ });
2062
+ return { ...claimedIdentity };
2063
+ };
2064
+ if (options?.existingBindingOnly) {
2065
+ const candidates = Array.from(workspaceBindings.values())
2066
+ .filter((binding) => binding.cwd === normalizedCwd &&
2067
+ binding.sessionId === normalizedSessionId)
2068
+ .sort((left, right) => left.instanceSlot.length - right.instanceSlot.length ||
2069
+ left.instanceSlot.localeCompare(right.instanceSlot));
2070
+ for (const binding of candidates) {
2071
+ const claimed = claimIdentity({
2072
+ cwd: binding.cwd,
2073
+ workspaceKey: binding.workspaceKey,
2074
+ ...(binding.sessionId && binding.sessionKey
2075
+ ? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
2076
+ : {}),
2077
+ instanceSlot: binding.instanceSlot,
2078
+ bindingKey: binding.bindingKey,
2079
+ });
2080
+ if (claimed)
2081
+ return claimed;
2082
+ }
2083
+ if (!legacyRecord)
2084
+ return undefined;
2085
+ }
2086
+ if (targetBinding) {
2087
+ const claimed = claimIdentity({
2088
+ cwd: targetBinding.cwd,
2089
+ workspaceKey: targetBinding.workspaceKey,
2090
+ instanceSlot: targetBinding.instanceSlot,
2091
+ bindingKey: targetBinding.bindingKey,
2092
+ });
2093
+ if (claimed)
2094
+ return claimed;
2095
+ if (options?.existingBindingOnly)
2096
+ return undefined;
2097
+ // A migrated live peer is not a handoff or a target for another slot.
2098
+ legacyRecord = undefined;
2099
+ }
2100
+ const attemptLimit = workspaceBindings.size + workspaceClaims.size + 1;
2101
+ for (let ordinal = 0; ordinal < attemptLimit; ordinal += 1) {
2102
+ const identity = createTelegramWorkspaceBindingIdentityWithKey(normalizedCwd, workspaceKey, ordinal, normalizedSessionId);
2103
+ if (!identity)
2104
+ return undefined;
2105
+ const mapKey = getWorkspaceBindingMapKey(identity);
2106
+ const binding = workspaceBindings.get(mapKey);
2107
+ if (legacyRecord && binding)
2108
+ continue;
2109
+ const claimed = claimIdentity(identity);
2110
+ if (!claimed)
2111
+ continue;
2112
+ if (legacyRecord && !binding) {
2113
+ workspaceBindings.set(mapKey, {
2114
+ ...identity,
2115
+ target: { ...legacyRecord.target },
2116
+ ...(legacyRecord.threadName
2117
+ ? { threadName: legacyRecord.threadName }
2118
+ : {}),
2119
+ ...(legacyRecord.slot ? { slot: legacyRecord.slot } : {}),
2120
+ updatedAtMs: getNowMs(),
2121
+ });
2122
+ reconcileWorkspaceSuffixExposure();
2123
+ markDirty();
2124
+ }
2125
+ return claimed;
2126
+ }
2127
+ if (capacityUnavailable)
2128
+ options?.onCapacityUnavailable?.();
2129
+ return undefined;
2130
+ },
2131
+ releaseWorkspaceClaim(instanceId) {
2132
+ let released = false;
2133
+ for (const [key, claim] of workspaceClaims) {
2134
+ if (claim.instanceId !== instanceId)
2135
+ continue;
2136
+ workspaceClaims.delete(key);
2137
+ released = true;
2138
+ }
2139
+ return released;
2140
+ },
2141
+ upsertWorkspaceBinding(binding, claimInstanceId) {
2142
+ if (workspaceRetirementCommitInFlight)
2143
+ return undefined;
2144
+ const next = normalizeWorkspaceBindingRecord(binding);
2145
+ if (next && hasWorkspaceRetirementConflict(next))
2146
+ return undefined;
2147
+ if (!next)
2148
+ return undefined;
2149
+ const nextMapKey = getWorkspaceBindingMapKey(next);
2150
+ const claim = workspaceClaims.get(nextMapKey);
2151
+ if (next.slot && Array.from(workspaceBindings.values()).some((existing) => existing.bindingKey !== next.bindingKey && existing.slot === next.slot))
2152
+ return undefined;
2153
+ for (const existing of workspaceBindings.values()) {
2154
+ if (existing.workspaceKey === next.workspaceKey &&
2155
+ existing.cwd !== next.cwd) {
2156
+ return undefined;
2157
+ }
2158
+ }
2159
+ const previous = workspaceBindings.get(nextMapKey);
2160
+ if (previous?.showSlotSuffix)
2161
+ next.showSlotSuffix = true;
2162
+ if (previous?.threadName && !next.threadName) {
2163
+ next.threadName = previous.threadName;
2164
+ }
2165
+ if (previous?.manualThreadName && !next.manualThreadName) {
2166
+ next.manualThreadName = previous.manualThreadName;
2167
+ }
2168
+ if (previous) {
2169
+ const journalBindingKeys = Array.from(new Set([
2170
+ ...(previous.journalBindingKeys ?? []),
2171
+ ...(next.journalBindingKeys ?? []),
2172
+ ]));
2173
+ if (journalBindingKeys.length)
2174
+ next.journalBindingKeys = journalBindingKeys;
2175
+ else
2176
+ delete next.journalBindingKeys;
2177
+ if (previous.journalBindingsComplete)
2178
+ next.journalBindingsComplete = true;
2179
+ else
2180
+ delete next.journalBindingsComplete;
2181
+ }
2182
+ if (previous && !targetMatches(previous.target, next.target)) {
2183
+ delete next.displayTitle;
2184
+ delete next.inactiveSinceMs;
2185
+ }
2186
+ else if (previous) {
2187
+ if (previous.displayTitle)
2188
+ next.displayTitle = previous.displayTitle;
2189
+ if (previous.inactiveSinceMs !== undefined)
2190
+ next.inactiveSinceMs = previous.inactiveSinceMs;
2191
+ }
2192
+ if (claimInstanceId) {
2193
+ if (claim?.instanceId !== claimInstanceId)
2194
+ return undefined;
2195
+ if (next.slot !== undefined && claim.identity.slot !== next.slot)
2196
+ return undefined;
2197
+ next.slot = claim.identity.slot;
2198
+ }
2199
+ else if (claim) {
2200
+ return undefined;
2201
+ }
2202
+ for (const [key, existing] of workspaceBindings) {
2203
+ if (key === nextMapKey)
2204
+ continue;
2205
+ if (targetMatches(existing.target, next.target)) {
2206
+ workspaceBindings.delete(key);
2207
+ }
2208
+ }
2209
+ workspaceBindings.set(nextMapKey, next);
2210
+ reconcileWorkspaceSuffixExposure();
2211
+ if (claim)
2212
+ workspaceClaims.delete(nextMapKey);
2213
+ markDirty();
2214
+ return cloneWorkspaceBinding(next);
2215
+ },
2216
+ upsert(record) {
2217
+ const next = cloneRecord(record);
2218
+ const nextOwnerKey = getRecordOwnerKey(next);
2219
+ const previousRecord = records.get(nextOwnerKey);
2220
+ if (isCurrentThreadRecord(next)) {
2221
+ for (const existing of Array.from(records.values())) {
2222
+ const existingOwnerKey = getRecordOwnerKey(existing);
2223
+ if (existingOwnerKey === nextOwnerKey)
2224
+ continue;
2225
+ if (!targetMatches(existing.target, next.target))
2226
+ continue;
2227
+ records.delete(existingOwnerKey);
2228
+ }
2229
+ }
2230
+ if (next.instanceId &&
2231
+ (next.status === "active" || next.status === "starting")) {
2232
+ for (const existing of records.values()) {
2233
+ if (existing.instanceId !== next.instanceId)
2234
+ continue;
2235
+ if (getRecordOwnerKey(existing) === nextOwnerKey)
2236
+ continue;
2237
+ if (targetMatches(existing.target, next.target))
2238
+ continue;
2239
+ if (existing.status !== "active" && existing.status !== "starting")
2240
+ continue;
2241
+ records.delete(getRecordOwnerKey(existing));
2242
+ }
2243
+ }
2244
+ if (!isPersistedThreadRecord(next)) {
2245
+ rememberIdentity(next);
2246
+ records.delete(nextOwnerKey);
2247
+ markDirty();
2248
+ return cloneRecord(next);
2249
+ }
2250
+ records.set(nextOwnerKey, next);
2251
+ if (!previousRecord ||
2252
+ !targetMatches(previousRecord.target, next.target)) {
2253
+ rememberSlot(next.slot, next.updatedAtMs);
2254
+ }
2255
+ rememberIdentity(next);
2256
+ markDirty();
2257
+ return cloneRecord(next);
2258
+ },
2259
+ markOfflineByInstanceId(instanceId) {
2260
+ let count = 0;
2261
+ for (const record of Array.from(records.values())) {
2262
+ if (record.instanceId !== instanceId ||
2263
+ (record.status !== "active" && record.status !== "starting"))
2264
+ continue;
2265
+ records.delete(getRecordOwnerKey(record));
2266
+ count += 1;
2267
+ }
2268
+ if (count > 0)
2269
+ markDirty();
2270
+ return count;
2271
+ },
2272
+ markStaleByTarget(target, syncStatus = "unknown", lastSyncError) {
2273
+ const record = Array.from(records.values()).find((entry) => targetMatches(entry.target, target));
2274
+ const pending = syncStatus === "deleted" ? pendingProvisions.find((entry) => entry.target && targetMatches(entry.target, target)) : undefined;
2275
+ const source = record ?? pending;
2276
+ if (!source?.target)
2277
+ return false;
2278
+ syncObservations = syncObservations.filter((entry) => !targetMatches(entry.target, target));
2279
+ syncObservations.push({
2280
+ target: { ...source.target }, syncStatus, observedAtMs: getNowMs(),
2281
+ ...(source.instanceId ? { instanceId: source.instanceId } : {}),
2282
+ ...(source.slot ? { slot: source.slot } : {}),
2283
+ ...(lastSyncError ? { lastSyncError } : {}),
2284
+ lastReconcileAction: "mark-stale",
2285
+ });
2286
+ if (record) {
2287
+ rememberIdentity(record);
2288
+ records.delete(getRecordOwnerKey(record));
2289
+ }
2290
+ if (syncStatus === "deleted") {
2291
+ pendingProvisions = pendingProvisions.filter((entry) => !entry.target || !targetMatches(entry.target, target));
2292
+ }
2293
+ markDirty();
2294
+ return true;
2295
+ },
2296
+ markActiveByTarget(target) {
2297
+ const nowMs = getNowMs();
2298
+ for (const record of records.values()) {
2299
+ if (!targetMatches(record.target, target))
2300
+ continue;
2301
+ record.status = "active";
2302
+ record.updatedAtMs = nowMs;
2303
+ record.syncStatus = "open";
2304
+ record.lastSyncObservedAtMs = nowMs;
2305
+ record.lastReconcileAction = "mark-active";
2306
+ delete record.lastError;
2307
+ delete record.lastSyncError;
2308
+ markDirty();
2309
+ return true;
2310
+ }
2311
+ return false;
2312
+ },
2313
+ renameByTarget(target, threadName, options) {
2314
+ if (hasWorkspaceRetirementConflict({ target }))
2315
+ return undefined;
2316
+ const nowMs = getNowMs();
2317
+ const normalizedThreadName = normalizeTelegramTopicTargetThreadName(threadName);
2318
+ if (!normalizedThreadName)
2319
+ return undefined;
2320
+ for (const record of records.values()) {
2321
+ if (!targetMatches(record.target, target))
2322
+ continue;
2323
+ record.manualThreadName = normalizedThreadName;
2324
+ record.updatedAtMs = nowMs;
2325
+ for (const binding of workspaceBindings.values()) {
2326
+ if (!targetMatches(binding.target, target))
2327
+ continue;
2328
+ const previousTitle = binding.displayTitle ?? binding.manualThreadName ??
2329
+ binding.threadName;
2330
+ binding.manualThreadName = normalizedThreadName;
2331
+ binding.displayTitle = options?.updateDisplayTitle === false
2332
+ ? previousTitle : normalizedThreadName;
2333
+ binding.updatedAtMs = nowMs;
2334
+ }
2335
+ markDirty();
2336
+ return cloneRecord(record);
2337
+ }
2338
+ return undefined;
2339
+ },
2340
+ clearManualNameByTarget(target, automaticTitle) {
2341
+ if (hasWorkspaceRetirementConflict({ target }))
2342
+ return undefined;
2343
+ const title = normalizeTelegramTopicTargetThreadName(automaticTitle);
2344
+ if (!title)
2345
+ return undefined;
2346
+ const nowMs = getNowMs();
2347
+ for (const record of records.values()) {
2348
+ if (!targetMatches(record.target, target))
2349
+ continue;
2350
+ delete record.manualThreadName;
2351
+ record.updatedAtMs = nowMs;
2352
+ for (const binding of workspaceBindings.values()) {
2353
+ if (!targetMatches(binding.target, target))
2354
+ continue;
2355
+ delete binding.manualThreadName;
2356
+ binding.displayTitle = title;
2357
+ binding.updatedAtMs = nowMs;
2358
+ }
2359
+ markDirty();
2360
+ return cloneRecord(record);
2361
+ }
2362
+ return undefined;
2363
+ },
2364
+ claimReusableTarget(instanceId, threadName) {
2365
+ const nowMs = getNowMs();
2366
+ const candidates = Array.from(records.values())
2367
+ .filter((record) => {
2368
+ if (record.instanceId)
2369
+ return false;
2370
+ if (!record.slot || !/^[A-Z]$/u.test(record.slot))
2371
+ return false;
2372
+ if (record.slot === "A")
2373
+ return false;
2374
+ if (record.status !== "pending")
2375
+ return false;
2376
+ return !Array.from(records.values()).some((other) => other !== record &&
2377
+ other.slot === record.slot &&
2378
+ (other.status === "active" || other.status === "starting"));
2379
+ })
2380
+ .sort((left, right) => {
2381
+ const leftSlot = left.slot ?? "Z";
2382
+ const rightSlot = right.slot ?? "Z";
2383
+ if (leftSlot !== rightSlot)
2384
+ return leftSlot.localeCompare(rightSlot);
2385
+ return left.createdAtMs - right.createdAtMs;
2386
+ });
2387
+ const record = candidates[0];
2388
+ if (!record)
2389
+ return undefined;
2390
+ record.status = "active";
2391
+ record.instanceId = instanceId;
2392
+ record.updatedAtMs = nowMs;
2393
+ if (!record.threadName &&
2394
+ threadName &&
2395
+ isTelegramTopicThreadNameValidForSlot(threadName, record.slot))
2396
+ record.threadName = threadName;
2397
+ delete record.lastError;
2398
+ rememberIdentity(record);
2399
+ markDirty();
2400
+ return cloneRecord(record);
2401
+ },
2402
+ allocateSlot(profileKey, preferredSlot, workspaceBindingKey, options) {
2403
+ if (workspaceRetirementCommitInFlight)
2404
+ return undefined;
2405
+ const externalReservedSlots = captureExternalReservedSlots();
2406
+ if (!externalReservedSlots)
2407
+ return undefined;
2408
+ const isExternalSlotOccupied = (slot) => externalReservedSlots.includes(slot);
2409
+ const ownerKey = getTelegramThreadOwnerKey(getTelegramThreadOwnerFromProfileKey(profileKey));
2410
+ const existing = records.get(ownerKey) ?? records.get(profileKey);
2411
+ const nowMs = getNowMs();
2412
+ const isWorkspaceSlotOccupied = (slot) => Array.from(workspaceBindings.values()).some((binding) => binding.bindingKey !== workspaceBindingKey && binding.slot === slot);
2413
+ const isWorkspaceClaimSlotOccupied = (slot) => Array.from(workspaceClaims.values()).some((claim) => claim.identity.bindingKey !== workspaceBindingKey &&
2414
+ claim.identity.slot === slot);
2415
+ if (existing?.slot && isCurrentThreadRecord(existing) &&
2416
+ !options?.excludeCurrentRecord) {
2417
+ const bindingConflict = Array.from(workspaceBindings.values()).some((binding) => binding.bindingKey !== workspaceBindingKey &&
2418
+ binding.slot === existing.slot &&
2419
+ !targetMatches(binding.target, existing.target));
2420
+ const claimConflict = Array.from(workspaceClaims.values()).some((claim) => claim.identity.bindingKey !== workspaceBindingKey &&
2421
+ claim.identity.slot === existing.slot &&
2422
+ claim.instanceId !== existing.instanceId);
2423
+ return bindingConflict || claimConflict ||
2424
+ isExternalSlotOccupied(existing.slot) ? undefined : existing.slot;
2425
+ }
2426
+ if (workspaceBindingKey) {
2427
+ const claim = Array.from(workspaceClaims.values()).find((claim) => claim.identity.bindingKey === workspaceBindingKey);
2428
+ const slot = claim?.identity.slot;
2429
+ if (!slot)
2430
+ return undefined;
2431
+ const foreignClaim = Array.from(workspaceClaims.values()).some((other) => other !== claim && other.identity.slot === slot);
2432
+ const foreignBinding = Array.from(workspaceBindings.values()).some((binding) => binding.bindingKey !== workspaceBindingKey && binding.slot === slot);
2433
+ if (foreignClaim || foreignBinding || isExternalSlotOccupied(slot) ||
2434
+ isTelegramTopicTargetSlotOccupied(slot, records, reservations, pendingProvisions, nowMs))
2435
+ return undefined;
2436
+ return slot;
2437
+ }
2438
+ if (preferredSlot &&
2439
+ !isExternalSlotOccupied(preferredSlot) &&
2440
+ !isWorkspaceSlotOccupied(preferredSlot) &&
2441
+ !isWorkspaceClaimSlotOccupied(preferredSlot) &&
2442
+ !isTelegramTopicTargetSlotOccupied(preferredSlot, records, reservations, pendingProvisions, nowMs)) {
2443
+ return preferredSlot;
2444
+ }
2445
+ const next = getNextMonotonicSlot(records, reservations, pendingProvisions, nowMs, botState.lastSlot);
2446
+ if (next && !isExternalSlotOccupied(next) &&
2447
+ !isWorkspaceSlotOccupied(next) &&
2448
+ !isWorkspaceClaimSlotOccupied(next))
2449
+ return next;
2450
+ return Array.from(TELEGRAM_WORKSPACE_SLOTS, (slot) => slot.toUpperCase())
2451
+ .find((slot) => !isExternalSlotOccupied(slot) &&
2452
+ !isWorkspaceSlotOccupied(slot) &&
2453
+ !isWorkspaceClaimSlotOccupied(slot) &&
2454
+ !isTelegramTopicTargetSlotOccupied(slot, records, reservations, pendingProvisions, nowMs));
2455
+ },
2456
+ };
2457
+ }
2458
+ function isTelegramTopicTargetSlotOccupied(slot, records, reservations = [], pendingProvisions = [], nowMs = Date.now()) {
2459
+ for (const record of records.values()) {
2460
+ if (record.slot === slot && isCurrentThreadRecord(record))
2461
+ return true;
2462
+ }
2463
+ for (const reservation of reservations) {
2464
+ if (reservation.expiresAtMs !== undefined &&
2465
+ reservation.expiresAtMs <= nowMs)
2466
+ continue;
2467
+ if (reservation.slot === slot)
2468
+ return true;
2469
+ }
2470
+ for (const provision of pendingProvisions) {
2471
+ if (provision.status !== "ambiguous" &&
2472
+ provision.expiresAtMs !== undefined &&
2473
+ provision.expiresAtMs <= nowMs)
2474
+ continue;
2475
+ if (provision.slot === slot)
2476
+ return true;
2477
+ }
2478
+ return false;
2479
+ }
2480
+ export function normalizeTelegramTopicTargetThreadName(threadName) {
2481
+ return threadName.replace(/\s+/g, " ").trim().slice(0, 96);
2482
+ }
2483
+ function getGraphemeSegments(value) {
2484
+ const segmenter = Intl.Segmenter;
2485
+ if (!segmenter)
2486
+ return Array.from(value);
2487
+ return Array.from(new segmenter(undefined, { granularity: "grapheme" }).segment(value), (part) => part.segment);
2488
+ }
2489
+ export function getTelegramTopicIdentityName(threadName) {
2490
+ return getGraphemeSegments(normalizeTelegramTopicTargetThreadName(threadName))
2491
+ .join("")
2492
+ .trim();
2493
+ }
2494
+ const TELEGRAM_THREAD_NAME_PALETTE = {
2495
+ A: ["Atlas", "Aster", "Aurora", "Anchor", "Ashen"],
2496
+ B: ["Beacon", "Briar", "Boreal", "Birch", "Bison"],
2497
+ C: ["Cedar", "Comet", "Cipher", "Coral", "Cinder"],
2498
+ D: ["Delta", "Dawn", "Drift", "Dune", "Dagger"],
2499
+ E: ["Ember", "Echo", "Eagle", "Eden", "Elder"],
2500
+ F: ["Falcon", "Fjord", "Flint", "Forest", "Fable"],
2501
+ G: ["Grove", "Glade", "Glyph", "Garnet", "Gale"],
2502
+ H: ["Harbor", "Hawk", "Hazel", "Helix", "Haven"],
2503
+ I: ["Iris", "Ivory", "Iron", "Isle", "Idea"],
2504
+ J: ["Jade", "Juno", "Jolt", "Jewel", "Jasper"],
2505
+ K: ["Kite", "Karma", "Kernel", "Kodiak", "Kelp"],
2506
+ L: ["Lumen", "Laurel", "Lynx", "Lotus", "Lagoon"],
2507
+ M: ["Maple", "Meteor", "Meadow", "Marble", "Moss"],
2508
+ N: ["Nimbus", "Nova", "Nectar", "North", "Noble"],
2509
+ O: ["Orion", "Onyx", "Opal", "Orbit", "Olive"],
2510
+ P: ["Pine", "Pulse", "Praxis", "Pebble", "Prism"],
2511
+ Q: ["Quartz", "Quill", "Quasar", "Quest", "Quiver"],
2512
+ R: ["River", "Raven", "Rune", "Reef", "Ridge"],
2513
+ S: ["Spruce", "Solar", "Signal", "Stone", "Sable"],
2514
+ T: ["Timber", "Talon", "Terra", "Torch", "Tide"],
2515
+ U: ["Umber", "Unity", "Ursa", "Uplink", "Ulmus"],
2516
+ V: ["Violet", "Vector", "Vista", "Vale", "Vortex"],
2517
+ W: ["Willow", "Warden", "Wave", "Winter", "Wisp"],
2518
+ X: ["Xenon", "Xylem", "Xavier", "Xylo", "Xerus"],
2519
+ Y: ["Yarrow", "Yonder", "Yukon", "Yale", "Yogi"],
2520
+ Z: ["Zenith", "Zephyr", "Zircon", "Zebra", "Zion"],
2521
+ };
2522
+ export function listOccupiedTelegramThreadIdentities(input) {
2523
+ const occupied = new Set();
2524
+ const add = (threadName) => {
2525
+ if (!threadName)
2526
+ return;
2527
+ occupied.add(getTelegramTopicIdentityName(threadName));
2528
+ };
2529
+ for (const record of input.records) {
2530
+ if (!isCurrentThreadRecord(record))
2531
+ continue;
2532
+ if (input.exceptTarget && targetMatches(record.target, input.exceptTarget))
2533
+ continue;
2534
+ add(record.manualThreadName);
2535
+ add(record.threadName);
2536
+ }
2537
+ for (const binding of input.workspaceBindings ?? []) {
2538
+ if (binding.bindingKey === input.exceptWorkspaceBindingKey)
2539
+ continue;
2540
+ if (input.exceptTarget && targetMatches(binding.target, input.exceptTarget))
2541
+ continue;
2542
+ add(binding.manualThreadName);
2543
+ add(binding.threadName);
2544
+ }
2545
+ for (const pending of input.pendingProvisions ?? []) {
2546
+ if (input.exceptTarget &&
2547
+ pending.target &&
2548
+ targetMatches(pending.target, input.exceptTarget)) {
2549
+ continue;
2550
+ }
2551
+ add(pending.threadName);
2552
+ }
2553
+ return Array.from(occupied);
2554
+ }
2555
+ export function chooseTelegramThreadName(input) {
2556
+ if (!input.slot || !/^[A-Z]$/.test(input.slot))
2557
+ return undefined;
2558
+ const names = TELEGRAM_THREAD_NAME_PALETTE[input.slot];
2559
+ if (!names || names.length === 0)
2560
+ return undefined;
2561
+ const occupied = new Set((input.occupied ?? []).map((name) => getTelegramTopicIdentityName(name)));
2562
+ const start = input.getRandom
2563
+ ? Math.max(0, Math.min(names.length - 1, Math.floor(input.getRandom() * names.length)))
2564
+ : getTelegramThreadNameEntropyIndex(input.entropy, names.length);
2565
+ for (let offset = 0; offset < names.length; offset += 1) {
2566
+ const name = names[(start + offset) % names.length];
2567
+ if (!occupied.has(getTelegramTopicIdentityName(name)))
2568
+ return name;
2569
+ }
2570
+ for (const paletteSlot of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
2571
+ for (const name of TELEGRAM_THREAD_NAME_PALETTE[paletteSlot] ?? []) {
2572
+ if (!occupied.has(getTelegramTopicIdentityName(name)))
2573
+ return name;
2574
+ }
2575
+ }
2576
+ return undefined;
2577
+ }
2578
+ function getTelegramThreadNameLeadingSlot(threadName) {
2579
+ if (!threadName)
2580
+ return undefined;
2581
+ const first = getTelegramTopicIdentityName(threadName)[0];
2582
+ return first && /^[A-Z]$/.test(first) ? first : undefined;
2583
+ }
2584
+ function getNextTelegramThreadNamePaletteSlot(records, fallbackSlot) {
2585
+ let maxCode = "A".charCodeAt(0) - 1;
2586
+ for (const record of records) {
2587
+ if (!isCurrentThreadRecord(record) || !record.threadName)
2588
+ continue;
2589
+ const identity = getTelegramTopicIdentityName(record.threadName);
2590
+ const first = identity[0];
2591
+ if (!first || !/^[A-Z]$/.test(first))
2592
+ continue;
2593
+ maxCode = Math.max(maxCode, first.charCodeAt(0));
2594
+ }
2595
+ if (maxCode < "A".charCodeAt(0))
2596
+ return fallbackSlot;
2597
+ let code = maxCode + 1;
2598
+ if (code > "Z".charCodeAt(0))
2599
+ code = "A".charCodeAt(0);
2600
+ return String.fromCharCode(code);
2601
+ }
2602
+ function getTelegramThreadNameEntropyIndex(entropy, length) {
2603
+ if (length <= 1)
2604
+ return 0;
2605
+ if (typeof entropy === "number" && entropy < 1_000_000_000_000)
2606
+ return 0;
2607
+ const value = entropy === undefined ? "0" : String(entropy);
2608
+ let hash = 2166136261;
2609
+ for (const char of value) {
2610
+ hash ^= char.codePointAt(0) ?? 0;
2611
+ hash = Math.imul(hash, 16777619) >>> 0;
2612
+ }
2613
+ return hash % length;
2614
+ }
2615
+ export function getTelegramTopicThreadNameValidationError(threadName, _slot) {
2616
+ const identity = getTelegramTopicIdentityName(threadName);
2617
+ const reasons = [];
2618
+ if (!identity)
2619
+ reasons.push("it is empty after trimming");
2620
+ if (/\s/.test(identity))
2621
+ reasons.push("it contains spaces");
2622
+ if (/[^A-Za-z]/.test(identity)) {
2623
+ reasons.push("it contains characters outside Latin A-Z letters");
2624
+ }
2625
+ if (!/^[A-Z]/.test(identity)) {
2626
+ reasons.push("it does not start with an uppercase Latin letter");
2627
+ }
2628
+ const genericLabels = new Set(["telegram", "leader", "follower"]);
2629
+ if (genericLabels.has(identity.toLowerCase())) {
2630
+ reasons.push("it is a generic role label");
2631
+ }
2632
+ if (/^[A-Z]$/.test(identity))
2633
+ reasons.push("it is only a bare slot letter");
2634
+ if (reasons.length === 0)
2635
+ return undefined;
2636
+ return `Invalid Telegram instance name: ${reasons.join("; ")}. Use exactly one capitalized Latin word with no spaces, punctuation, emoji, non-Latin letters, or digits; it must not be a generic role label or only a bare slot letter.`;
2637
+ }
2638
+ export function getTelegramManualThreadDisplayNameValidationError(threadName) {
2639
+ const trimmed = threadName.trim();
2640
+ const normalized = trimmed.replace(/\s+/g, " ");
2641
+ const reasons = [];
2642
+ if (!trimmed)
2643
+ reasons.push("it is empty after trimming");
2644
+ if (trimmed && /[^\x20-\x7E]/.test(trimmed)) {
2645
+ reasons.push("it contains characters outside printable ASCII");
2646
+ }
2647
+ if (normalized.length > 96)
2648
+ reasons.push("it is longer than 96 characters");
2649
+ if (/^[A-Z]$/.test(normalized)) {
2650
+ reasons.push("a bare slot letter is reserved for reset to automatic");
2651
+ }
2652
+ if (reasons.length === 0)
2653
+ return undefined;
2654
+ return `Invalid Telegram Thread display name: ${reasons.join("; ")}. Use 1–96 printable ASCII characters.`;
2655
+ }
2656
+ export function isTelegramTopicThreadNameValidForSlot(threadName, slot) {
2657
+ return !getTelegramTopicThreadNameValidationError(threadName, slot);
2658
+ }
2659
+ function applyTopicNameTemplate(template, request, slot) {
2660
+ const threadName = request.threadName?.replace(/\s+/g, " ").trim() || request.profileKey;
2661
+ let result = template
2662
+ .replaceAll("{threadName}", threadName)
2663
+ .replaceAll("{profileKey}", request.profileKey)
2664
+ .replaceAll("{instanceId}", request.instanceId);
2665
+ if (slot)
2666
+ result = result.replaceAll("{slot}", slot);
2667
+ return result;
2668
+ }
2669
+ export function getTelegramTopicName(request, template = "{slot}", slot) {
2670
+ const name = applyTopicNameTemplate(template, request, slot)
2671
+ .replace(/\s+/g, " ")
2672
+ .trim();
2673
+ return (name || slot || "Pi").slice(0, 128);
2674
+ }
2675
+ function asInteger(value) {
2676
+ if (typeof value === "number" && Number.isInteger(value))
2677
+ return value;
2678
+ if (typeof value !== "string" || value.trim() === "")
2679
+ return undefined;
2680
+ const parsed = Number(value);
2681
+ return Number.isInteger(parsed) ? parsed : undefined;
2682
+ }
2683
+ export async function promoteTelegramFollowerBindingToLeader(deps) {
2684
+ const target = deps.target;
2685
+ if (typeof target?.threadId !== "number")
2686
+ return undefined;
2687
+ await deps.store.load();
2688
+ const nowMs = deps.nowMs ?? Date.now();
2689
+ const existing = deps.store
2690
+ .list()
2691
+ .find((record) => record.target.chatId === target.chatId &&
2692
+ record.target.threadId === target.threadId);
2693
+ const workspaceIdentity = deps.cwd
2694
+ ? deps.store.claimWorkspaceIdentity(deps.cwd, deps.instanceId, existing?.instanceId, {
2695
+ existingBindingOnly: true,
2696
+ sessionId: deps.sessionId,
2697
+ })
2698
+ : undefined;
2699
+ const workspaceBinding = workspaceIdentity
2700
+ ? deps.store.getWorkspaceBinding(workspaceIdentity.cwd, workspaceIdentity.instanceSlot, workspaceIdentity.sessionId)
2701
+ : undefined;
2702
+ const exactWorkspaceBinding = workspaceBinding &&
2703
+ targetMatches(workspaceBinding.target, target)
2704
+ ? workspaceBinding : undefined;
2705
+ const slot = existing
2706
+ ? deps.store.allocateSlot(existing.profileKey)
2707
+ : exactWorkspaceBinding ? workspaceIdentity?.slot : undefined;
2708
+ if (!slot || (deps.slot && deps.slot !== slot)) {
2709
+ deps.store.releaseWorkspaceClaim(deps.instanceId);
2710
+ return undefined;
2711
+ }
2712
+ if (exactWorkspaceBinding && exactWorkspaceBinding.slot !== slot) {
2713
+ const committed = deps.store.upsertWorkspaceBinding({
2714
+ ...exactWorkspaceBinding, slot, updatedAtMs: nowMs,
2715
+ }, deps.instanceId);
2716
+ if (!committed) {
2717
+ deps.store.releaseWorkspaceClaim(deps.instanceId);
2718
+ return undefined;
2719
+ }
2720
+ await deps.store.persist();
2721
+ }
2722
+ const owner = {
2723
+ kind: "leader",
2724
+ cwd: deps.cwd,
2725
+ instanceId: deps.instanceId,
2726
+ ...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
2727
+ };
2728
+ const record = deps.store.upsert({
2729
+ profileKey: getTelegramThreadOwnerKey(owner),
2730
+ owner,
2731
+ target: { chatId: target.chatId, threadId: target.threadId },
2732
+ status: "active",
2733
+ createdAtMs: existing?.createdAtMs ?? nowMs,
2734
+ updatedAtMs: nowMs,
2735
+ ...((existing?.threadName ?? deps.threadName)
2736
+ ? { threadName: existing?.threadName ?? deps.threadName }
2737
+ : {}),
2738
+ instanceId: deps.instanceId,
2739
+ slot,
2740
+ ...(existing?.syncStatus ? { syncStatus: existing.syncStatus } : {}),
2741
+ ...(existing?.lastSyncObservedAtMs !== undefined
2742
+ ? { lastSyncObservedAtMs: existing.lastSyncObservedAtMs }
2743
+ : {}),
2744
+ lastReconcileAction: "follower-promoted-to-leader",
2745
+ ...(existing?.rerouteConfirmedAtMs !== undefined
2746
+ ? { rerouteConfirmedAtMs: existing.rerouteConfirmedAtMs }
2747
+ : {}),
2748
+ });
2749
+ await deps.store.persist();
2750
+ deps.store.releaseWorkspaceClaim(deps.instanceId);
2751
+ return record;
2752
+ }
2753
+ /**
2754
+ * Provision a topic for the bus leader's own use (slot A).
2755
+ * This is a thread-binding primitive; sync policy decides when startup/connect
2756
+ * should call it to ensure the leader has a visible working thread.
2757
+ */
2758
+ export async function provisionOwnBusTopic(deps) {
2759
+ const chatId = deps.getAllowedUserId();
2760
+ let profileKey = getTelegramThreadOwnerKey({
2761
+ kind: "leader",
2762
+ cwd: deps.cwd,
2763
+ instanceId: deps.instanceId,
2764
+ telegramProfile: deps.telegramProfile,
2765
+ });
2766
+ if (typeof chatId !== "number")
2767
+ return undefined;
2768
+ await deps.store.load();
2769
+ const reservationCleanupPorts = {
2770
+ isCleanupTargetProtected: createTelegramCleanupTargetProtection(deps.store),
2771
+ callApi: deps.callApi,
2772
+ markStaleByTarget: (target, syncStatus, lastSyncError) => deps.store.markStaleByTarget(target, syncStatus, lastSyncError),
2773
+ removePendingProvisionById: (id) => deps.store.removePendingProvision(id),
2774
+ persist: () => deps.store.persist(),
2775
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
2776
+ recordRuntimeEvent(category, error, details) {
2777
+ deps.recordEvent(category, error instanceof Error ? error.message : String(error), details);
2778
+ },
2779
+ };
2780
+ const reservationCleanupNowMs = Date.now();
2781
+ const reservationsBeforeCleanup = deps.store.listReservations();
2782
+ const reservationCleanupPlan = ThreadReconciler.planThreadReconciliation({
2783
+ nowMs: reservationCleanupNowMs,
2784
+ currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
2785
+ previousState: deps.getThreadReconciliationMachineState?.(),
2786
+ records: deps.store.list(),
2787
+ reservations: reservationsBeforeCleanup,
2788
+ pendingProvisions: deps.store.listPendingProvisions(),
2789
+ proactiveReservationCleanup: true,
2790
+ });
2791
+ deps.recordThreadReconciliationPlan?.(reservationCleanupPlan);
2792
+ const reservationCleanupApplyStartedAtMs = Date.now();
2793
+ await ThreadReconciler.applyThreadReconciliationPlan(reservationCleanupPlan, reservationCleanupPorts);
2794
+ deps.recordEvent("bus", "Bus leader reservation cleanup reconciled", {
2795
+ phase: "leader-topic-reservation-cleanup-duration",
2796
+ durationMs: Date.now() - reservationCleanupApplyStartedAtMs,
2797
+ actions: reservationCleanupPlan.actions.length,
2798
+ });
2799
+ const nowMs = Date.now();
2800
+ const currentLeaderOwner = {
2801
+ kind: "leader",
2802
+ cwd: deps.cwd,
2803
+ instanceId: deps.instanceId,
2804
+ ...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
2805
+ };
2806
+ const leaderSessionHandoff = getTelegramLeaderSessionHandoff();
2807
+ if (isTelegramLeaderSessionHandoffFresh(leaderSessionHandoff) &&
2808
+ leaderSessionHandoff.profileKey === profileKey) {
2809
+ const existingHandoffRecord = deps.store
2810
+ .list()
2811
+ .find((record) => targetMatches(record.target, leaderSessionHandoff.target));
2812
+ deps.store.upsert({
2813
+ profileKey,
2814
+ owner: currentLeaderOwner,
2815
+ target: { ...leaderSessionHandoff.target },
2816
+ status: "active",
2817
+ createdAtMs: existingHandoffRecord?.createdAtMs ?? leaderSessionHandoff.createdAtMs,
2818
+ updatedAtMs: nowMs,
2819
+ threadName: existingHandoffRecord?.threadName ?? leaderSessionHandoff.threadName,
2820
+ instanceId: deps.instanceId,
2821
+ slot: existingHandoffRecord?.slot ?? leaderSessionHandoff.slot,
2822
+ ...(existingHandoffRecord?.syncStatus
2823
+ ? { syncStatus: existingHandoffRecord.syncStatus }
2824
+ : {}),
2825
+ ...(existingHandoffRecord?.lastSyncObservedAtMs !== undefined
2826
+ ? {
2827
+ lastSyncObservedAtMs: existingHandoffRecord.lastSyncObservedAtMs,
2828
+ }
2829
+ : {}),
2830
+ lastReconcileAction: "leader-session-handoff-restored",
2831
+ });
2832
+ await deps.store.persist();
2833
+ setTelegramLeaderSessionHandoff(undefined);
2834
+ deps.recordEvent("bus", "Bus leader session handoff restored", {
2835
+ phase: "leader-session-handoff-restore",
2836
+ chatId: leaderSessionHandoff.target.chatId,
2837
+ threadId: leaderSessionHandoff.target.threadId,
2838
+ slot: existingHandoffRecord?.slot ?? leaderSessionHandoff.slot,
2839
+ threadName: existingHandoffRecord?.threadName ?? leaderSessionHandoff.threadName,
2840
+ previousInstanceId: leaderSessionHandoff.instanceId,
2841
+ instanceId: deps.instanceId,
2842
+ });
2843
+ }
2844
+ else if (leaderSessionHandoff) {
2845
+ setTelegramLeaderSessionHandoff(undefined);
2846
+ }
2847
+ const recordsBeforePreviousLeaderCleanup = deps.store.list();
2848
+ const previousLeaderCleanupPlan = ThreadReconciler.planThreadReconciliation({
2849
+ nowMs,
2850
+ currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
2851
+ previousState: deps.getThreadReconciliationMachineState?.(),
2852
+ records: recordsBeforePreviousLeaderCleanup.map((record) => ({
2853
+ ...record,
2854
+ ownerKind: record.owner?.kind,
2855
+ })),
2856
+ pendingProvisions: deps.store.listPendingProvisions(),
2857
+ previousLeaderCleanup: { currentInstanceId: deps.instanceId },
2858
+ });
2859
+ deps.recordThreadReconciliationPlan?.(previousLeaderCleanupPlan);
2860
+ for (const action of previousLeaderCleanupPlan.actions) {
2861
+ if (action.kind !== "close-delete-previous-leader-topic")
2862
+ continue;
2863
+ const record = recordsBeforePreviousLeaderCleanup.find((candidate) => targetMatches(candidate.target, action.target));
2864
+ if (!record)
2865
+ continue;
2866
+ const isSameProfile = record.profileKey === profileKey;
2867
+ if (isSameProfile) {
2868
+ deps.recordEvent("bus", "Bus leader same-profile topic preserved", {
2869
+ phase: "leader-topic-same-profile-preserve",
2870
+ chatId: record.target.chatId,
2871
+ threadId: record.target.threadId,
2872
+ slot: record.slot,
2873
+ previousInstanceId: record.instanceId,
2874
+ instanceId: deps.instanceId,
2875
+ profileKey,
2876
+ });
2877
+ continue;
2878
+ }
2879
+ if (isSameTelegramProcessInstance(record.instanceId, deps.instanceId)) {
2880
+ deps.store.upsert({
2881
+ ...record,
2882
+ profileKey,
2883
+ owner: currentLeaderOwner,
2884
+ status: "active",
2885
+ instanceId: deps.instanceId,
2886
+ updatedAtMs: nowMs,
2887
+ lastError: undefined,
2888
+ lastReconcileAction: "leader-topic-same-process-preserve",
2889
+ });
2890
+ deps.recordEvent("bus", "Bus leader same-process topic preserved", {
2891
+ phase: "leader-topic-same-process-preserve",
2892
+ chatId: record.target.chatId,
2893
+ threadId: record.target.threadId,
2894
+ slot: record.slot,
2895
+ previousInstanceId: record.instanceId,
2896
+ instanceId: deps.instanceId,
2897
+ profileKey,
2898
+ });
2899
+ continue;
2900
+ }
2901
+ const previousLeaderCleanupStartedAtMs = Date.now();
2902
+ const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.store, record);
2903
+ const cleanup = await ThreadReconciler.applyThreadReconciliationPlan({ actions: [action] }, {
2904
+ isCleanupTargetProtected,
2905
+ callApi: deps.callApi,
2906
+ markStaleByTarget: (target, syncStatus, lastSyncError) => deps.store.markStaleByTarget(target, syncStatus, lastSyncError),
2907
+ persist: () => deps.store.persist(),
2908
+ removePendingProvisionById: (id) => deps.store.removePendingProvision(id),
2909
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
2910
+ recordRuntimeEvent(category, error, details) {
2911
+ deps.recordEvent(category, error instanceof Error ? error.message : String(error), details);
2912
+ },
2913
+ });
2914
+ deps.recordEvent("bus", "Bus leader previous-topic cleanup applied", {
2915
+ phase: "leader-topic-previous-cleanup-duration",
2916
+ durationMs: Date.now() - previousLeaderCleanupStartedAtMs,
2917
+ chatId: record.target.chatId,
2918
+ threadId: record.target.threadId,
2919
+ slot: record.slot,
2920
+ });
2921
+ if (deps.getCurrentLeaderEpoch &&
2922
+ (action.leaderEpoch === undefined ||
2923
+ deps.getCurrentLeaderEpoch() !== action.leaderEpoch)) {
2924
+ deps.recordEvent("bus", "Skipped previous-topic local cleanup after leader epoch loss", {
2925
+ phase: "leader-topic-previous-cleanup-stale-epoch-skip",
2926
+ actionLeaderEpoch: action.leaderEpoch,
2927
+ currentLeaderEpoch: deps.getCurrentLeaderEpoch(),
2928
+ chatId: record.target.chatId,
2929
+ threadId: record.target.threadId,
2930
+ });
2931
+ throw new Error("Telegram leader ownership changed during topic reconciliation.");
2932
+ }
2933
+ if (cleanup.incompleteActions?.length) {
2934
+ throw new Error("Previous Telegram leader topic deletion was not confirmed.");
2935
+ }
2936
+ if (isCleanupTargetProtected(action.target, action))
2937
+ continue;
2938
+ deps.store.markStaleByTarget(record.target);
2939
+ if (record.slot) {
2940
+ deps.store.reserveThread({
2941
+ target: record.target,
2942
+ slot: record.slot,
2943
+ reason: "previous-process-cleaned-without-visible-probe",
2944
+ createdAtMs: nowMs,
2945
+ updatedAtMs: nowMs,
2946
+ expiresAtMs: nowMs + TELEGRAM_THREAD_RESERVATION_TTL_MS,
2947
+ instanceId: record.instanceId,
2948
+ lastReconcileAction: "leader-topic-previous-instance-cleaned-no-probe",
2949
+ });
2950
+ }
2951
+ deps.store.setBotState({
2952
+ threadMode: "enabled",
2953
+ updatedAtMs: nowMs,
2954
+ lastReconcileAction: "leader-topic-next-slot-after-unprobed-previous",
2955
+ });
2956
+ deps.recordEvent("bus", "Bus leader previous-process topic reserved after cleanup without visible probe", {
2957
+ phase: "leader-topic-previous-instance-reserve-no-probe",
2958
+ chatId: record.target.chatId,
2959
+ threadId: record.target.threadId,
2960
+ slot: record.slot,
2961
+ previousInstanceId: record.instanceId,
2962
+ instanceId: deps.instanceId,
2963
+ });
2964
+ }
2965
+ const provision = createTelegramTopicTargetProvisioner({
2966
+ topicChatId: chatId,
2967
+ store: deps.store,
2968
+ callApi: deps.callApi,
2969
+ getNowMs: deps.getNowMs,
2970
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
2971
+ getRandom: deps.getRandom,
2972
+ resolveInitialWorkspaceDisplayTitle: deps.resolveInitialWorkspaceDisplayTitle,
2973
+ claimPendingTargets: false,
2974
+ });
2975
+ let result = await provision({
2976
+ instanceId: deps.instanceId,
2977
+ owner: currentLeaderOwner,
2978
+ profileKey,
2979
+ ...(deps.requestedThreadName
2980
+ ? { threadName: deps.requestedThreadName }
2981
+ : {}),
2982
+ ...(deps.preferredSlot ? { preferredSlot: deps.preferredSlot } : {}),
2983
+ ...(deps.workspaceBindingKey
2984
+ ? {
2985
+ workspaceBindingKey: deps.workspaceBindingKey,
2986
+ ...(deps.cwd ? { workspaceCwd: deps.cwd } : {}),
2987
+ }
2988
+ : {}),
2989
+ });
2990
+ if (result.reused) {
2991
+ // Reused topics may already have a human-chosen Telegram title. Do not edit
2992
+ // them during leader startup: startup reconciliation must not reset a named
2993
+ // topic back to its bare slot or create redundant "renamed the thread" service
2994
+ // messages. Also do not probe with Bot API chat actions: every chat action is
2995
+ // user-visible as native typing/activity, so reload would falsely signal that
2996
+ // the agent is working. Treat the reused binding as optimistically open;
2997
+ // ordinary target-scoped sends still detect stale topics and trigger the
2998
+ // stale-api-error reconciliation path when real delivery happens.
2999
+ const nowMs = Date.now();
3000
+ deps.store.upsert({
3001
+ ...result.record,
3002
+ syncStatus: "open",
3003
+ lastSyncObservedAtMs: nowMs,
3004
+ lastReconcileAction: "leader-startup-skip-probe",
3005
+ });
3006
+ }
3007
+ deps.store.setBotState({
3008
+ threadMode: "enabled",
3009
+ updatedAtMs: Date.now(),
3010
+ lastReconcileAction: result.reused
3011
+ ? "leader-startup-skip-probe"
3012
+ : "leader-topic-created",
3013
+ });
3014
+ await deps.store.persist();
3015
+ deps.recordEvent("bus", "Bus leader own topic assigned", {
3016
+ phase: "leader-topic",
3017
+ chatId: result.target.chatId,
3018
+ threadId: result.target.threadId,
3019
+ slot: result.record.slot,
3020
+ threadName: result.record.threadName,
3021
+ reused: result.reused,
3022
+ });
3023
+ if (!result.record.slot) {
3024
+ throw new Error("Telegram Thread slot authority is unavailable.");
3025
+ }
3026
+ return {
3027
+ target: result.target,
3028
+ slot: result.record.slot,
3029
+ ...(result.record.threadName
3030
+ ? { threadName: result.record.threadName }
3031
+ : {}),
3032
+ ...(result.displayTitle ? { displayTitle: result.displayTitle } : {}),
3033
+ reused: result.reused,
3034
+ };
3035
+ }
3036
+ export function resolveTelegramInstanceThreadIdentity(options) {
3037
+ const targetMatchesCandidate = (candidate) => {
3038
+ if (!candidate)
3039
+ return false;
3040
+ if (!options.target)
3041
+ return true;
3042
+ return (!!candidate.target && targetMatches(candidate.target, options.target));
3043
+ };
3044
+ const local = targetMatchesCandidate(options.follower)
3045
+ ? options.follower
3046
+ : targetMatchesCandidate(options.leader)
3047
+ ? options.leader
3048
+ : undefined;
3049
+ const record = options.record &&
3050
+ (!options.target || targetMatches(options.record.target, options.target))
3051
+ ? options.record
3052
+ : undefined;
3053
+ return {
3054
+ ...((local?.target ?? record?.target)
3055
+ ? { target: local?.target ?? record?.target }
3056
+ : {}),
3057
+ ...((local?.slot ?? record?.slot)
3058
+ ? { slot: local?.slot ?? record?.slot }
3059
+ : {}),
3060
+ ...((local?.threadName ?? record?.threadName)
3061
+ ? { threadName: local?.threadName ?? record?.threadName }
3062
+ : {}),
3063
+ };
3064
+ }
3065
+ export function createTelegramLeaderThreadStateRuntime() {
3066
+ let identity;
3067
+ return {
3068
+ getTarget: () => identity?.target,
3069
+ getIdentity: () => identity,
3070
+ set(input) {
3071
+ identity = { ...input, target: { ...input.target } };
3072
+ },
3073
+ clear() {
3074
+ identity = undefined;
3075
+ },
3076
+ };
3077
+ }
3078
+ export function createTelegramCurrentInstanceThreadRuntime(deps) {
3079
+ const findRecord = function () {
3080
+ return findCurrentTelegramInstanceThreadRecord({
3081
+ records: deps.listRecords(),
3082
+ instanceId: deps.instanceId,
3083
+ preferredTarget: deps.getPreferredTarget(),
3084
+ });
3085
+ };
3086
+ const getRecord = function () {
3087
+ const record = findRecord();
3088
+ const follower = deps.getFollower();
3089
+ if (record?.owner?.kind === "manual-follower" && !follower?.registered) {
3090
+ return undefined;
3091
+ }
3092
+ return record;
3093
+ };
3094
+ return {
3095
+ findRecord,
3096
+ getRecord,
3097
+ getIdentity(target) {
3098
+ const follower = deps.getFollower();
3099
+ const record = target
3100
+ ? findCurrentTelegramInstanceThreadRecord({
3101
+ records: deps.listRecords(),
3102
+ instanceId: deps.instanceId,
3103
+ preferredTarget: target,
3104
+ })
3105
+ : getRecord();
3106
+ return resolveTelegramInstanceThreadIdentity({
3107
+ target,
3108
+ follower: follower?.registered ? follower : undefined,
3109
+ leader: deps.getLeader(),
3110
+ record,
3111
+ });
3112
+ },
3113
+ getRestorationIdentity() {
3114
+ const follower = deps.getFollower();
3115
+ return resolveTelegramInstanceThreadIdentity({
3116
+ follower: follower?.registered ? follower : undefined,
3117
+ leader: deps.getLeader(),
3118
+ record: findRecord(),
3119
+ });
3120
+ },
3121
+ };
3122
+ }
3123
+ export function findCurrentTelegramInstanceThreadRecord(options) {
3124
+ const target = options.preferredTarget;
3125
+ if (typeof target?.threadId === "number") {
3126
+ const targetRecord = options.records.find((record) => {
3127
+ return (record.target.chatId === target.chatId &&
3128
+ record.target.threadId === target.threadId);
3129
+ });
3130
+ if (targetRecord)
3131
+ return targetRecord;
3132
+ }
3133
+ return options.records.find((record) => {
3134
+ return (record.instanceId === options.instanceId && record.status === "active");
3135
+ });
3136
+ }
3137
+ export function resolveTelegramInstanceThreadTarget(options) {
3138
+ const raw = typeof options.followerTarget?.threadId === "number"
3139
+ ? options.followerTarget
3140
+ : (options.currentRecord?.target ?? options.leaderTarget);
3141
+ return raw &&
3142
+ typeof raw.chatId === "number" &&
3143
+ typeof raw.threadId === "number"
3144
+ ? { chatId: raw.chatId, threadId: raw.threadId }
3145
+ : undefined;
3146
+ }
3147
+ export function createTelegramThreadStatusProjectionRuntime(deps) {
3148
+ return {
3149
+ getBusRole() {
3150
+ if (deps.getThreadMode() === "disabled")
3151
+ return undefined;
3152
+ if (deps.isBusPollingStarted())
3153
+ return "leader";
3154
+ return deps.isFollowerRegistered() ? "follower" : undefined;
3155
+ },
3156
+ getBusFollowers() {
3157
+ return listTelegramThreadStatusFollowers({
3158
+ followers: deps.listFollowers(),
3159
+ records: deps.listRecords(),
3160
+ }).map((follower) => ({
3161
+ ...follower,
3162
+ threadName: (follower.target ? deps.getDisplayTitle?.(follower.target) : undefined) ?? follower.threadName,
3163
+ }));
3164
+ },
3165
+ getLocalBus() {
3166
+ const leaderSocketPath = deps.getLeaderSocketPath();
3167
+ const followerSocketPath = deps.getFollowerSocketPath();
3168
+ const leaderProtocol = deps.getLeaderProtocol?.();
3169
+ const followerTarget = deps.getFollowerTarget();
3170
+ return {
3171
+ leaderSocketPath,
3172
+ leaderTransport: deps.getTransportKind(leaderSocketPath),
3173
+ followerSocketPath,
3174
+ followerTransport: deps.getTransportKind(followerSocketPath),
3175
+ followerRegistered: deps.isFollowerRegistered(),
3176
+ followerTarget,
3177
+ followerSlot: deps.getFollowerSlot(),
3178
+ followerThreadName: (followerTarget ? deps.getDisplayTitle?.(followerTarget) : undefined) ??
3179
+ deps.getFollowerThreadName(),
3180
+ ...(leaderProtocol ? { leaderProtocol } : {}),
3181
+ };
3182
+ },
3183
+ getTopicTargets: () => listTelegramThreadStatusTargets(deps.listRecords()).map((record) => ({
3184
+ ...record, threadName: deps.getDisplayTitle?.(record.target) ?? record.threadName,
3185
+ })),
3186
+ getThreadReservations: () => listTelegramThreadStatusReservations(deps.listReservations()),
3187
+ getTopicSyncObservations: () => listTelegramThreadStatusObservations(deps.listSyncObservations()),
3188
+ getInstanceSlot() {
3189
+ if (deps.getThreadMode() === "disabled")
3190
+ return undefined;
3191
+ return deps.getCurrentIdentity().slot;
3192
+ },
3193
+ getInstanceThreadName() {
3194
+ if (deps.getThreadMode() === "disabled")
3195
+ return undefined;
3196
+ return deps.getCurrentIdentity().threadName;
3197
+ },
3198
+ };
3199
+ }
3200
+ /** Own current-thread preference and its matching status projection. */
3201
+ export function createTelegramCurrentThreadAssembly(deps) {
3202
+ const getFollower = () => {
3203
+ const target = deps.getFollowerTarget();
3204
+ if (!target)
3205
+ return undefined;
3206
+ return {
3207
+ registered: deps.isFollowerRegistered(),
3208
+ target,
3209
+ slot: deps.getFollowerSlot(),
3210
+ threadName: deps.getFollowerThreadName(),
3211
+ };
3212
+ };
3213
+ const namedCurrent = createTelegramCurrentInstanceThreadRuntime({
3214
+ instanceId: deps.instanceId,
3215
+ listRecords: deps.listRecords,
3216
+ getPreferredTarget: () => deps.getActiveTurnTarget() ??
3217
+ deps.getFollowerTarget() ??
3218
+ deps.getLeaderTarget(),
3219
+ getFollower,
3220
+ getLeader: deps.getLeaderIdentity,
3221
+ });
3222
+ const getDisplayTitle = (target) => {
3223
+ const followerTarget = deps.getFollowerTarget();
3224
+ if (followerTarget && targetMatches(followerTarget, target) && deps.isFollowerRegistered()) {
3225
+ return deps.getFollowerDisplayTitle?.();
3226
+ }
3227
+ return deps.listWorkspaceBindings?.().find((binding) => targetMatches(binding.target, target))?.displayTitle;
3228
+ };
3229
+ const displayIdentity = (identity) => {
3230
+ const title = identity.target ? getDisplayTitle(identity.target) : undefined;
3231
+ return title ? { ...identity, threadName: title } : identity;
3232
+ };
3233
+ const current = {
3234
+ ...namedCurrent,
3235
+ getIdentity: (target) => displayIdentity(namedCurrent.getIdentity(target)),
3236
+ };
3237
+ return {
3238
+ getDisplayTitle,
3239
+ current,
3240
+ status: createTelegramThreadStatusProjectionRuntime({
3241
+ ...deps.status,
3242
+ isFollowerRegistered: deps.isFollowerRegistered,
3243
+ listRecords: deps.listRecords,
3244
+ getFollowerTarget: deps.getFollowerTarget,
3245
+ getFollowerSlot: deps.getFollowerSlot,
3246
+ getFollowerThreadName: deps.getFollowerThreadName,
3247
+ getLeaderProtocol: deps.getLeaderProtocol,
3248
+ getDisplayTitle,
3249
+ getCurrentIdentity: () => displayIdentity(namedCurrent.getRestorationIdentity()),
3250
+ }),
3251
+ };
3252
+ }
3253
+ function getTelegramThreadStatusName(record) {
3254
+ if (!record)
3255
+ return undefined;
3256
+ if (record.threadName &&
3257
+ isTelegramTopicThreadNameValidForSlot(record.threadName, record.slot))
3258
+ return record.threadName;
3259
+ return chooseTelegramThreadName({ slot: record.slot });
3260
+ }
3261
+ export function listTelegramThreadStatusFollowers(options) {
3262
+ return options.followers.map((follower) => {
3263
+ const record = options.records.find((record) => {
3264
+ return (record.target.chatId === follower.target?.chatId &&
3265
+ record.target.threadId === follower.target?.threadId);
3266
+ });
3267
+ return {
3268
+ instanceId: follower.instanceId,
3269
+ cwd: follower.cwd,
3270
+ lastHeartbeatMs: follower.lastHeartbeatMs,
3271
+ target: follower.target,
3272
+ ...(follower.protocol ? { protocol: follower.protocol } : {}),
3273
+ slot: record?.slot,
3274
+ threadName: getTelegramThreadStatusName(record),
3275
+ status: record?.status,
3276
+ };
3277
+ });
3278
+ }
3279
+ export function listTelegramThreadStatusTargets(records) {
3280
+ return records.map((record) => {
3281
+ return {
3282
+ instanceId: record.instanceId,
3283
+ status: record.status,
3284
+ target: record.target,
3285
+ slot: record.slot,
3286
+ threadName: getTelegramThreadStatusName(record),
3287
+ syncStatus: record.syncStatus,
3288
+ lastSyncObservedAtMs: record.lastSyncObservedAtMs,
3289
+ lastSyncProbeAtMs: record.lastSyncProbeAtMs,
3290
+ lastSyncError: record.lastSyncError,
3291
+ lastReconcileAction: record.lastReconcileAction,
3292
+ };
3293
+ });
3294
+ }
3295
+ export function listTelegramThreadStatusReservations(reservations) {
3296
+ return reservations.map((reservation) => {
3297
+ return {
3298
+ target: reservation.target,
3299
+ slot: reservation.slot,
3300
+ reason: reservation.reason,
3301
+ instanceId: reservation.instanceId,
3302
+ expiresAtMs: reservation.expiresAtMs,
3303
+ lastReconcileAction: reservation.lastReconcileAction,
3304
+ };
3305
+ });
3306
+ }
3307
+ export function listTelegramThreadStatusObservations(observations) {
3308
+ return observations.map((observation) => {
3309
+ return {
3310
+ target: observation.target,
3311
+ syncStatus: observation.syncStatus,
3312
+ observedAtMs: observation.observedAtMs,
3313
+ instanceId: observation.instanceId,
3314
+ slot: observation.slot,
3315
+ lastSyncError: observation.lastSyncError,
3316
+ lastReconcileAction: observation.lastReconcileAction,
3317
+ };
3318
+ });
3319
+ }
3320
+ export function getTelegramTargetFromApiBody(body) {
3321
+ if (!body || typeof body !== "object" || Array.isArray(body))
3322
+ return undefined;
3323
+ const record = body;
3324
+ const chatId = asInteger(record.chat_id);
3325
+ const threadId = asInteger(record.message_thread_id);
3326
+ return chatId !== undefined && threadId !== undefined
3327
+ ? { chatId, threadId }
3328
+ : undefined;
3329
+ }
3330
+ export function isTelegramTopicTargetStaleError(error) {
3331
+ if (!(error instanceof Error))
3332
+ return false;
3333
+ const status = "status" in error && typeof error.status === "number"
3334
+ ? error.status
3335
+ : undefined;
3336
+ if (status !== undefined && status !== 400)
3337
+ return false;
3338
+ const message = error.message.toLowerCase();
3339
+ return (message.includes("topic_id_invalid") ||
3340
+ message.includes("message thread not found") ||
3341
+ message.includes("thread not found") ||
3342
+ message.includes("topic not found") ||
3343
+ message.includes("topic deleted") ||
3344
+ message.includes("topic closed") ||
3345
+ message.includes("thread closed") ||
3346
+ message.includes("forum topic closed") ||
3347
+ message.includes("message thread closed"));
3348
+ }
3349
+ export function isTelegramTopicModeUnavailableError(error) {
3350
+ if (!(error instanceof Error))
3351
+ return false;
3352
+ const message = error.message.toLowerCase();
3353
+ return (message.includes("not a forum") ||
3354
+ message.includes("forum topic") ||
3355
+ message.includes("topics are disabled") ||
3356
+ message.includes("threaded mode") ||
3357
+ message.includes("method is available only for"));
3358
+ }
3359
+ export function getTelegramTopicTitleForThreadName(threadName, slot, template = "{threadName}") {
3360
+ return getTelegramTopicName({
3361
+ instanceId: "",
3362
+ profileKey: normalizeTelegramTopicTargetThreadName(threadName) || "Pi",
3363
+ threadName,
3364
+ }, template, slot);
3365
+ }
3366
+ export function createTelegramTopicTargetRenamer(deps) {
3367
+ return async (request) => {
3368
+ const threadName = normalizeTelegramTopicTargetThreadName(request.threadName);
3369
+ if (!threadName ||
3370
+ !!getTelegramManualThreadDisplayNameValidationError(threadName))
3371
+ return undefined;
3372
+ const occupied = new Set(listOccupiedTelegramThreadIdentities({
3373
+ records: deps.store.list(),
3374
+ workspaceBindings: deps.store.listWorkspaceBindings(),
3375
+ pendingProvisions: deps.store.listPendingProvisions(),
3376
+ exceptTarget: request.target,
3377
+ }));
3378
+ if (occupied.has(getTelegramTopicIdentityName(threadName)))
3379
+ return undefined;
3380
+ const name = getTelegramTopicTitleForThreadName(threadName, request.slot ?? "", deps.topicNameTemplate);
3381
+ deps.assertAuthority?.();
3382
+ const updateDisplayTitle = deps.shouldRenameDisplayedTitle?.() ?? true;
3383
+ if (updateDisplayTitle) {
3384
+ await deps.callApi("editForumTopic", {
3385
+ chat_id: request.target.chatId,
3386
+ message_thread_id: request.target.threadId,
3387
+ name,
3388
+ });
3389
+ }
3390
+ deps.assertAuthority?.();
3391
+ if ((deps.shouldRenameDisplayedTitle?.() ?? true) !== updateDisplayTitle) {
3392
+ throw new Error("Telegram display mode changed during Workspace rename.");
3393
+ }
3394
+ return deps.store.renameByTarget(request.target, threadName, { updateDisplayTitle });
3395
+ };
3396
+ }
3397
+ export function createTelegramTopicTargetProvisioner(deps) {
3398
+ const getNowMs = deps.getNowMs ?? (() => 0);
3399
+ const getRandom = deps.getRandom;
3400
+ return async (request) => {
3401
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
3402
+ const assertLeaderEpoch = (phase) => {
3403
+ if (deps.getCurrentLeaderEpoch &&
3404
+ (leaderEpoch === undefined ||
3405
+ deps.getCurrentLeaderEpoch() !== leaderEpoch)) {
3406
+ throw new Error(`Telegram topic provisioning lost leader ownership (${phase}).`);
3407
+ }
3408
+ };
3409
+ assertLeaderEpoch("start");
3410
+ const isManualFollowerRequest = request.owner?.kind === "manual-follower";
3411
+ const identity = deps.store.getIdentityByProfileKey(request.profileKey);
3412
+ const nowMs = getNowMs();
3413
+ let pendingForRequest = deps.store
3414
+ .listPendingProvisions()
3415
+ .find((pending) => pending.profileKey === request.profileKey ||
3416
+ pending.instanceId === request.instanceId);
3417
+ if (pendingForRequest?.target) {
3418
+ const target = pendingForRequest.target;
3419
+ const observation = deps.store.listSyncObservations().find((entry) => targetMatches(entry.target, target));
3420
+ assertTelegramPendingTopicRecoveryAllowed(deps.store, target);
3421
+ if (observation?.syncStatus === "deleted") {
3422
+ assertLeaderEpoch("before-deleted-provision-settlement");
3423
+ deps.store.markStaleByTarget(target, "deleted");
3424
+ await deps.store.persist();
3425
+ assertLeaderEpoch("after-deleted-provision-settlement");
3426
+ pendingForRequest = undefined;
3427
+ }
3428
+ }
3429
+ const existing = deps.store.getByProfileKey(request.profileKey);
3430
+ if (existing && isCurrentThreadRecord(existing)) {
3431
+ const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
3432
+ if (!slot) {
3433
+ throw new Error("Telegram Workspace slot reservation is unavailable.");
3434
+ }
3435
+ const occupied = listOccupiedTelegramThreadIdentities({
3436
+ records: deps.store.list(),
3437
+ workspaceBindings: deps.store.listWorkspaceBindings(),
3438
+ pendingProvisions: deps.store.listPendingProvisions(),
3439
+ exceptTarget: existing.target,
3440
+ exceptWorkspaceBindingKey: request.workspaceBindingKey,
3441
+ });
3442
+ const identityThreadName = identity?.threadName &&
3443
+ isTelegramTopicThreadNameValidForSlot(identity.threadName, slot) &&
3444
+ !occupied.includes(getTelegramTopicIdentityName(identity.threadName))
3445
+ ? identity.threadName
3446
+ : undefined;
3447
+ const bakedThreadName = chooseTelegramThreadName({
3448
+ slot: getNextTelegramThreadNamePaletteSlot(deps.store.list(), slot),
3449
+ entropy: nowMs,
3450
+ getRandom,
3451
+ occupied,
3452
+ });
3453
+ const record = deps.store.upsert({
3454
+ ...existing,
3455
+ status: "active",
3456
+ updatedAtMs: nowMs,
3457
+ threadName: existing.threadName ?? identityThreadName ?? bakedThreadName,
3458
+ instanceId: request.instanceId,
3459
+ slot,
3460
+ owner: request.owner ?? existing.owner,
3461
+ lastError: undefined,
3462
+ });
3463
+ const recoveredTitle = pendingForRequest?.target &&
3464
+ targetMatches(pendingForRequest.target, record.target)
3465
+ ? pendingForRequest.displayTitle : undefined;
3466
+ if (pendingForRequest?.target && targetMatches(pendingForRequest.target, record.target)) {
3467
+ if (!request.workspaceBindingKey)
3468
+ deps.store.removePendingProvision(pendingForRequest.id);
3469
+ await deps.store.persist();
3470
+ assertLeaderEpoch("after-recovered-current-binding");
3471
+ }
3472
+ return {
3473
+ target: record.target, reused: true, record,
3474
+ ...(recoveredTitle ? { displayTitle: recoveredTitle } : {}),
3475
+ };
3476
+ }
3477
+ if (pendingForRequest?.target) {
3478
+ if (!pendingForRequest.slot) {
3479
+ throw new Error("Telegram Workspace slot reservation is unavailable.");
3480
+ }
3481
+ const record = deps.store.upsert({
3482
+ profileKey: request.profileKey,
3483
+ owner: request.owner,
3484
+ target: pendingForRequest.target,
3485
+ status: "active",
3486
+ createdAtMs: pendingForRequest.startedAtMs,
3487
+ updatedAtMs: nowMs,
3488
+ threadName: pendingForRequest.threadName,
3489
+ instanceId: request.instanceId,
3490
+ slot: pendingForRequest.slot,
3491
+ });
3492
+ if (!request.workspaceBindingKey)
3493
+ deps.store.removePendingProvision(pendingForRequest.id);
3494
+ await deps.store.persist();
3495
+ assertLeaderEpoch("after-recovered-binding");
3496
+ return {
3497
+ target: record.target,
3498
+ reused: true,
3499
+ record,
3500
+ ...(pendingForRequest.displayTitle
3501
+ ? { displayTitle: pendingForRequest.displayTitle }
3502
+ : {}),
3503
+ };
3504
+ }
3505
+ if (pendingForRequest) {
3506
+ throw new Error(`Telegram topic provisioning remains ${pendingForRequest.status ?? "in-flight"} for this instance.`);
3507
+ }
3508
+ const activeForInstance = deps.store.getActiveByInstanceId(request.instanceId);
3509
+ if (activeForInstance) {
3510
+ if (!activeForInstance.slot) {
3511
+ throw new Error("Telegram Workspace slot reservation is unavailable.");
3512
+ }
3513
+ return {
3514
+ target: activeForInstance.target,
3515
+ reused: true,
3516
+ record: activeForInstance,
3517
+ };
3518
+ }
3519
+ // No profileKey match — try to claim an existing inactive thread before creating another Telegram tab.
3520
+ if (deps.claimPendingTargets !== false) {
3521
+ const claimed = deps.store.claimReusableTarget(request.instanceId, identity?.threadName);
3522
+ if (claimed) {
3523
+ return { target: claimed.target, reused: true, record: claimed };
3524
+ }
3525
+ }
3526
+ const occupied = listOccupiedTelegramThreadIdentities({
3527
+ records: deps.store.list(),
3528
+ workspaceBindings: deps.store.listWorkspaceBindings(),
3529
+ pendingProvisions: deps.store.listPendingProvisions(),
3530
+ exceptWorkspaceBindingKey: request.workspaceBindingKey,
3531
+ });
3532
+ const requestedThreadName = request.threadName &&
3533
+ isTelegramTopicThreadNameValidForSlot(request.threadName, undefined) &&
3534
+ !occupied.includes(getTelegramTopicIdentityName(request.threadName))
3535
+ ? normalizeTelegramTopicTargetThreadName(request.threadName)
3536
+ : undefined;
3537
+ const candidateThreadName = requestedThreadName ?? identity?.threadName;
3538
+ const preferredNameSlot = getTelegramThreadNameLeadingSlot(candidateThreadName) ??
3539
+ getNextTelegramThreadNamePaletteSlot(deps.store.list(), undefined) ??
3540
+ request.preferredSlot;
3541
+ const slot = existing?.slot ??
3542
+ deps.store.allocateSlot(request.profileKey, isManualFollowerRequest
3543
+ ? request.preferredSlot
3544
+ : (request.preferredSlot ??
3545
+ (candidateThreadName ? undefined : identity?.slot) ??
3546
+ preferredNameSlot), request.workspaceBindingKey);
3547
+ if (!slot) {
3548
+ throw new Error("Telegram Workspace slot reservation is unavailable.");
3549
+ }
3550
+ const uniqueCandidate = candidateThreadName &&
3551
+ isTelegramTopicThreadNameValidForSlot(candidateThreadName, slot) &&
3552
+ !occupied.includes(getTelegramTopicIdentityName(candidateThreadName))
3553
+ ? candidateThreadName
3554
+ : undefined;
3555
+ const requestThreadName = uniqueCandidate ??
3556
+ chooseTelegramThreadName({ slot, entropy: nowMs, getRandom, occupied });
3557
+ let displayTitle;
3558
+ if (deps.resolveInitialWorkspaceDisplayTitle &&
3559
+ request.workspaceBindingKey &&
3560
+ request.workspaceCwd) {
3561
+ const projectedTitle = deps.resolveInitialWorkspaceDisplayTitle({
3562
+ bindingKey: request.workspaceBindingKey,
3563
+ cwd: request.workspaceCwd,
3564
+ slot,
3565
+ threadName: requestThreadName,
3566
+ });
3567
+ if (!projectedTitle?.trim()) {
3568
+ throw new Error("Telegram Thread display identity is missing or ambiguous.");
3569
+ }
3570
+ displayTitle = getTelegramTopicName({ ...request, threadName: projectedTitle }, "{threadName}", slot);
3571
+ }
3572
+ const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`;
3573
+ const pendingOwner = request.owner?.kind === "leader" ? "leader" : "manual-follower";
3574
+ assertLeaderEpoch("before-pending-intent");
3575
+ const pendingBase = {
3576
+ id: pendingId,
3577
+ owner: pendingOwner,
3578
+ instanceId: request.instanceId,
3579
+ profileKey: request.profileKey,
3580
+ threadName: requestThreadName,
3581
+ ...(displayTitle ? { displayTitle } : {}),
3582
+ slot,
3583
+ startedAtMs: nowMs,
3584
+ ...(leaderEpoch !== undefined ? { leaderEpoch } : {}),
3585
+ };
3586
+ deps.store.upsertPendingProvision(pendingBase);
3587
+ await deps.store.persist();
3588
+ assertLeaderEpoch("after-pending-intent");
3589
+ let threadId;
3590
+ try {
3591
+ assertLeaderEpoch("before-createForumTopic");
3592
+ const topic = await deps.callApi("createForumTopic", {
3593
+ chat_id: deps.topicChatId,
3594
+ name: displayTitle ?? getTelegramTopicName({
3595
+ ...request,
3596
+ ...(requestThreadName ? { threadName: requestThreadName } : {}),
3597
+ }, deps.topicNameTemplate ??
3598
+ (requestThreadName ? "{threadName}" : "{slot}"), slot),
3599
+ }, { maxAttempts: 1 });
3600
+ threadId = topic.message_thread_id;
3601
+ if (typeof threadId !== "number" || !Number.isInteger(threadId)) {
3602
+ throw new TelegramApiCommitUnknownError("createForumTopic", new Error("Telegram createForumTopic returned no message_thread_id."));
3603
+ }
3604
+ assertLeaderEpoch("after-createForumTopic");
3605
+ const target = { chatId: deps.topicChatId, threadId };
3606
+ deps.store.upsertPendingProvision({ ...pendingBase, target });
3607
+ await deps.store.persist();
3608
+ assertLeaderEpoch("after-pending-target");
3609
+ deps.store.upsert({
3610
+ profileKey: request.profileKey,
3611
+ owner: request.owner,
3612
+ target,
3613
+ status: "starting",
3614
+ createdAtMs: existing?.createdAtMs ?? nowMs,
3615
+ updatedAtMs: nowMs,
3616
+ threadName: requestThreadName,
3617
+ instanceId: request.instanceId,
3618
+ slot,
3619
+ });
3620
+ await deps.store.persist();
3621
+ assertLeaderEpoch("after-starting-binding");
3622
+ const record = deps.store.upsert({
3623
+ profileKey: request.profileKey,
3624
+ owner: request.owner,
3625
+ target,
3626
+ status: "active",
3627
+ createdAtMs: existing?.createdAtMs ?? nowMs,
3628
+ updatedAtMs: nowMs,
3629
+ threadName: requestThreadName,
3630
+ instanceId: request.instanceId,
3631
+ slot,
3632
+ });
3633
+ // Workspace commit consumes the exact title evidence in the same publication.
3634
+ if (!request.workspaceBindingKey)
3635
+ deps.store.removePendingProvision(pendingId);
3636
+ await deps.store.persist();
3637
+ assertLeaderEpoch("after-active-binding");
3638
+ return {
3639
+ target: record.target,
3640
+ reused: false,
3641
+ record,
3642
+ ...(displayTitle ? { displayTitle } : {}),
3643
+ };
3644
+ }
3645
+ catch (error) {
3646
+ if (threadId !== undefined &&
3647
+ deps.getCurrentLeaderEpoch &&
3648
+ deps.getCurrentLeaderEpoch() !== leaderEpoch) {
3649
+ await deps.store.recordPendingProvisionTargetRecovery(pendingBase, {
3650
+ chatId: deps.topicChatId,
3651
+ threadId,
3652
+ });
3653
+ throw error;
3654
+ }
3655
+ assertLeaderEpoch("failure-cleanup");
3656
+ if (threadId === undefined) {
3657
+ if (isTelegramApiCommitUnknownError(error)) {
3658
+ deps.store.upsertPendingProvision({
3659
+ ...pendingBase,
3660
+ status: "ambiguous",
3661
+ });
3662
+ }
3663
+ else {
3664
+ deps.store.removePendingProvision(pendingId);
3665
+ }
3666
+ await deps.store.persist();
3667
+ }
3668
+ else {
3669
+ try {
3670
+ await deps.store.persist();
3671
+ }
3672
+ catch {
3673
+ // Keep the original post-create failure visible to the caller.
3674
+ }
3675
+ }
3676
+ throw error;
3677
+ }
3678
+ };
3679
+ }