@llblab/pi-kit 0.5.2 → 0.7.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 (141) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +5 -5
  3. package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-actors/README.md +1 -1
  6. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
  7. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
  8. package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
  9. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
  10. package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
  11. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
  12. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
  13. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
  14. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
  15. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
  16. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
  17. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
  18. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
  19. package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
  20. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
  21. package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
  22. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
  23. package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
  24. package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
  25. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
  26. package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
  27. package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
  28. package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
  29. package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
  30. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
  31. package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
  32. package/node_modules/@llblab/pi-actors/package.json +1 -1
  33. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
  34. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +2 -2
  35. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
  36. package/node_modules/@llblab/pi-grow-loop/README.md +6 -6
  37. package/node_modules/@llblab/pi-grow-loop/index.ts +6 -3
  38. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  39. package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
  40. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
  41. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
  42. package/node_modules/@llblab/pi-state-flow/README.md +203 -107
  43. package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
  44. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
  45. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
  46. package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
  47. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
  48. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
  49. package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
  50. package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
  51. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
  52. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
  53. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
  54. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
  55. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
  56. package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
  57. package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
  58. package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
  59. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
  60. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
  61. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
  62. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
  63. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
  64. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
  65. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
  66. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
  67. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
  68. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
  69. package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
  70. package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
  71. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
  72. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
  73. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
  74. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
  75. package/node_modules/@llblab/pi-state-flow/package.json +8 -2
  76. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
  77. package/node_modules/@llblab/pi-telegram/AGENTS.md +14 -9
  78. package/node_modules/@llblab/pi-telegram/BACKLOG.md +20 -4
  79. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +22 -5
  80. package/node_modules/@llblab/pi-telegram/README.md +13 -9
  81. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
  82. package/node_modules/@llblab/pi-telegram/docs/architecture.md +220 -18
  83. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  84. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
  85. package/node_modules/@llblab/pi-telegram/docs/outbound.md +13 -7
  86. package/node_modules/@llblab/pi-telegram/docs/public-api.md +13 -5
  87. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
  88. package/node_modules/@llblab/pi-telegram/index.ts +4 -1415
  89. package/node_modules/@llblab/pi-telegram/lib/activity.ts +19 -5
  90. package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
  91. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +37 -2
  92. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
  93. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
  94. package/node_modules/@llblab/pi-telegram/lib/bus.ts +350 -26
  95. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +544 -0
  96. package/node_modules/@llblab/pi-telegram/lib/commands.ts +234 -11
  97. package/node_modules/@llblab/pi-telegram/lib/config.ts +178 -25
  98. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +18 -18
  99. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1792 -0
  100. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
  101. package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
  102. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +7 -1
  103. package/node_modules/@llblab/pi-telegram/lib/locks.ts +38 -1
  104. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +154 -15
  105. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +74 -40
  106. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +28 -42
  107. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +18 -14
  108. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
  109. package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
  110. package/node_modules/@llblab/pi-telegram/lib/preview.ts +115 -70
  111. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +5 -2
  112. package/node_modules/@llblab/pi-telegram/lib/queue.ts +66 -22
  113. package/node_modules/@llblab/pi-telegram/lib/replies.ts +47 -39
  114. package/node_modules/@llblab/pi-telegram/lib/routing.ts +305 -112
  115. package/node_modules/@llblab/pi-telegram/lib/status.ts +10 -0
  116. package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
  117. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +315 -7
  118. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
  119. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
  120. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
  121. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
  122. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
  123. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
  124. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
  125. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
  126. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  127. package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
  128. package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
  129. package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
  130. package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
  131. package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
  132. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
  133. package/node_modules/@llblab/skills/abcd-context/AGENTS.md +1 -0
  134. package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +6 -2
  135. package/node_modules/@llblab/skills/abcd-context/SKILL.md +1 -1
  136. package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +11 -5
  137. package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +61 -0
  138. package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +67 -0
  139. package/node_modules/@llblab/skills/package.json +2 -3
  140. package/node_modules/@llblab/skills/release-flow/SKILL.md +2 -4
  141. package/package.json +7 -6
@@ -4,7 +4,7 @@
4
4
  * Owns current live instance-binding to Telegram UI thread mappings backed by Bot API ForumTopic/message_thread_id transport
5
5
  */
6
6
 
7
- import { randomUUID } from "node:crypto";
7
+ import { createHash, randomUUID } from "node:crypto";
8
8
  import {
9
9
  chmodSync,
10
10
  existsSync,
@@ -20,7 +20,7 @@ import {
20
20
  unlink,
21
21
  writeFile,
22
22
  } from "node:fs/promises";
23
- import { dirname } from "node:path";
23
+ import { dirname, posix, resolve } from "node:path";
24
24
  import { isDeepStrictEqual } from "node:util";
25
25
 
26
26
  import {
@@ -31,6 +31,11 @@ import {
31
31
  import type { TelegramTarget } from "./target.ts";
32
32
  import { withTelegramFileTransaction } from "./locks.ts";
33
33
  import * as ThreadReconciler from "./thread-reconciler.ts";
34
+ import {
35
+ planTelegramWorkspaceSlotAllocation,
36
+ TELEGRAM_WORKSPACE_SLOTS,
37
+ type TelegramWorkspaceSlotOccupancy,
38
+ } from "./workspace-slots.ts";
34
39
  import {
35
40
  resolveAgentDir,
36
41
  resolveTelegramProfileTempFilePath,
@@ -44,6 +49,101 @@ export interface TelegramThreadNameInput {
44
49
  slot?: string;
45
50
  }
46
51
 
52
+ export interface TelegramWorkspaceBindingIdentity {
53
+ cwd: string;
54
+ workspaceKey: string;
55
+ /** Immutable legacy binding-key component, not the displayed global letter. */
56
+ instanceSlot: string;
57
+ bindingKey: string;
58
+ /** Profile-wide letter reserved by the transient claim. */
59
+ slot?: string;
60
+ }
61
+
62
+ const TELEGRAM_WORKSPACE_KEY_MAX_LENGTH = 180;
63
+
64
+ export function normalizeTelegramWorkspacePath(
65
+ cwd: string,
66
+ ): string | undefined {
67
+ const trimmed = cwd.trim();
68
+ if (!trimmed) return undefined;
69
+ const normalized = (trimmed.startsWith("/")
70
+ ? posix.normalize(trimmed)
71
+ : resolve(trimmed).replaceAll("\\", "/"));
72
+ const withoutTrailingSeparators =
73
+ normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
74
+ return process.platform === "win32"
75
+ ? withoutTrailingSeparators.replace(/^([A-Z]):/u, (_, drive: string) =>
76
+ `${drive.toLowerCase()}:`,
77
+ )
78
+ : withoutTrailingSeparators;
79
+ }
80
+
81
+ export function createTelegramWorkspaceDirectoryKey(
82
+ cwd: string,
83
+ ): string | undefined {
84
+ const normalized = normalizeTelegramWorkspacePath(cwd);
85
+ if (!normalized) return undefined;
86
+ const readable =
87
+ normalized
88
+ .replace(/[^\p{L}\p{N}._-]+/gu, "-")
89
+ .replace(/^-+|-+$/gu, "") || "root";
90
+ const candidate = `--${readable}--`;
91
+ if (candidate.length <= TELEGRAM_WORKSPACE_KEY_MAX_LENGTH) return candidate;
92
+ const digest = createHash("sha256")
93
+ .update(normalized)
94
+ .digest("hex")
95
+ .slice(0, 12);
96
+ const prefixLength =
97
+ TELEGRAM_WORKSPACE_KEY_MAX_LENGTH - digest.length - 5;
98
+ return `--${readable.slice(0, prefixLength)}-${digest}--`;
99
+ }
100
+
101
+ function createTelegramWorkspaceInstanceSlot(
102
+ ordinal: number,
103
+ ): string | undefined {
104
+ if (!Number.isSafeInteger(ordinal) || ordinal < 0) return undefined;
105
+ let value = ordinal + 1;
106
+ let slot = "";
107
+ while (value > 0) {
108
+ value -= 1;
109
+ slot = String.fromCharCode(97 + (value % 26)) + slot;
110
+ value = Math.floor(value / 26);
111
+ }
112
+ return slot;
113
+ }
114
+
115
+ function createTelegramWorkspaceBindingIdentityWithKey(
116
+ cwd: string,
117
+ workspaceKey: string,
118
+ ordinal: number,
119
+ ): TelegramWorkspaceBindingIdentity | undefined {
120
+ const instanceSlot = createTelegramWorkspaceInstanceSlot(ordinal);
121
+ if (!instanceSlot) return undefined;
122
+ return {
123
+ cwd,
124
+ workspaceKey,
125
+ instanceSlot,
126
+ bindingKey:
127
+ instanceSlot === "a" ? workspaceKey : `${workspaceKey}${instanceSlot}`,
128
+ };
129
+ }
130
+
131
+ export function createTelegramWorkspaceBindingIdentity(
132
+ cwd: string,
133
+ ordinal = 0,
134
+ ): TelegramWorkspaceBindingIdentity | undefined {
135
+ const normalized = normalizeTelegramWorkspacePath(cwd);
136
+ const workspaceKey = normalized
137
+ ? createTelegramWorkspaceDirectoryKey(normalized)
138
+ : undefined;
139
+ if (!normalized || !workspaceKey) return undefined;
140
+ return createTelegramWorkspaceBindingIdentityWithKey(
141
+ normalized,
142
+ workspaceKey,
143
+ ordinal,
144
+ );
145
+ }
146
+
47
147
  export type TelegramTopicTargetStatus =
48
148
  | "active"
49
149
  | "offline"
@@ -86,6 +186,7 @@ export interface TelegramThreadPendingProvision {
86
186
  profileKey?: string;
87
187
  status?: "in-flight" | "ambiguous";
88
188
  threadName?: string;
189
+ displayTitle?: string;
89
190
  slot?: string;
90
191
  target?: TelegramTarget & { threadId: number };
91
192
  startedAtMs: number;
@@ -125,6 +226,8 @@ export interface TelegramTopicTargetRecord {
125
226
  createdAtMs: number;
126
227
  updatedAtMs: number;
127
228
  threadName?: string;
229
+ /** Explicit per-Workspace display override set by the operator. */
230
+ manualThreadName?: string;
128
231
  instanceId?: string;
129
232
  slot?: string;
130
233
  lastError?: string;
@@ -143,6 +246,61 @@ export interface TelegramThreadIdentityRecord {
143
246
  updatedAtMs: number;
144
247
  }
145
248
 
249
+ export interface TelegramWorkspaceThreadBinding {
250
+ cwd: string;
251
+ workspaceKey: string;
252
+ instanceSlot: string;
253
+ bindingKey: string;
254
+ target: TelegramTarget & { threadId: number };
255
+ /** Stable generated identity retained for compatibility and recovery. */
256
+ threadName?: string;
257
+ /** Explicit display override; absence selects the profile's automatic mode. */
258
+ manualThreadName?: string;
259
+ slot?: string;
260
+ /** Last title acknowledged by Telegram; never replaces the stable name. */
261
+ displayTitle?: string;
262
+ /** Historical follower-journal routing keys that may retain accepted work. */
263
+ journalBindingKeys?: string[];
264
+ /** True only when the historical journal-key set is proven complete. */
265
+ journalBindingsComplete?: true;
266
+ /** Sticky once this directory has multiple retained bindings. */
267
+ showSlotSuffix?: boolean;
268
+ /** First continuously proven no-owner transition; absent means active or unproven. */
269
+ inactiveSinceMs?: number;
270
+ updatedAtMs: number;
271
+ }
272
+
273
+ export type TelegramWorkspaceDisplayBinding = Pick<
274
+ TelegramWorkspaceThreadBinding,
275
+ "bindingKey" | "cwd" | "slot" | "threadName" | "manualThreadName" |
276
+ "showSlotSuffix"
277
+ >;
278
+
279
+ export interface TelegramWorkspaceRetirementIntent {
280
+ id: string;
281
+ reason: "pressure";
282
+ profileKey: string;
283
+ binding: TelegramWorkspaceThreadBinding;
284
+ leaderEpoch: number | string;
285
+ requestedAtMs: number;
286
+ }
287
+
288
+ export type TelegramWorkspaceProtectionState =
289
+ | "clear"
290
+ | "protected"
291
+ | "unknown";
292
+
293
+ export interface TelegramWorkspaceExternalProtectionEvidence {
294
+ liveOwner: TelegramWorkspaceProtectionState;
295
+ acceptedWork: TelegramWorkspaceProtectionState;
296
+ deliveryAuthority: TelegramWorkspaceProtectionState;
297
+ }
298
+
299
+ export interface TelegramWorkspaceSlotOccupancySnapshot {
300
+ bindings: TelegramWorkspaceSlotOccupancy[];
301
+ reservedSlots: string[];
302
+ }
303
+
146
304
  export type TelegramBotThreadMode = "unknown" | "enabled" | "disabled";
147
305
 
148
306
  export interface TelegramBotStateSnapshot {
@@ -162,6 +320,8 @@ export interface TelegramTopicTargetFile {
162
320
  diagnostics?: Record<string, unknown>;
163
321
  threads: TelegramTopicTargetRecord[];
164
322
  identities?: TelegramThreadIdentityRecord[];
323
+ workspaceBindings?: TelegramWorkspaceThreadBinding[];
324
+ workspaceRetirements?: TelegramWorkspaceRetirementIntent[];
165
325
  reservations?: TelegramThreadReservation[];
166
326
  pendingProvisions?: TelegramThreadPendingProvision[];
167
327
  pendingCleanups?: TelegramThreadCleanupIntent[];
@@ -269,6 +429,70 @@ export interface TelegramTopicTargetStore {
269
429
  profileKey: string,
270
430
  ) => TelegramThreadIdentityRecord | undefined;
271
431
  forgetIdentityByProfileKey: (profileKey: string) => boolean;
432
+ listWorkspaceBindings: () => TelegramWorkspaceThreadBinding[];
433
+ listWorkspaceRetirementIntents: () => TelegramWorkspaceRetirementIntent[];
434
+ commitWorkspaceJournalEvidence: (
435
+ expected: TelegramWorkspaceThreadBinding,
436
+ journalBindingKeys: readonly string[],
437
+ complete: boolean,
438
+ ) => TelegramWorkspaceThreadBinding | undefined;
439
+ upsertWorkspaceRetirementIntent: (
440
+ intent: TelegramWorkspaceRetirementIntent,
441
+ ) => boolean;
442
+ removeWorkspaceRetirementIntent: (
443
+ expected: TelegramWorkspaceRetirementIntent,
444
+ ) => boolean;
445
+ replaceWorkspaceRetirementIntent: (
446
+ expected: TelegramWorkspaceRetirementIntent,
447
+ replacement: TelegramWorkspaceRetirementIntent,
448
+ isCurrent: () => boolean,
449
+ ) => Promise<boolean>;
450
+ commitWorkspaceRetirement: (
451
+ expected: TelegramWorkspaceRetirementIntent,
452
+ isCurrent: () => boolean,
453
+ ) => Promise<boolean>;
454
+ commitInactiveWorkspaceCleanup: (
455
+ expected: TelegramWorkspaceThreadBinding | {
456
+ cwd: string; workspaceKey: string; instanceSlot: string; slot: string; bindingKey: string;
457
+ target: { chatId: number; threadId: number }; inactiveSinceMs: number; bindingUpdatedAtMs: number;
458
+ },
459
+ isCurrent: () => boolean,
460
+ ) => Promise<boolean>;
461
+ /** Caller must separately prove no external live owner, accepted work, or delivery authority. */
462
+ captureWorkspaceSlotOccupancy: (
463
+ getExternalProtection: (
464
+ binding: TelegramWorkspaceThreadBinding,
465
+ ) => TelegramWorkspaceExternalProtectionEvidence,
466
+ options?: { expectedRetirement?: TelegramWorkspaceRetirementIntent },
467
+ ) => TelegramWorkspaceSlotOccupancySnapshot;
468
+ hasWorkspaceBinding: (cwd: string) => boolean;
469
+ setWorkspaceDisplayTitle: (
470
+ expected: TelegramWorkspaceThreadBinding,
471
+ title: string,
472
+ ) => boolean;
473
+ markWorkspaceBindingInactiveByTarget: (
474
+ target: TelegramTarget,
475
+ inactiveSinceMs?: number,
476
+ ) => boolean;
477
+ markWorkspaceBindingActiveByTarget: (target: TelegramTarget) => boolean;
478
+ getWorkspaceBinding: (
479
+ cwd: string,
480
+ instanceSlot?: string,
481
+ ) => TelegramWorkspaceThreadBinding | undefined;
482
+ claimWorkspaceIdentity: (
483
+ cwd: string,
484
+ instanceId: string,
485
+ previousInstanceId?: string,
486
+ options?: {
487
+ existingBindingOnly?: boolean;
488
+ onCapacityUnavailable?: () => void;
489
+ },
490
+ ) => TelegramWorkspaceBindingIdentity | undefined;
491
+ releaseWorkspaceClaim: (instanceId: string) => boolean;
492
+ upsertWorkspaceBinding: (
493
+ binding: TelegramWorkspaceThreadBinding,
494
+ claimInstanceId?: string,
495
+ ) => TelegramWorkspaceThreadBinding | undefined;
272
496
  upsert: (record: TelegramTopicTargetRecord) => TelegramTopicTargetRecord;
273
497
  markOfflineByInstanceId: (instanceId: string) => number;
274
498
  markStaleByTarget: (
@@ -280,10 +504,17 @@ export interface TelegramTopicTargetStore {
280
504
  renameByTarget: (
281
505
  target: TelegramTarget,
282
506
  threadName: string,
507
+ options?: { updateDisplayTitle: boolean },
508
+ ) => TelegramTopicTargetRecord | undefined;
509
+ clearManualNameByTarget: (
510
+ target: TelegramTarget,
511
+ automaticTitle: string,
283
512
  ) => TelegramTopicTargetRecord | undefined;
284
513
  allocateSlot: (
285
514
  profileKey: string,
286
515
  preferredSlot?: string,
516
+ workspaceBindingKey?: string,
517
+ options?: { excludeCurrentRecord?: boolean },
287
518
  ) => string | undefined;
288
519
  /** Claim the first reusable inactive thread for an instance, linking it to instanceId. */
289
520
  claimReusableTarget: (
@@ -371,9 +602,11 @@ export function createTelegramCleanupTargetProtection(
371
602
 
372
603
  export interface TelegramTopicTargetStoreOptions {
373
604
  path: string | (() => string);
605
+ telegramProfile?: string | (() => string | undefined);
374
606
  getNowMs?: () => number;
375
607
  canPersist?: () => boolean;
376
608
  commitPersist?: (commit: () => void) => boolean;
609
+ getExternalReservedSlots?: () => readonly string[];
377
610
  }
378
611
 
379
612
  export interface TelegramTopicTargetProvisionerDeps {
@@ -389,10 +622,13 @@ export interface TelegramTopicTargetProvisionerDeps {
389
622
  | "markStaleByTarget"
390
623
  | "allocateSlot"
391
624
  | "claimReusableTarget"
625
+ | "listWorkspaceBindings"
392
626
  | "listPendingProvisions"
393
627
  | "upsertPendingProvision"
394
628
  | "recordPendingProvisionTargetRecovery"
395
629
  | "removePendingProvision"
630
+ | "listSyncObservations"
631
+ | "listPendingCleanups"
396
632
  | "persist"
397
633
  >;
398
634
  callApi: <TResponse>(
@@ -401,6 +637,9 @@ export interface TelegramTopicTargetProvisionerDeps {
401
637
  options?: TelegramApiCallOptions,
402
638
  ) => Promise<TResponse>;
403
639
  topicNameTemplate?: string;
640
+ resolveInitialWorkspaceDisplayTitle?: (
641
+ binding: TelegramWorkspaceDisplayBinding,
642
+ ) => string | undefined;
404
643
  getNowMs?: () => number;
405
644
  getRandom?: () => number;
406
645
  getCurrentLeaderEpoch?: () => number | string | undefined;
@@ -408,11 +647,19 @@ export interface TelegramTopicTargetProvisionerDeps {
408
647
  }
409
648
 
410
649
  export interface TelegramTopicTargetRenamerDeps {
411
- store: Pick<TelegramTopicTargetStore, "renameByTarget">;
650
+ store: Pick<
651
+ TelegramTopicTargetStore,
652
+ | "renameByTarget"
653
+ | "list"
654
+ | "listWorkspaceBindings"
655
+ | "listPendingProvisions"
656
+ >;
412
657
  callApi: <TResponse>(
413
658
  method: string,
414
659
  body: Record<string, unknown>,
415
660
  ) => Promise<TResponse>;
661
+ assertAuthority?: () => void;
662
+ shouldRenameDisplayedTitle?: () => boolean;
416
663
  topicNameTemplate?: string;
417
664
  }
418
665
 
@@ -423,6 +670,57 @@ export interface TelegramTopicTargetProvisionRequest {
423
670
  profileKey: string;
424
671
  threadName?: string;
425
672
  preferredSlot?: string;
673
+ workspaceBindingKey?: string;
674
+ workspaceCwd?: string;
675
+ }
676
+
677
+ /** Pending creation evidence cannot bypass an exact target's unresolved cleanup or closure. */
678
+ export function assertTelegramPendingTopicRecoveryAllowed(
679
+ store: Pick<TelegramTopicTargetStore,
680
+ "listPendingProvisions" | "listPendingCleanups" | "listSyncObservations">,
681
+ target: TelegramTarget,
682
+ ): void {
683
+ if (!store.listPendingProvisions().some((entry) =>
684
+ entry.target && targetMatches(entry.target, target),
685
+ )) return;
686
+ if (store.listPendingCleanups().some((entry) => targetMatches(entry.target, target)) ||
687
+ store.listSyncObservations().some((entry) =>
688
+ targetMatches(entry.target, target) && entry.syncStatus === "closed",
689
+ )) {
690
+ throw new Error("Telegram pending topic requires reconciliation before recovery.");
691
+ }
692
+ }
693
+
694
+ /** Settle creation-title evidence with the exact Workspace claim; caller fences and persists. */
695
+ export function commitTelegramWorkspaceProvisionBinding(input: {
696
+ store: Pick<TelegramTopicTargetStore,
697
+ "upsertWorkspaceBinding" | "setWorkspaceDisplayTitle" |
698
+ "listPendingProvisions" | "removePendingProvision" |
699
+ "listPendingCleanups" | "listSyncObservations">;
700
+ binding: TelegramWorkspaceThreadBinding;
701
+ instanceId: string;
702
+ profileKey: string;
703
+ displayTitle?: string;
704
+ }): TelegramWorkspaceThreadBinding {
705
+ assertTelegramPendingTopicRecoveryAllowed(input.store, input.binding.target);
706
+ const pending = input.store.listPendingProvisions().filter((provision) =>
707
+ provision.target && targetMatches(provision.target, input.binding.target) &&
708
+ provision.slot === input.binding.slot &&
709
+ (provision.instanceId === input.instanceId || provision.profileKey === input.profileKey),
710
+ );
711
+ const titles = new Set(pending.map((provision) => provision.displayTitle)
712
+ .filter((title) => title !== undefined));
713
+ if (input.displayTitle !== undefined) titles.add(input.displayTitle);
714
+ if (titles.size > 1) throw new Error("Telegram Workspace creation title evidence conflicts.");
715
+ const displayTitle = titles.values().next().value;
716
+ const committed = input.store.upsertWorkspaceBinding(input.binding, input.instanceId);
717
+ if (!committed) throw new Error("Telegram Workspace binding claim changed.");
718
+ if (displayTitle !== undefined &&
719
+ !input.store.setWorkspaceDisplayTitle(committed, displayTitle)) {
720
+ throw new Error("Telegram Workspace display title commit changed binding.");
721
+ }
722
+ for (const provision of pending) input.store.removePendingProvision(provision.id);
723
+ return displayTitle === undefined ? committed : { ...committed, displayTitle };
426
724
  }
427
725
 
428
726
  export interface TelegramTopicTargetRenameRequest {
@@ -435,6 +733,7 @@ export interface TelegramTopicTargetProvisionResult {
435
733
  target: TelegramTarget & { threadId: number };
436
734
  reused: boolean;
437
735
  record: TelegramTopicTargetRecord;
736
+ displayTitle?: string;
438
737
  }
439
738
 
440
739
  interface TelegramTopicResult {
@@ -620,13 +919,22 @@ function parseThreadOwner(value: unknown): TelegramThreadOwner | undefined {
620
919
  cwd: typeof record.cwd === "string" ? record.cwd : undefined,
621
920
  instanceId:
622
921
  typeof record.instanceId === "string" ? record.instanceId : undefined,
922
+ ...(typeof record.telegramProfile === "string"
923
+ ? { telegramProfile: record.telegramProfile }
924
+ : {}),
623
925
  };
624
926
  }
625
927
  if (
626
928
  record.kind === "manual-follower" &&
627
929
  typeof record.instanceId === "string"
628
930
  ) {
629
- return { kind: "manual-follower", instanceId: record.instanceId };
931
+ return {
932
+ kind: "manual-follower",
933
+ instanceId: record.instanceId,
934
+ ...(typeof record.telegramProfile === "string"
935
+ ? { telegramProfile: record.telegramProfile }
936
+ : {}),
937
+ };
630
938
  }
631
939
  if (
632
940
  record.kind === "pending-topic" &&
@@ -729,6 +1037,11 @@ function normalizeRecord(
729
1037
  createdAtMs: record.createdAtMs,
730
1038
  updatedAtMs: record.updatedAtMs,
731
1039
  threadName: getPersistedThreadName(record),
1040
+ ...(typeof record.manualThreadName === "string" &&
1041
+ normalizeTelegramTopicTargetThreadName(record.manualThreadName)
1042
+ ? { manualThreadName:
1043
+ normalizeTelegramTopicTargetThreadName(record.manualThreadName) }
1044
+ : {}),
732
1045
  instanceId:
733
1046
  typeof record.instanceId === "string" ? record.instanceId : undefined,
734
1047
  slot: typeof record.slot === "string" ? record.slot : undefined,
@@ -810,6 +1123,134 @@ function cloneIdentityRecord(
810
1123
  return { ...identity };
811
1124
  }
812
1125
 
1126
+ function getWorkspaceBindingMapKey(
1127
+ binding: Pick<TelegramWorkspaceThreadBinding, "cwd" | "instanceSlot">,
1128
+ ): string {
1129
+ return `${binding.cwd}\u0000${binding.instanceSlot}`;
1130
+ }
1131
+
1132
+ function normalizeWorkspaceBindingRecord(
1133
+ value: unknown,
1134
+ ): TelegramWorkspaceThreadBinding | undefined {
1135
+ if (!value || typeof value !== "object" || Array.isArray(value))
1136
+ return undefined;
1137
+ const record = value as Record<string, unknown>;
1138
+ if (
1139
+ typeof record.cwd !== "string" ||
1140
+ typeof record.workspaceKey !== "string" ||
1141
+ typeof record.instanceSlot !== "string" ||
1142
+ typeof record.bindingKey !== "string" ||
1143
+ typeof record.updatedAtMs !== "number"
1144
+ ) {
1145
+ return undefined;
1146
+ }
1147
+ const cwd = normalizeTelegramWorkspacePath(record.cwd);
1148
+ if (
1149
+ !cwd ||
1150
+ cwd !== record.cwd ||
1151
+ !record.workspaceKey ||
1152
+ !/^[a-z]+$/u.test(record.instanceSlot) ||
1153
+ record.bindingKey !==
1154
+ (record.instanceSlot === "a"
1155
+ ? record.workspaceKey
1156
+ : `${record.workspaceKey}${record.instanceSlot}`)
1157
+ ) {
1158
+ return undefined;
1159
+ }
1160
+ const targetValue = record.target;
1161
+ if (
1162
+ !targetValue ||
1163
+ typeof targetValue !== "object" ||
1164
+ Array.isArray(targetValue)
1165
+ ) {
1166
+ return undefined;
1167
+ }
1168
+ const target = targetValue as Record<string, unknown>;
1169
+ if (
1170
+ typeof target.chatId !== "number" ||
1171
+ typeof target.threadId !== "number" ||
1172
+ !Number.isInteger(target.threadId)
1173
+ ) {
1174
+ return undefined;
1175
+ }
1176
+ const threadName = getPersistedThreadName(record);
1177
+ const manualThreadName =
1178
+ typeof record.manualThreadName === "string"
1179
+ ? normalizeTelegramTopicTargetThreadName(record.manualThreadName)
1180
+ : undefined;
1181
+ const slot =
1182
+ typeof record.slot === "string" && /^[A-Z]$/u.test(record.slot)
1183
+ ? record.slot
1184
+ : undefined;
1185
+ const journalBindingKeys = Array.isArray(record.journalBindingKeys) &&
1186
+ record.journalBindingKeys.every((key) =>
1187
+ typeof key === "string" && key.length > 0 && key.length <= 512,
1188
+ )
1189
+ ? Array.from(new Set(record.journalBindingKeys as string[]))
1190
+ : undefined;
1191
+ return {
1192
+ cwd,
1193
+ workspaceKey: record.workspaceKey,
1194
+ instanceSlot: record.instanceSlot,
1195
+ bindingKey: record.bindingKey,
1196
+ ...(record.showSlotSuffix === true ? { showSlotSuffix: true } : {}),
1197
+ ...(typeof record.displayTitle === "string" && record.displayTitle.trim()
1198
+ ? { displayTitle: record.displayTitle }
1199
+ : {}),
1200
+ ...(typeof record.inactiveSinceMs === "number" &&
1201
+ Number.isFinite(record.inactiveSinceMs) && record.inactiveSinceMs >= 0
1202
+ ? { inactiveSinceMs: record.inactiveSinceMs }
1203
+ : {}),
1204
+ ...(journalBindingKeys ? { journalBindingKeys } : {}),
1205
+ ...(record.journalBindingsComplete === true && journalBindingKeys
1206
+ ? { journalBindingsComplete: true as const }
1207
+ : {}),
1208
+ target: { chatId: target.chatId, threadId: target.threadId },
1209
+ ...(threadName ? { threadName } : {}),
1210
+ ...(manualThreadName ? { manualThreadName } : {}),
1211
+ ...(slot ? { slot } : {}),
1212
+ updatedAtMs: record.updatedAtMs,
1213
+ };
1214
+ }
1215
+
1216
+ function cloneWorkspaceBinding(
1217
+ binding: TelegramWorkspaceThreadBinding,
1218
+ ): TelegramWorkspaceThreadBinding {
1219
+ return { ...binding, target: { ...binding.target } };
1220
+ }
1221
+
1222
+ function normalizeWorkspaceRetirementIntent(
1223
+ value: unknown,
1224
+ ): TelegramWorkspaceRetirementIntent | undefined {
1225
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1226
+ const record = value as Record<string, unknown>;
1227
+ const binding = normalizeWorkspaceBindingRecord(record.binding);
1228
+ if (
1229
+ typeof record.id !== "string" || !record.id ||
1230
+ record.reason !== "pressure" ||
1231
+ typeof record.profileKey !== "string" || !record.profileKey ||
1232
+ !binding?.slot || binding.inactiveSinceMs === undefined ||
1233
+ !((typeof record.leaderEpoch === "number" && Number.isFinite(record.leaderEpoch)) ||
1234
+ (typeof record.leaderEpoch === "string" && record.leaderEpoch.length > 0)) ||
1235
+ typeof record.requestedAtMs !== "number" || !Number.isFinite(record.requestedAtMs) ||
1236
+ record.requestedAtMs < 0
1237
+ ) return undefined;
1238
+ return {
1239
+ id: record.id,
1240
+ reason: record.reason,
1241
+ profileKey: record.profileKey,
1242
+ binding,
1243
+ leaderEpoch: record.leaderEpoch,
1244
+ requestedAtMs: record.requestedAtMs,
1245
+ };
1246
+ }
1247
+
1248
+ function cloneWorkspaceRetirementIntent(
1249
+ intent: TelegramWorkspaceRetirementIntent,
1250
+ ): TelegramWorkspaceRetirementIntent {
1251
+ return { ...intent, binding: cloneWorkspaceBinding(intent.binding) };
1252
+ }
1253
+
813
1254
  function normalizeBotStateSnapshot(value: unknown): TelegramBotStateSnapshot {
814
1255
  if (!value || typeof value !== "object" || Array.isArray(value)) {
815
1256
  return { threadMode: "unknown" };
@@ -925,6 +1366,9 @@ function normalizePendingProvision(
925
1366
  ...(typeof record.threadName === "string"
926
1367
  ? { threadName: record.threadName }
927
1368
  : {}),
1369
+ ...(typeof record.displayTitle === "string" && record.displayTitle.trim()
1370
+ ? { displayTitle: record.displayTitle }
1371
+ : {}),
928
1372
  ...(typeof record.slot === "string" ? { slot: record.slot } : {}),
929
1373
  ...(target ? { target } : {}),
930
1374
  startedAtMs: record.startedAtMs,
@@ -1070,6 +1514,18 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
1070
1514
  return normalized ? [normalized] : [];
1071
1515
  })
1072
1516
  : [],
1517
+ workspaceBindings: Array.isArray(file.workspaceBindings)
1518
+ ? file.workspaceBindings.flatMap((binding) => {
1519
+ const normalized = normalizeWorkspaceBindingRecord(binding);
1520
+ return normalized ? [normalized] : [];
1521
+ })
1522
+ : [],
1523
+ workspaceRetirements: Array.isArray(file.workspaceRetirements)
1524
+ ? file.workspaceRetirements.flatMap((intent) => {
1525
+ const normalized = normalizeWorkspaceRetirementIntent(intent);
1526
+ return normalized ? [normalized] : [];
1527
+ })
1528
+ : [],
1073
1529
  reservations: Array.isArray(file.reservations)
1074
1530
  ? file.reservations.flatMap((reservation) => {
1075
1531
  const normalized = normalizeReservation(reservation);
@@ -1097,16 +1553,16 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
1097
1553
  };
1098
1554
  }
1099
1555
 
1100
- function serializeTelegramStateSemanticSnapshot(
1556
+ function getTelegramStateSemanticSnapshot(
1101
1557
  value: unknown,
1102
- ): string | undefined {
1558
+ ): Record<string, unknown> | undefined {
1103
1559
  if (!value || typeof value !== "object" || Array.isArray(value))
1104
1560
  return undefined;
1105
1561
  const { writtenAtMs: _writtenAtMs, ...semantic } = value as Record<
1106
1562
  string,
1107
1563
  unknown
1108
1564
  >;
1109
- return JSON.stringify(semantic);
1565
+ return semantic;
1110
1566
  }
1111
1567
 
1112
1568
  function targetMatches(left: TelegramTarget, right: TelegramTarget): boolean {
@@ -1174,7 +1630,7 @@ function getInstanceProcessKey(
1174
1630
  return pid && /^\d+$/.test(pid) ? pid : undefined;
1175
1631
  }
1176
1632
 
1177
- function isSameProcessInstance(
1633
+ export function isSameTelegramProcessInstance(
1178
1634
  left: string | undefined,
1179
1635
  right: string | undefined,
1180
1636
  ): boolean {
@@ -1197,9 +1653,39 @@ export function createTelegramTopicTargetStore(
1197
1653
  options: TelegramTopicTargetStoreOptions,
1198
1654
  ): TelegramTopicTargetStore {
1199
1655
  const getNowMs = options.getNowMs ?? Date.now;
1656
+ const captureExternalReservedSlots = (): string[] | undefined => {
1657
+ try {
1658
+ const slots = options.getExternalReservedSlots?.() ?? [];
1659
+ if (!Array.isArray(slots) ||
1660
+ slots.some((slot) => typeof slot !== "string" || !/^[A-Z]$/u.test(slot))) {
1661
+ return undefined;
1662
+ }
1663
+ return Array.from(new Set(slots));
1664
+ } catch {
1665
+ return undefined;
1666
+ }
1667
+ };
1200
1668
  let botState: TelegramBotStateSnapshot = { threadMode: "unknown" };
1201
1669
  let records = new Map<string, TelegramTopicTargetRecord>();
1202
1670
  let identities = new Map<string, TelegramThreadIdentityRecord>();
1671
+ let workspaceBindings = new Map<string, TelegramWorkspaceThreadBinding>();
1672
+ let workspaceRetirements: TelegramWorkspaceRetirementIntent[] = [];
1673
+ let workspaceRetirementCommitInFlight = false;
1674
+ const hasWorkspaceRetirementConflict = (input: {
1675
+ bindingKey?: string;
1676
+ cwd?: string;
1677
+ target?: TelegramTarget;
1678
+ slot?: string;
1679
+ }): boolean => workspaceRetirements.some((intent) =>
1680
+ (input.bindingKey !== undefined && intent.binding.bindingKey === input.bindingKey) ||
1681
+ (input.cwd !== undefined && intent.binding.cwd === input.cwd) ||
1682
+ (input.target !== undefined && targetMatches(intent.binding.target, input.target)) ||
1683
+ (input.slot !== undefined && intent.binding.slot === input.slot),
1684
+ );
1685
+ let workspaceClaims = new Map<
1686
+ string,
1687
+ { identity: TelegramWorkspaceBindingIdentity; instanceId: string }
1688
+ >();
1203
1689
  let reservations: TelegramThreadReservation[] = [];
1204
1690
  let pendingProvisions: TelegramThreadPendingProvision[] = [];
1205
1691
  let pendingCleanups: TelegramThreadCleanupIntent[] = [];
@@ -1220,6 +1706,19 @@ export function createTelegramTopicTargetStore(
1220
1706
  diagnostics?: Record<string, unknown>;
1221
1707
  } = {};
1222
1708
 
1709
+ const reconcileWorkspaceSuffixExposure = (): void => {
1710
+ const directoryCounts = new Map<string, number>();
1711
+ const exposed = new Set<string>();
1712
+ for (const binding of workspaceBindings.values()) {
1713
+ directoryCounts.set(binding.cwd, (directoryCounts.get(binding.cwd) ?? 0) + 1);
1714
+ if (binding.showSlotSuffix) exposed.add(binding.cwd);
1715
+ }
1716
+ for (const binding of workspaceBindings.values()) {
1717
+ if ((directoryCounts.get(binding.cwd) ?? 0) > 1 || exposed.has(binding.cwd)) {
1718
+ binding.showSlotSuffix = true;
1719
+ }
1720
+ }
1721
+ };
1223
1722
  const rememberSlot = (slot: string | undefined, nowMs = getNowMs()) => {
1224
1723
  if (!slot || !/^[A-Z]$/.test(slot)) return;
1225
1724
  botState = { ...botState, lastSlot: slot, updatedAtMs: nowMs };
@@ -1234,9 +1733,84 @@ export function createTelegramTopicTargetStore(
1234
1733
  updatedAtMs: record.updatedAtMs,
1235
1734
  });
1236
1735
  };
1736
+ const resolveWorkspaceKey = (cwd: string): string | undefined => {
1737
+ const known = [
1738
+ ...Array.from(workspaceBindings.values()),
1739
+ ...Array.from(workspaceClaims.values()).map((claim) => claim.identity),
1740
+ ];
1741
+ const existing = known.find((binding) => binding.cwd === cwd);
1742
+ if (existing) return existing.workspaceKey;
1743
+ const readable = createTelegramWorkspaceDirectoryKey(cwd);
1744
+ if (!readable) return undefined;
1745
+ const collision = known.some(
1746
+ (binding) =>
1747
+ binding.workspaceKey === readable && binding.cwd !== cwd,
1748
+ );
1749
+ if (!collision) return readable;
1750
+ const digest = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
1751
+ const prefix = readable.endsWith("--") ? readable.slice(0, -2) : readable;
1752
+ return `${prefix.slice(0, TELEGRAM_WORKSPACE_KEY_MAX_LENGTH - digest.length - 3)}-${digest}--`;
1753
+ };
1754
+ const isWorkspaceTargetLive = (
1755
+ binding: TelegramWorkspaceThreadBinding,
1756
+ ): TelegramTopicTargetRecord | undefined =>
1757
+ Array.from(records.values()).find(
1758
+ (record) =>
1759
+ isCurrentThreadRecord(record) &&
1760
+ targetMatches(record.target, binding.target),
1761
+ );
1762
+ const findLegacyWorkspaceMigrationRecord = (
1763
+ cwd: string,
1764
+ instanceId: string,
1765
+ previousInstanceId?: string,
1766
+ ): TelegramTopicTargetRecord | undefined => {
1767
+ const processIds = new Set(
1768
+ [instanceId, previousInstanceId].filter(
1769
+ (value): value is string => !!value,
1770
+ ),
1771
+ );
1772
+ return Array.from(records.values())
1773
+ .filter((record) => {
1774
+ const owner = getRecordOwner(record);
1775
+ const belongsToWorkspace =
1776
+ owner.kind === "leader"
1777
+ ? owner.cwd
1778
+ ? normalizeTelegramWorkspacePath(owner.cwd) === cwd
1779
+ : !!record.instanceId && Array.from(processIds).some((processId) =>
1780
+ isSameTelegramProcessInstance(record.instanceId, processId),
1781
+ )
1782
+ : owner.kind === "manual-follower" &&
1783
+ (processIds.has(owner.instanceId) ||
1784
+ (!!record.instanceId && processIds.has(record.instanceId)));
1785
+ if (!belongsToWorkspace) return false;
1786
+ const targetBinding = Array.from(workspaceBindings.values()).find(
1787
+ (binding) => targetMatches(binding.target, record.target),
1788
+ );
1789
+ return !targetBinding || targetBinding.cwd === cwd;
1790
+ })
1791
+ .sort((left, right) => right.updatedAtMs - left.updatedAtMs)[0];
1792
+ };
1237
1793
 
1238
1794
  const getPath = () =>
1239
1795
  typeof options.path === "function" ? options.path() : options.path;
1796
+ const getTelegramProfile = () =>
1797
+ typeof options.telegramProfile === "function"
1798
+ ? options.telegramProfile()
1799
+ : options.telegramProfile;
1800
+ const scopeOwnerToActiveProfile = (
1801
+ owner: TelegramThreadOwner,
1802
+ ): TelegramThreadOwner => {
1803
+ const telegramProfile = getTelegramProfile();
1804
+ if (
1805
+ !telegramProfile ||
1806
+ owner.kind === "pending-topic" ||
1807
+ owner.kind === "legacy" ||
1808
+ owner.telegramProfile
1809
+ ) {
1810
+ return owner;
1811
+ }
1812
+ return { ...owner, telegramProfile };
1813
+ };
1240
1814
  const getRecoveryPath = (path: string) => `${path}.provision-recovery.json`;
1241
1815
  const readProvisionRecoveries = (
1242
1816
  path: string,
@@ -1257,6 +1831,10 @@ export function createTelegramTopicTargetStore(
1257
1831
  botState = { threadMode: "unknown" };
1258
1832
  records = new Map();
1259
1833
  identities = new Map();
1834
+ workspaceBindings = new Map();
1835
+ workspaceRetirements = [];
1836
+ workspaceRetirementCommitInFlight = false;
1837
+ workspaceClaims = new Map();
1260
1838
  reservations = [];
1261
1839
  pendingProvisions = [];
1262
1840
  pendingCleanups = [];
@@ -1275,6 +1853,9 @@ export function createTelegramTopicTargetStore(
1275
1853
  botState = { threadMode: "unknown" };
1276
1854
  records = new Map();
1277
1855
  identities = new Map();
1856
+ workspaceBindings = new Map();
1857
+ workspaceRetirements = [];
1858
+ workspaceRetirementCommitInFlight = false;
1278
1859
  reservations = [];
1279
1860
  pendingProvisions = [];
1280
1861
  pendingCleanups = [];
@@ -1292,18 +1873,37 @@ export function createTelegramTopicTargetStore(
1292
1873
  const file = parseTopicTargetFile(rawFile);
1293
1874
  followerRecoveryHints = parseFollowerRecoveryHints(rawFile);
1294
1875
  botState = file.bot;
1876
+ const scopedRecords = file.threads.map((record) =>
1877
+ cloneRecord({
1878
+ ...record,
1879
+ owner: scopeOwnerToActiveProfile(getRecordOwner(record)),
1880
+ }),
1881
+ );
1295
1882
  records = new Map(
1296
- file.threads.map((record) => [
1297
- getRecordOwnerKey(record),
1298
- cloneRecord(record),
1299
- ]),
1883
+ scopedRecords.map((record) => [getRecordOwnerKey(record), record]),
1300
1884
  );
1301
1885
  identities = new Map(
1302
- (file.identities ?? []).map((identity) => [
1303
- identity.profileKey,
1304
- cloneIdentityRecord(identity),
1886
+ (file.identities ?? []).map((identity) => {
1887
+ const owner = scopeOwnerToActiveProfile(
1888
+ getTelegramThreadOwnerFromProfileKey(identity.profileKey),
1889
+ );
1890
+ const profileKey = getTelegramThreadOwnerKey(owner);
1891
+ return [
1892
+ profileKey,
1893
+ cloneIdentityRecord({ ...identity, profileKey }),
1894
+ ];
1895
+ }),
1896
+ );
1897
+ workspaceBindings = new Map(
1898
+ (file.workspaceBindings ?? []).map((binding) => [
1899
+ getWorkspaceBindingMapKey(binding),
1900
+ cloneWorkspaceBinding(binding),
1305
1901
  ]),
1306
1902
  );
1903
+ workspaceRetirements = (file.workspaceRetirements ?? []).map(
1904
+ cloneWorkspaceRetirementIntent,
1905
+ );
1906
+ reconcileWorkspaceSuffixExposure();
1307
1907
  for (const record of records.values()) rememberIdentity(record);
1308
1908
  const nowMs = getNowMs();
1309
1909
  reservations = (file.reservations ?? [])
@@ -1364,8 +1964,6 @@ export function createTelegramTopicTargetStore(
1364
1964
  }
1365
1965
  if (!dirty || !loaded) await loadFromDisk();
1366
1966
  if (invalidation && !invalidation.isCurrent()) return false;
1367
- await mkdir(dirname(path), { recursive: true });
1368
- const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
1369
1967
  const nowMs = getNowMs();
1370
1968
  reservations = reservations.filter(
1371
1969
  (reservation) =>
@@ -1393,6 +1991,12 @@ export function createTelegramTopicTargetStore(
1393
1991
  bot: botState,
1394
1992
  ...statusSnapshot,
1395
1993
  identities: Array.from(identities.values()).map(cloneIdentityRecord),
1994
+ workspaceBindings: Array.from(workspaceBindings.values()).map(
1995
+ cloneWorkspaceBinding,
1996
+ ),
1997
+ workspaceRetirements: workspaceRetirements.map(
1998
+ cloneWorkspaceRetirementIntent,
1999
+ ),
1396
2000
  reservations: reservations.map((reservation) => ({ ...reservation })),
1397
2001
  pendingProvisions: pendingProvisions.map((provision) => ({
1398
2002
  ...provision,
@@ -1426,24 +2030,30 @@ export function createTelegramTopicTargetStore(
1426
2030
  lastReconcileAction: "mark-stale",
1427
2031
  });
1428
2032
  }
1429
- let persistedSemanticSnapshot: string | undefined;
2033
+ let persistedSemanticSnapshot: Record<string, unknown> | undefined;
1430
2034
  try {
1431
- persistedSemanticSnapshot = serializeTelegramStateSemanticSnapshot(
2035
+ persistedSemanticSnapshot = getTelegramStateSemanticSnapshot(
1432
2036
  JSON.parse(await readFile(path, "utf8")),
1433
2037
  );
1434
2038
  } catch {
1435
2039
  /* missing or invalid snapshots must be replaced */
1436
2040
  }
2041
+ // Normalize optional fields to wire JSON; object key order is not a state change.
1437
2042
  if (
1438
- persistedSemanticSnapshot ===
1439
- serializeTelegramStateSemanticSnapshot(file) &&
2043
+ isDeepStrictEqual(
2044
+ persistedSemanticSnapshot,
2045
+ getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file))),
2046
+ ) &&
1440
2047
  mutationRevision === persistedRevision &&
1441
2048
  statusRevision === persistedStatusRevision
1442
2049
  ) {
2050
+ loadedPath = path;
1443
2051
  loaded = true;
1444
2052
  dirty = false;
1445
2053
  return true;
1446
2054
  }
2055
+ await mkdir(dirname(path), { recursive: true });
2056
+ const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
1447
2057
  await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
1448
2058
  encoding: "utf8",
1449
2059
  mode: 0o600,
@@ -1458,6 +2068,7 @@ export function createTelegramTopicTargetStore(
1458
2068
  // Fence and rename share one synchronous commit boundary. No stale
1459
2069
  // invalidation enters the live projection before durable commit.
1460
2070
  renameSync(tempPath, path);
2071
+ loadedPath = path;
1461
2072
  records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, invalidation.target)));
1462
2073
  syncObservations = file.syncObservations;
1463
2074
  mutationRevision += 1;
@@ -1488,6 +2099,7 @@ export function createTelegramTopicTargetStore(
1488
2099
  await unlink(tempPath).catch(() => undefined);
1489
2100
  throw error;
1490
2101
  }
2102
+ loadedPath = path;
1491
2103
  loaded = true;
1492
2104
  if (mutationRevision === persistedRevision) dirty = false;
1493
2105
  return true;
@@ -1678,6 +2290,620 @@ export function createTelegramTopicTargetStore(
1678
2290
  markDirty();
1679
2291
  return true;
1680
2292
  },
2293
+ listWorkspaceBindings() {
2294
+ return Array.from(workspaceBindings.values()).map(cloneWorkspaceBinding);
2295
+ },
2296
+ listWorkspaceRetirementIntents() {
2297
+ return workspaceRetirements.map(cloneWorkspaceRetirementIntent);
2298
+ },
2299
+ commitWorkspaceJournalEvidence(expected, journalBindingKeys, complete) {
2300
+ if (workspaceRetirementCommitInFlight || hasWorkspaceRetirementConflict(expected)) {
2301
+ return undefined;
2302
+ }
2303
+ const key = getWorkspaceBindingMapKey(expected);
2304
+ const current = workspaceBindings.get(key);
2305
+ if (!current || !isDeepStrictEqual(current, expected) ||
2306
+ !journalBindingKeys.every((bindingKey) =>
2307
+ typeof bindingKey === "string" && bindingKey.length > 0 && bindingKey.length <= 512,
2308
+ )) return undefined;
2309
+ const keys = Array.from(new Set(journalBindingKeys));
2310
+ if ((current.journalBindingsComplete === true) === complete &&
2311
+ isDeepStrictEqual(current.journalBindingKeys ?? [], keys)) {
2312
+ return cloneWorkspaceBinding(current);
2313
+ }
2314
+ const next = { ...current, updatedAtMs: getNowMs() };
2315
+ if (keys.length) next.journalBindingKeys = keys;
2316
+ else delete next.journalBindingKeys;
2317
+ if (complete) next.journalBindingsComplete = true;
2318
+ else delete next.journalBindingsComplete;
2319
+ workspaceBindings.set(key, next);
2320
+ markDirty();
2321
+ return cloneWorkspaceBinding(next);
2322
+ },
2323
+ upsertWorkspaceRetirementIntent(intent) {
2324
+ const next = normalizeWorkspaceRetirementIntent(intent);
2325
+ if (!next) return false;
2326
+ const current = workspaceBindings.get(getWorkspaceBindingMapKey(next.binding));
2327
+ if (!current || !isDeepStrictEqual(current, next.binding)) return false;
2328
+ const sameId = workspaceRetirements.find((candidate) => candidate.id === next.id);
2329
+ if (sameId) return isDeepStrictEqual(sameId, next);
2330
+ if (workspaceRetirements.some((candidate) =>
2331
+ candidate.binding.bindingKey === next.binding.bindingKey ||
2332
+ candidate.binding.slot === next.binding.slot ||
2333
+ targetMatches(candidate.binding.target, next.binding.target),
2334
+ )) return false;
2335
+ workspaceRetirements.push(cloneWorkspaceRetirementIntent(next));
2336
+ markDirty();
2337
+ return true;
2338
+ },
2339
+ removeWorkspaceRetirementIntent(expected) {
2340
+ const index = workspaceRetirements.findIndex((candidate) =>
2341
+ isDeepStrictEqual(candidate, expected),
2342
+ );
2343
+ if (index < 0) return false;
2344
+ workspaceRetirements.splice(index, 1);
2345
+ markDirty();
2346
+ return true;
2347
+ },
2348
+ async replaceWorkspaceRetirementIntent(expected, replacement, isCurrent) {
2349
+ if (workspaceRetirementCommitInFlight || !isCurrent()) return false;
2350
+ const previous = normalizeWorkspaceRetirementIntent(expected);
2351
+ const next = normalizeWorkspaceRetirementIntent(replacement);
2352
+ if (!previous || !next || previous.id !== next.id ||
2353
+ previous.reason !== next.reason || previous.profileKey !== next.profileKey ||
2354
+ previous.requestedAtMs !== next.requestedAtMs ||
2355
+ !isDeepStrictEqual(previous.binding, next.binding)) return false;
2356
+ const binding = workspaceBindings.get(getWorkspaceBindingMapKey(previous.binding));
2357
+ const intentIndex = workspaceRetirements.findIndex((candidate) =>
2358
+ isDeepStrictEqual(candidate, previous),
2359
+ );
2360
+ if (!binding || !isDeepStrictEqual(binding, previous.binding) || intentIndex < 0) {
2361
+ return false;
2362
+ }
2363
+ if (isDeepStrictEqual(previous, next)) return true;
2364
+ workspaceRetirementCommitInFlight = true;
2365
+ workspaceRetirements[intentIndex] = cloneWorkspaceRetirementIntent(next);
2366
+ markDirty();
2367
+ const restoreInMemory = () => {
2368
+ workspaceRetirements = workspaceRetirements.filter(
2369
+ (candidate) => candidate.id !== previous.id,
2370
+ );
2371
+ workspaceRetirements.push(cloneWorkspaceRetirementIntent(previous));
2372
+ markDirty();
2373
+ };
2374
+ try {
2375
+ if (!isCurrent()) {
2376
+ restoreInMemory();
2377
+ return false;
2378
+ }
2379
+ const persisted = await persistSnapshot();
2380
+ if (!persisted) {
2381
+ restoreInMemory();
2382
+ return false;
2383
+ }
2384
+ return true;
2385
+ } catch (error) {
2386
+ try {
2387
+ await loadFromDisk();
2388
+ } catch {
2389
+ restoreInMemory();
2390
+ throw error;
2391
+ }
2392
+ if (workspaceRetirements.some((candidate) => isDeepStrictEqual(candidate, next))) {
2393
+ return true;
2394
+ }
2395
+ if (!workspaceRetirements.some((candidate) => isDeepStrictEqual(candidate, previous))) {
2396
+ restoreInMemory();
2397
+ }
2398
+ throw error;
2399
+ } finally {
2400
+ workspaceRetirementCommitInFlight = false;
2401
+ }
2402
+ },
2403
+ async commitInactiveWorkspaceCleanup(expected, isCurrent) {
2404
+ if (workspaceRetirementCommitInFlight || !isCurrent()) return false;
2405
+ const cleanupSnapshot = "bindingUpdatedAtMs" in expected ? expected : undefined;
2406
+ const normalized = cleanupSnapshot ? undefined : normalizeWorkspaceBindingRecord(expected);
2407
+ if (cleanupSnapshot && (!cleanupSnapshot.cwd || !cleanupSnapshot.workspaceKey ||
2408
+ !cleanupSnapshot.instanceSlot || !cleanupSnapshot.slot || !cleanupSnapshot.bindingKey ||
2409
+ !Number.isSafeInteger(cleanupSnapshot.inactiveSinceMs) ||
2410
+ !Number.isSafeInteger(cleanupSnapshot.bindingUpdatedAtMs) ||
2411
+ !Number.isSafeInteger(cleanupSnapshot.target.chatId) ||
2412
+ !Number.isSafeInteger(cleanupSnapshot.target.threadId) || cleanupSnapshot.target.threadId <= 0)) return false;
2413
+ if (!cleanupSnapshot && (!normalized?.slot || normalized.inactiveSinceMs === undefined)) return false;
2414
+ const mapKey = getWorkspaceBindingMapKey((cleanupSnapshot ?? normalized)!);
2415
+ const binding = workspaceBindings.get(mapKey);
2416
+ if (!binding) return isCurrent();
2417
+ const exact = cleanupSnapshot ? binding.cwd === cleanupSnapshot.cwd &&
2418
+ binding.workspaceKey === cleanupSnapshot.workspaceKey &&
2419
+ binding.instanceSlot === cleanupSnapshot.instanceSlot && binding.slot === cleanupSnapshot.slot &&
2420
+ binding.bindingKey === cleanupSnapshot.bindingKey && targetMatches(binding.target, cleanupSnapshot.target) &&
2421
+ binding.inactiveSinceMs === cleanupSnapshot.inactiveSinceMs &&
2422
+ binding.updatedAtMs === cleanupSnapshot.bindingUpdatedAtMs : isDeepStrictEqual(binding, normalized);
2423
+ if (!exact) return false;
2424
+ const nowMs = getNowMs();
2425
+ const targetOrSlotMatches = (candidate: { target?: TelegramTarget; slot?: string }) =>
2426
+ (candidate.target && targetMatches(candidate.target, binding.target)) || candidate.slot === binding.slot;
2427
+ const protectedLocally = Array.from(records.values()).some((record) =>
2428
+ isCurrentThreadRecord(record) && targetOrSlotMatches(record)) ||
2429
+ Array.from(workspaceClaims.values()).some((claim) =>
2430
+ claim.identity.bindingKey === binding.bindingKey || claim.identity.slot === binding.slot) ||
2431
+ reservations.some((reservation) =>
2432
+ (reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) && targetOrSlotMatches(reservation)) ||
2433
+ pendingProvisions.some((provision) =>
2434
+ isPendingProvisionLiveOrTargeted(provision, nowMs) && targetOrSlotMatches(provision)) ||
2435
+ pendingCleanups.some(targetOrSlotMatches) || workspaceRetirements.some((intent) =>
2436
+ intent.binding.bindingKey === binding.bindingKey || targetOrSlotMatches(intent.binding));
2437
+ if (protectedLocally) return false;
2438
+ workspaceRetirementCommitInFlight = true;
2439
+ workspaceBindings.delete(mapKey);
2440
+ markDirty();
2441
+ const restore = () => { workspaceBindings.set(mapKey, cloneWorkspaceBinding(binding)); markDirty(); };
2442
+ try {
2443
+ if (!isCurrent()) { restore(); return false; }
2444
+ if (!await persistSnapshot()) { restore(); return false; }
2445
+ return true;
2446
+ } catch (error) {
2447
+ try { await loadFromDisk(); } catch { restore(); throw error; }
2448
+ if (!workspaceBindings.has(mapKey)) return true;
2449
+ if (!isDeepStrictEqual(workspaceBindings.get(mapKey), binding)) restore();
2450
+ throw error;
2451
+ } finally { workspaceRetirementCommitInFlight = false; }
2452
+ },
2453
+ async commitWorkspaceRetirement(expected, isCurrent) {
2454
+ if (workspaceRetirementCommitInFlight || !isCurrent()) return false;
2455
+ const normalized = normalizeWorkspaceRetirementIntent(expected);
2456
+ if (!normalized) return false;
2457
+ const mapKey = getWorkspaceBindingMapKey(normalized.binding);
2458
+ const binding = workspaceBindings.get(mapKey);
2459
+ const intentIndex = workspaceRetirements.findIndex((candidate) =>
2460
+ isDeepStrictEqual(candidate, normalized),
2461
+ );
2462
+ if (!binding || !isDeepStrictEqual(binding, normalized.binding) || intentIndex < 0) {
2463
+ return false;
2464
+ }
2465
+ const nowMs = getNowMs();
2466
+ const targetOrSlotMatches = (candidate: {
2467
+ target?: TelegramTarget;
2468
+ slot?: string;
2469
+ }) =>
2470
+ (candidate.target && targetMatches(candidate.target, binding.target)) ||
2471
+ candidate.slot === binding.slot;
2472
+ const locallyProtected =
2473
+ Array.from(records.values()).some((record) =>
2474
+ isCurrentThreadRecord(record) && targetOrSlotMatches(record),
2475
+ ) ||
2476
+ Array.from(workspaceClaims.values()).some((claim) =>
2477
+ claim.identity.bindingKey === binding.bindingKey ||
2478
+ claim.identity.slot === binding.slot,
2479
+ ) ||
2480
+ reservations.some((reservation) =>
2481
+ (reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) &&
2482
+ targetOrSlotMatches(reservation),
2483
+ ) ||
2484
+ pendingProvisions.some((provision) =>
2485
+ isPendingProvisionLiveOrTargeted(provision, nowMs) &&
2486
+ targetOrSlotMatches(provision),
2487
+ ) ||
2488
+ pendingCleanups.some(targetOrSlotMatches) ||
2489
+ workspaceRetirements.some((candidate, index) =>
2490
+ index !== intentIndex &&
2491
+ (candidate.binding.bindingKey === binding.bindingKey ||
2492
+ targetOrSlotMatches(candidate.binding)),
2493
+ );
2494
+ if (locallyProtected) return false;
2495
+ const previousRetirements = workspaceRetirements.map(
2496
+ cloneWorkspaceRetirementIntent,
2497
+ );
2498
+ workspaceRetirementCommitInFlight = true;
2499
+ workspaceBindings.delete(mapKey);
2500
+ workspaceRetirements.splice(intentIndex, 1);
2501
+ markDirty();
2502
+ const restoreInMemory = () => {
2503
+ workspaceBindings.set(mapKey, cloneWorkspaceBinding(binding));
2504
+ workspaceRetirements = previousRetirements.map(
2505
+ cloneWorkspaceRetirementIntent,
2506
+ );
2507
+ markDirty();
2508
+ };
2509
+ try {
2510
+ if (!isCurrent()) {
2511
+ restoreInMemory();
2512
+ return false;
2513
+ }
2514
+ const persisted = await persistSnapshot();
2515
+ if (!persisted) {
2516
+ restoreInMemory();
2517
+ return false;
2518
+ }
2519
+ return true;
2520
+ } catch (error) {
2521
+ try {
2522
+ await loadFromDisk();
2523
+ } catch {
2524
+ restoreInMemory();
2525
+ throw error;
2526
+ }
2527
+ const committed = !workspaceBindings.has(mapKey) &&
2528
+ !workspaceRetirements.some((candidate) => candidate.id === normalized.id);
2529
+ if (committed) return true;
2530
+ if (!workspaceBindings.has(mapKey) ||
2531
+ !workspaceRetirements.some((candidate) => candidate.id === normalized.id)) {
2532
+ restoreInMemory();
2533
+ }
2534
+ throw error;
2535
+ } finally {
2536
+ workspaceRetirementCommitInFlight = false;
2537
+ }
2538
+ },
2539
+ captureWorkspaceSlotOccupancy(getExternalProtection, options) {
2540
+ const nowMs = getNowMs();
2541
+ const bindings = Array.from(workspaceBindings.values()).map((binding) => {
2542
+ const slot = binding.slot?.toLowerCase() ?? "";
2543
+ const targetOrSlotMatches = (candidate: {
2544
+ target?: TelegramTarget;
2545
+ slot?: string;
2546
+ }) =>
2547
+ (candidate.target && targetMatches(candidate.target, binding.target)) ||
2548
+ (!!binding.slot && candidate.slot === binding.slot);
2549
+ const locallyProtected =
2550
+ Array.from(records.values()).some((record) =>
2551
+ isCurrentThreadRecord(record) && targetOrSlotMatches(record),
2552
+ ) ||
2553
+ Array.from(workspaceClaims.values()).some((claim) =>
2554
+ claim.identity.bindingKey === binding.bindingKey ||
2555
+ (!!binding.slot && claim.identity.slot === binding.slot),
2556
+ ) ||
2557
+ reservations.some((reservation) =>
2558
+ (reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs) &&
2559
+ targetOrSlotMatches(reservation),
2560
+ ) ||
2561
+ pendingProvisions.some((provision) =>
2562
+ isPendingProvisionLiveOrTargeted(provision, nowMs) &&
2563
+ targetOrSlotMatches(provision),
2564
+ ) ||
2565
+ pendingCleanups.some((intent) => targetOrSlotMatches(intent)) ||
2566
+ workspaceRetirements.some((intent) =>
2567
+ !isDeepStrictEqual(intent, options?.expectedRetirement) &&
2568
+ (intent.binding.bindingKey === binding.bindingKey ||
2569
+ targetOrSlotMatches(intent.binding)),
2570
+ );
2571
+ const externalProtection = getExternalProtection(cloneWorkspaceBinding(binding));
2572
+ const externalStates = [
2573
+ externalProtection.liveOwner,
2574
+ externalProtection.acceptedWork,
2575
+ externalProtection.deliveryAuthority,
2576
+ ];
2577
+ const hasValidInactivity = binding.inactiveSinceMs !== undefined &&
2578
+ Number.isFinite(binding.inactiveSinceMs) &&
2579
+ binding.inactiveSinceMs >= 0 && binding.inactiveSinceMs <= nowMs;
2580
+ const protection: TelegramWorkspaceSlotOccupancy["protection"] =
2581
+ locallyProtected || externalStates.includes("protected")
2582
+ ? "protected"
2583
+ : externalStates.every((state) => state === "clear") && hasValidInactivity
2584
+ ? "eligible"
2585
+ : "unknown";
2586
+ return {
2587
+ bindingKey: binding.bindingKey,
2588
+ slot,
2589
+ ...(binding.inactiveSinceMs !== undefined
2590
+ ? { inactiveSinceMs: binding.inactiveSinceMs }
2591
+ : {}),
2592
+ protection,
2593
+ };
2594
+ });
2595
+ const localReservedSlots = [
2596
+ ...Array.from(workspaceClaims.values()).map((claim) => claim.identity.slot),
2597
+ ...Array.from(records.values()).filter(isCurrentThreadRecord).map((record) => record.slot),
2598
+ ...reservations.filter((reservation) =>
2599
+ reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs,
2600
+ ).map((reservation) => reservation.slot),
2601
+ ...pendingProvisions.filter((provision) =>
2602
+ isPendingProvisionLiveOrTargeted(provision, nowMs),
2603
+ ).map((provision) => provision.slot),
2604
+ ].filter((slot): slot is string => !!slot && /^[A-Z]$/u.test(slot))
2605
+ .map((slot) => slot.toLowerCase());
2606
+ const externalReservedSlots = captureExternalReservedSlots();
2607
+ const reservedSlots = externalReservedSlots
2608
+ ? [
2609
+ ...localReservedSlots,
2610
+ ...externalReservedSlots.map((slot) => slot.toLowerCase()),
2611
+ ]
2612
+ : [...localReservedSlots, "invalid"];
2613
+ return { bindings, reservedSlots };
2614
+ },
2615
+ hasWorkspaceBinding(cwd) {
2616
+ const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
2617
+ return !!normalizedCwd && Array.from(workspaceBindings.values()).some(
2618
+ (binding) => binding.cwd === normalizedCwd,
2619
+ );
2620
+ },
2621
+ setWorkspaceDisplayTitle(expected, title) {
2622
+ if (hasWorkspaceRetirementConflict(expected)) return false;
2623
+ const key = getWorkspaceBindingMapKey(expected);
2624
+ const current = workspaceBindings.get(key);
2625
+ if (!current || !isDeepStrictEqual(current, expected) || !title.trim() ||
2626
+ title.length > 128) return false;
2627
+ if (current.displayTitle === title) return true;
2628
+ workspaceBindings.set(key, { ...current, displayTitle: title });
2629
+ markDirty();
2630
+ return true;
2631
+ },
2632
+ markWorkspaceBindingInactiveByTarget(target, inactiveSinceMs = getNowMs()) {
2633
+ if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0) return false;
2634
+ for (const [key, binding] of workspaceBindings) {
2635
+ if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined) continue;
2636
+ workspaceBindings.set(key, { ...binding, inactiveSinceMs });
2637
+ markDirty();
2638
+ return true;
2639
+ }
2640
+ return false;
2641
+ },
2642
+ markWorkspaceBindingActiveByTarget(target) {
2643
+ if (hasWorkspaceRetirementConflict({ target })) return false;
2644
+ for (const [key, binding] of workspaceBindings) {
2645
+ if (!targetMatches(binding.target, target) || binding.inactiveSinceMs === undefined) continue;
2646
+ const next = { ...binding };
2647
+ delete next.inactiveSinceMs;
2648
+ workspaceBindings.set(key, next);
2649
+ markDirty();
2650
+ return true;
2651
+ }
2652
+ return false;
2653
+ },
2654
+ getWorkspaceBinding(cwd, instanceSlot = "a") {
2655
+ const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
2656
+ if (!normalizedCwd || !/^[a-z]+$/u.test(instanceSlot)) return undefined;
2657
+ const binding = workspaceBindings.get(
2658
+ getWorkspaceBindingMapKey({ cwd: normalizedCwd, instanceSlot }),
2659
+ );
2660
+ return binding ? cloneWorkspaceBinding(binding) : undefined;
2661
+ },
2662
+ claimWorkspaceIdentity(cwd, instanceId, previousInstanceId, options) {
2663
+ if (workspaceRetirementCommitInFlight) return undefined;
2664
+ const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
2665
+ if (!normalizedCwd || !instanceId ||
2666
+ hasWorkspaceRetirementConflict({ cwd: normalizedCwd })) return undefined;
2667
+ const externalReservedSlots = captureExternalReservedSlots();
2668
+ if (!externalReservedSlots) {
2669
+ options?.onCapacityUnavailable?.();
2670
+ return undefined;
2671
+ }
2672
+ const externalReservedSlotKeys = externalReservedSlots.map((slot) =>
2673
+ slot.toLowerCase(),
2674
+ );
2675
+ for (const claim of workspaceClaims.values()) {
2676
+ if (claim.instanceId !== instanceId) continue;
2677
+ if (claim.identity.cwd !== normalizedCwd || !claim.identity.slot ||
2678
+ externalReservedSlotKeys.includes(claim.identity.slot.toLowerCase())) {
2679
+ return undefined;
2680
+ }
2681
+ if (
2682
+ options?.existingBindingOnly &&
2683
+ !workspaceBindings.has(getWorkspaceBindingMapKey(claim.identity))
2684
+ ) return undefined;
2685
+ return { ...claim.identity };
2686
+ }
2687
+ const workspaceKey = resolveWorkspaceKey(normalizedCwd);
2688
+ if (!workspaceKey) return undefined;
2689
+ let legacyRecord = findLegacyWorkspaceMigrationRecord(
2690
+ normalizedCwd,
2691
+ instanceId,
2692
+ previousInstanceId,
2693
+ );
2694
+ const legacyTarget = legacyRecord?.target;
2695
+ const targetBinding = legacyTarget
2696
+ ? Array.from(workspaceBindings.values()).find(
2697
+ (binding) =>
2698
+ binding.cwd === normalizedCwd &&
2699
+ targetMatches(binding.target, legacyTarget),
2700
+ )
2701
+ : undefined;
2702
+ let capacityUnavailable = false;
2703
+ const claimIdentity = (
2704
+ identity: TelegramWorkspaceBindingIdentity,
2705
+ ): TelegramWorkspaceBindingIdentity | undefined => {
2706
+ const mapKey = getWorkspaceBindingMapKey(identity);
2707
+ const existingClaim = workspaceClaims.get(mapKey);
2708
+ if (existingClaim) {
2709
+ return existingClaim.instanceId === instanceId
2710
+ ? { ...existingClaim.identity }
2711
+ : undefined;
2712
+ }
2713
+ const binding = workspaceBindings.get(mapKey);
2714
+ const liveRecord = binding
2715
+ ? isWorkspaceTargetLive(binding)
2716
+ : undefined;
2717
+ if (
2718
+ liveRecord &&
2719
+ liveRecord.instanceId !== instanceId &&
2720
+ liveRecord.instanceId !== previousInstanceId
2721
+ ) {
2722
+ return undefined;
2723
+ }
2724
+ const retainedTarget = binding?.target ?? legacyRecord?.target;
2725
+ const retainedSlot = binding?.slot ?? legacyRecord?.slot;
2726
+ const otherBindings = Array.from(workspaceBindings.values())
2727
+ .filter((other) => other.bindingKey !== identity.bindingKey && other.slot)
2728
+ .map((other) => ({
2729
+ bindingKey: other.bindingKey,
2730
+ slot: other.slot!.toLowerCase(),
2731
+ protection: "unknown" as const,
2732
+ }));
2733
+ const nowMs = getNowMs();
2734
+ const reservedSlots = [
2735
+ ...Array.from(workspaceClaims.values()).map((claim) => claim.identity.slot),
2736
+ ...Array.from(records.values())
2737
+ .filter((record) => isCurrentThreadRecord(record) &&
2738
+ !(retainedTarget && targetMatches(record.target, retainedTarget)))
2739
+ .map((record) => record.slot),
2740
+ ...reservations.filter((reservation) =>
2741
+ reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs,
2742
+ ).map((reservation) => reservation.slot),
2743
+ ...pendingProvisions.filter((provision) =>
2744
+ isPendingProvisionLiveOrTargeted(provision, nowMs),
2745
+ ).map((provision) => provision.slot),
2746
+ ...externalReservedSlots,
2747
+ ].filter((slot): slot is string => !!slot).map((slot) => slot.toLowerCase());
2748
+ const retainedSlotKey = retainedSlot?.toLowerCase();
2749
+ if (retainedSlotKey && reservedSlots.includes(retainedSlotKey)) return undefined;
2750
+ const retainedSlotConflicts = !!retainedSlotKey &&
2751
+ otherBindings.some((other) => other.slot === retainedSlotKey);
2752
+ let slot = retainedSlotConflicts
2753
+ ? Array.from(TELEGRAM_WORKSPACE_SLOTS).find((candidate) =>
2754
+ !reservedSlots.includes(candidate) &&
2755
+ !otherBindings.some((other) => other.slot === candidate),
2756
+ )?.toUpperCase()
2757
+ : retainedSlot;
2758
+ if (!slot) {
2759
+ const allocation = planTelegramWorkspaceSlotAllocation({
2760
+ bindings: otherBindings,
2761
+ reservedSlots,
2762
+ nowMs,
2763
+ });
2764
+ if (allocation.kind === "blocked" && allocation.reason === "invalid-state") {
2765
+ return undefined;
2766
+ }
2767
+ if (allocation.kind !== "free") {
2768
+ capacityUnavailable = true;
2769
+ return undefined;
2770
+ }
2771
+ slot = allocation.slot.toUpperCase();
2772
+ }
2773
+ if (!slot || !/^[A-Z]$/u.test(slot)) return undefined;
2774
+ const claimedIdentity = { ...identity, slot };
2775
+ workspaceClaims.set(mapKey, { identity: claimedIdentity, instanceId });
2776
+ return { ...claimedIdentity };
2777
+ };
2778
+ if (options?.existingBindingOnly) {
2779
+ const candidates = Array.from(workspaceBindings.values())
2780
+ .filter((binding) => binding.cwd === normalizedCwd)
2781
+ .sort((left, right) =>
2782
+ left.instanceSlot.length - right.instanceSlot.length ||
2783
+ left.instanceSlot.localeCompare(right.instanceSlot),
2784
+ );
2785
+ for (const binding of candidates) {
2786
+ const claimed = claimIdentity({
2787
+ cwd: binding.cwd,
2788
+ workspaceKey: binding.workspaceKey,
2789
+ instanceSlot: binding.instanceSlot,
2790
+ bindingKey: binding.bindingKey,
2791
+ });
2792
+ if (claimed) return claimed;
2793
+ }
2794
+ if (!legacyRecord) return undefined;
2795
+ }
2796
+ if (targetBinding) {
2797
+ const claimed = claimIdentity({
2798
+ cwd: targetBinding.cwd,
2799
+ workspaceKey: targetBinding.workspaceKey,
2800
+ instanceSlot: targetBinding.instanceSlot,
2801
+ bindingKey: targetBinding.bindingKey,
2802
+ });
2803
+ if (claimed) return claimed;
2804
+ if (options?.existingBindingOnly) return undefined;
2805
+ // A migrated live peer is not a handoff or a target for another slot.
2806
+ legacyRecord = undefined;
2807
+ }
2808
+ const attemptLimit = workspaceBindings.size + workspaceClaims.size + 1;
2809
+ for (let ordinal = 0; ordinal < attemptLimit; ordinal += 1) {
2810
+ const identity = createTelegramWorkspaceBindingIdentityWithKey(
2811
+ normalizedCwd,
2812
+ workspaceKey,
2813
+ ordinal,
2814
+ );
2815
+ if (!identity) return undefined;
2816
+ const mapKey = getWorkspaceBindingMapKey(identity);
2817
+ const binding = workspaceBindings.get(mapKey);
2818
+ if (legacyRecord && binding) continue;
2819
+ const claimed = claimIdentity(identity);
2820
+ if (!claimed) continue;
2821
+ if (legacyRecord && !binding) {
2822
+ workspaceBindings.set(mapKey, {
2823
+ ...identity,
2824
+ target: { ...legacyRecord.target },
2825
+ ...(legacyRecord.threadName
2826
+ ? { threadName: legacyRecord.threadName }
2827
+ : {}),
2828
+ ...(legacyRecord.slot ? { slot: legacyRecord.slot } : {}),
2829
+ updatedAtMs: getNowMs(),
2830
+ });
2831
+ reconcileWorkspaceSuffixExposure();
2832
+ markDirty();
2833
+ }
2834
+ return claimed;
2835
+ }
2836
+ if (capacityUnavailable) options?.onCapacityUnavailable?.();
2837
+ return undefined;
2838
+ },
2839
+ releaseWorkspaceClaim(instanceId) {
2840
+ let released = false;
2841
+ for (const [key, claim] of workspaceClaims) {
2842
+ if (claim.instanceId !== instanceId) continue;
2843
+ workspaceClaims.delete(key);
2844
+ released = true;
2845
+ }
2846
+ return released;
2847
+ },
2848
+ upsertWorkspaceBinding(binding, claimInstanceId) {
2849
+ if (workspaceRetirementCommitInFlight) return undefined;
2850
+ const next = normalizeWorkspaceBindingRecord(binding);
2851
+ if (next && hasWorkspaceRetirementConflict(next)) return undefined;
2852
+ if (!next) return undefined;
2853
+ if (next.slot && Array.from(workspaceBindings.values()).some((existing) =>
2854
+ existing.bindingKey !== next.bindingKey && existing.slot === next.slot,
2855
+ )) return undefined;
2856
+ for (const existing of workspaceBindings.values()) {
2857
+ if (
2858
+ existing.workspaceKey === next.workspaceKey &&
2859
+ existing.cwd !== next.cwd
2860
+ ) {
2861
+ return undefined;
2862
+ }
2863
+ }
2864
+ const nextMapKey = getWorkspaceBindingMapKey(next);
2865
+ const previous = workspaceBindings.get(nextMapKey);
2866
+ if (previous?.showSlotSuffix) next.showSlotSuffix = true;
2867
+ if (previous?.manualThreadName && !next.manualThreadName) {
2868
+ next.manualThreadName = previous.manualThreadName;
2869
+ }
2870
+ if (previous) {
2871
+ const journalBindingKeys = Array.from(new Set([
2872
+ ...(previous.journalBindingKeys ?? []),
2873
+ ...(next.journalBindingKeys ?? []),
2874
+ ]));
2875
+ if (journalBindingKeys.length) next.journalBindingKeys = journalBindingKeys;
2876
+ else delete next.journalBindingKeys;
2877
+ if (previous.journalBindingsComplete) next.journalBindingsComplete = true;
2878
+ else delete next.journalBindingsComplete;
2879
+ }
2880
+ if (previous && !targetMatches(previous.target, next.target)) {
2881
+ delete next.displayTitle;
2882
+ delete next.inactiveSinceMs;
2883
+ } else if (previous) {
2884
+ if (previous.displayTitle) next.displayTitle = previous.displayTitle;
2885
+ if (previous.inactiveSinceMs !== undefined) next.inactiveSinceMs = previous.inactiveSinceMs;
2886
+ }
2887
+ const claim = workspaceClaims.get(nextMapKey);
2888
+ if (claimInstanceId) {
2889
+ if (claim?.instanceId !== claimInstanceId) return undefined;
2890
+ if (next.slot !== undefined && claim.identity.slot !== next.slot) return undefined;
2891
+ next.slot = claim.identity.slot;
2892
+ } else if (claim) {
2893
+ return undefined;
2894
+ }
2895
+ for (const [key, existing] of workspaceBindings) {
2896
+ if (key === nextMapKey) continue;
2897
+ if (targetMatches(existing.target, next.target)) {
2898
+ workspaceBindings.delete(key);
2899
+ }
2900
+ }
2901
+ workspaceBindings.set(nextMapKey, next);
2902
+ reconcileWorkspaceSuffixExposure();
2903
+ if (claim) workspaceClaims.delete(nextMapKey);
2904
+ markDirty();
2905
+ return cloneWorkspaceBinding(next);
2906
+ },
1681
2907
  upsert(record) {
1682
2908
  const next = cloneRecord(record);
1683
2909
  const nextOwnerKey = getRecordOwnerKey(next);
@@ -1735,27 +2961,31 @@ export function createTelegramTopicTargetStore(
1735
2961
  return count;
1736
2962
  },
1737
2963
  markStaleByTarget(target, syncStatus = "unknown", lastSyncError) {
1738
- for (const record of Array.from(records.values())) {
1739
- if (!targetMatches(record.target, target)) continue;
1740
- const nowMs = getNowMs();
1741
- syncObservations = syncObservations.filter(
1742
- (observation) => !targetMatches(observation.target, record.target),
1743
- );
1744
- syncObservations.push({
1745
- target: record.target,
1746
- syncStatus,
1747
- observedAtMs: nowMs,
1748
- ...(record.instanceId ? { instanceId: record.instanceId } : {}),
1749
- ...(record.slot ? { slot: record.slot } : {}),
1750
- ...(lastSyncError ? { lastSyncError } : {}),
1751
- lastReconcileAction: "mark-stale",
1752
- });
2964
+ const record = Array.from(records.values()).find((entry) => targetMatches(entry.target, target));
2965
+ const pending = syncStatus === "deleted" ? pendingProvisions.find((entry) =>
2966
+ entry.target && targetMatches(entry.target, target),
2967
+ ) : undefined;
2968
+ const source = record ?? pending;
2969
+ if (!source?.target) return false;
2970
+ syncObservations = syncObservations.filter((entry) => !targetMatches(entry.target, target));
2971
+ syncObservations.push({
2972
+ target: { ...source.target }, syncStatus, observedAtMs: getNowMs(),
2973
+ ...(source.instanceId ? { instanceId: source.instanceId } : {}),
2974
+ ...(source.slot ? { slot: source.slot } : {}),
2975
+ ...(lastSyncError ? { lastSyncError } : {}),
2976
+ lastReconcileAction: "mark-stale",
2977
+ });
2978
+ if (record) {
1753
2979
  rememberIdentity(record);
1754
2980
  records.delete(getRecordOwnerKey(record));
1755
- markDirty();
1756
- return true;
1757
2981
  }
1758
- return false;
2982
+ if (syncStatus === "deleted") {
2983
+ pendingProvisions = pendingProvisions.filter((entry) =>
2984
+ !entry.target || !targetMatches(entry.target, target),
2985
+ );
2986
+ }
2987
+ markDirty();
2988
+ return true;
1759
2989
  },
1760
2990
  markActiveByTarget(target) {
1761
2991
  const nowMs = getNowMs();
@@ -1773,16 +3003,45 @@ export function createTelegramTopicTargetStore(
1773
3003
  }
1774
3004
  return false;
1775
3005
  },
1776
- renameByTarget(target, threadName) {
3006
+ renameByTarget(target, threadName, options) {
3007
+ if (hasWorkspaceRetirementConflict({ target })) return undefined;
1777
3008
  const nowMs = getNowMs();
1778
3009
  const normalizedThreadName =
1779
3010
  normalizeTelegramTopicTargetThreadName(threadName);
1780
3011
  if (!normalizedThreadName) return undefined;
1781
3012
  for (const record of records.values()) {
1782
3013
  if (!targetMatches(record.target, target)) continue;
1783
- record.threadName = normalizedThreadName;
3014
+ record.manualThreadName = normalizedThreadName;
1784
3015
  record.updatedAtMs = nowMs;
1785
- rememberIdentity(record);
3016
+ for (const binding of workspaceBindings.values()) {
3017
+ if (!targetMatches(binding.target, target)) continue;
3018
+ const previousTitle = binding.displayTitle ?? binding.manualThreadName ??
3019
+ binding.threadName;
3020
+ binding.manualThreadName = normalizedThreadName;
3021
+ binding.displayTitle = options?.updateDisplayTitle === false
3022
+ ? previousTitle : normalizedThreadName;
3023
+ binding.updatedAtMs = nowMs;
3024
+ }
3025
+ markDirty();
3026
+ return cloneRecord(record);
3027
+ }
3028
+ return undefined;
3029
+ },
3030
+ clearManualNameByTarget(target, automaticTitle) {
3031
+ if (hasWorkspaceRetirementConflict({ target })) return undefined;
3032
+ const title = normalizeTelegramTopicTargetThreadName(automaticTitle);
3033
+ if (!title) return undefined;
3034
+ const nowMs = getNowMs();
3035
+ for (const record of records.values()) {
3036
+ if (!targetMatches(record.target, target)) continue;
3037
+ delete record.manualThreadName;
3038
+ record.updatedAtMs = nowMs;
3039
+ for (const binding of workspaceBindings.values()) {
3040
+ if (!targetMatches(binding.target, target)) continue;
3041
+ delete binding.manualThreadName;
3042
+ binding.displayTitle = title;
3043
+ binding.updatedAtMs = nowMs;
3044
+ }
1786
3045
  markDirty();
1787
3046
  return cloneRecord(record);
1788
3047
  }
@@ -1793,9 +3052,9 @@ export function createTelegramTopicTargetStore(
1793
3052
  const candidates = Array.from(records.values())
1794
3053
  .filter((record) => {
1795
3054
  if (record.instanceId) return false;
3055
+ if (!record.slot || !/^[A-Z]$/u.test(record.slot)) return false;
1796
3056
  if (record.slot === "A") return false;
1797
3057
  if (record.status !== "pending") return false;
1798
- if (!record.slot) return true;
1799
3058
  return !Array.from(records.values()).some(
1800
3059
  (other) =>
1801
3060
  other !== record &&
@@ -1825,17 +3084,64 @@ export function createTelegramTopicTargetStore(
1825
3084
  markDirty();
1826
3085
  return cloneRecord(record);
1827
3086
  },
1828
- allocateSlot(profileKey, preferredSlot) {
3087
+ allocateSlot(profileKey, preferredSlot, workspaceBindingKey, options) {
3088
+ if (workspaceRetirementCommitInFlight) return undefined;
3089
+ const externalReservedSlots = captureExternalReservedSlots();
3090
+ if (!externalReservedSlots) return undefined;
3091
+ const isExternalSlotOccupied = (slot: string): boolean =>
3092
+ externalReservedSlots.includes(slot);
1829
3093
  const ownerKey = getTelegramThreadOwnerKey(
1830
3094
  getTelegramThreadOwnerFromProfileKey(profileKey),
1831
3095
  );
1832
3096
  const existing = records.get(ownerKey) ?? records.get(profileKey);
1833
- if (existing?.slot && isCurrentThreadRecord(existing)) {
1834
- return existing.slot;
1835
- }
1836
3097
  const nowMs = getNowMs();
3098
+ const isWorkspaceSlotOccupied = (slot: string): boolean =>
3099
+ Array.from(workspaceBindings.values()).some((binding) =>
3100
+ binding.bindingKey !== workspaceBindingKey && binding.slot === slot,
3101
+ );
3102
+ const isWorkspaceClaimSlotOccupied = (slot: string): boolean =>
3103
+ Array.from(workspaceClaims.values()).some((claim) =>
3104
+ claim.identity.bindingKey !== workspaceBindingKey &&
3105
+ claim.identity.slot === slot,
3106
+ );
3107
+ if (existing?.slot && isCurrentThreadRecord(existing) &&
3108
+ !options?.excludeCurrentRecord) {
3109
+ const bindingConflict = Array.from(workspaceBindings.values()).some(
3110
+ (binding) => binding.bindingKey !== workspaceBindingKey &&
3111
+ binding.slot === existing.slot &&
3112
+ !targetMatches(binding.target, existing.target),
3113
+ );
3114
+ const claimConflict = Array.from(workspaceClaims.values()).some(
3115
+ (claim) => claim.identity.bindingKey !== workspaceBindingKey &&
3116
+ claim.identity.slot === existing.slot &&
3117
+ claim.instanceId !== existing.instanceId,
3118
+ );
3119
+ return bindingConflict || claimConflict ||
3120
+ isExternalSlotOccupied(existing.slot) ? undefined : existing.slot;
3121
+ }
3122
+ if (workspaceBindingKey) {
3123
+ const claim = Array.from(workspaceClaims.values()).find((claim) =>
3124
+ claim.identity.bindingKey === workspaceBindingKey,
3125
+ );
3126
+ const slot = claim?.identity.slot;
3127
+ if (!slot) return undefined;
3128
+ const foreignClaim = Array.from(workspaceClaims.values()).some((other) =>
3129
+ other !== claim && other.identity.slot === slot,
3130
+ );
3131
+ const foreignBinding = Array.from(workspaceBindings.values()).some((binding) =>
3132
+ binding.bindingKey !== workspaceBindingKey && binding.slot === slot,
3133
+ );
3134
+ if (foreignClaim || foreignBinding || isExternalSlotOccupied(slot) ||
3135
+ isTelegramTopicTargetSlotOccupied(
3136
+ slot, records, reservations, pendingProvisions, nowMs,
3137
+ )) return undefined;
3138
+ return slot;
3139
+ }
1837
3140
  if (
1838
3141
  preferredSlot &&
3142
+ !isExternalSlotOccupied(preferredSlot) &&
3143
+ !isWorkspaceSlotOccupied(preferredSlot) &&
3144
+ !isWorkspaceClaimSlotOccupied(preferredSlot) &&
1839
3145
  !isTelegramTopicTargetSlotOccupied(
1840
3146
  preferredSlot,
1841
3147
  records,
@@ -1846,13 +3152,25 @@ export function createTelegramTopicTargetStore(
1846
3152
  ) {
1847
3153
  return preferredSlot;
1848
3154
  }
1849
- return getNextMonotonicSlot(
3155
+ const next = getNextMonotonicSlot(
1850
3156
  records,
1851
3157
  reservations,
1852
3158
  pendingProvisions,
1853
3159
  nowMs,
1854
3160
  botState.lastSlot,
1855
3161
  );
3162
+ if (next && !isExternalSlotOccupied(next) &&
3163
+ !isWorkspaceSlotOccupied(next) &&
3164
+ !isWorkspaceClaimSlotOccupied(next)) return next;
3165
+ return Array.from(TELEGRAM_WORKSPACE_SLOTS, (slot) => slot.toUpperCase())
3166
+ .find((slot) =>
3167
+ !isExternalSlotOccupied(slot) &&
3168
+ !isWorkspaceSlotOccupied(slot) &&
3169
+ !isWorkspaceClaimSlotOccupied(slot) &&
3170
+ !isTelegramTopicTargetSlotOccupied(
3171
+ slot, records, reservations, pendingProvisions, nowMs,
3172
+ ),
3173
+ );
1856
3174
  },
1857
3175
  };
1858
3176
  }
@@ -1944,15 +3262,58 @@ const TELEGRAM_THREAD_NAME_PALETTE: Record<string, readonly string[]> = {
1944
3262
  Z: ["Zenith", "Zephyr", "Zircon", "Zebra", "Zion"],
1945
3263
  };
1946
3264
 
3265
+ export function listOccupiedTelegramThreadIdentities(input: {
3266
+ records: readonly TelegramTopicTargetRecord[];
3267
+ workspaceBindings?: readonly TelegramWorkspaceThreadBinding[];
3268
+ pendingProvisions?: readonly TelegramThreadPendingProvision[];
3269
+ exceptTarget?: TelegramTarget;
3270
+ exceptWorkspaceBindingKey?: string;
3271
+ }): string[] {
3272
+ const occupied = new Set<string>();
3273
+ const add = (threadName: string | undefined): void => {
3274
+ if (!threadName) return;
3275
+ occupied.add(getTelegramTopicIdentityName(threadName));
3276
+ };
3277
+ for (const record of input.records) {
3278
+ if (!isCurrentThreadRecord(record)) continue;
3279
+ if (input.exceptTarget && targetMatches(record.target, input.exceptTarget))
3280
+ continue;
3281
+ add(record.manualThreadName);
3282
+ add(record.threadName);
3283
+ }
3284
+ for (const binding of input.workspaceBindings ?? []) {
3285
+ if (binding.bindingKey === input.exceptWorkspaceBindingKey) continue;
3286
+ if (input.exceptTarget && targetMatches(binding.target, input.exceptTarget))
3287
+ continue;
3288
+ add(binding.manualThreadName);
3289
+ add(binding.threadName);
3290
+ }
3291
+ for (const pending of input.pendingProvisions ?? []) {
3292
+ if (
3293
+ input.exceptTarget &&
3294
+ pending.target &&
3295
+ targetMatches(pending.target, input.exceptTarget)
3296
+ ) {
3297
+ continue;
3298
+ }
3299
+ add(pending.threadName);
3300
+ }
3301
+ return Array.from(occupied);
3302
+ }
3303
+
1947
3304
  export function chooseTelegramThreadName(input: {
1948
3305
  slot: string | undefined;
1949
3306
  entropy?: number | string;
1950
3307
  getRandom?: () => number;
3308
+ occupied?: readonly string[];
1951
3309
  }): string | undefined {
1952
3310
  if (!input.slot || !/^[A-Z]$/.test(input.slot)) return undefined;
1953
3311
  const names = TELEGRAM_THREAD_NAME_PALETTE[input.slot];
1954
3312
  if (!names || names.length === 0) return undefined;
1955
- const index = input.getRandom
3313
+ const occupied = new Set(
3314
+ (input.occupied ?? []).map((name) => getTelegramTopicIdentityName(name)),
3315
+ );
3316
+ const start = input.getRandom
1956
3317
  ? Math.max(
1957
3318
  0,
1958
3319
  Math.min(
@@ -1961,7 +3322,16 @@ export function chooseTelegramThreadName(input: {
1961
3322
  ),
1962
3323
  )
1963
3324
  : getTelegramThreadNameEntropyIndex(input.entropy, names.length);
1964
- return names[index];
3325
+ for (let offset = 0; offset < names.length; offset += 1) {
3326
+ const name = names[(start + offset) % names.length];
3327
+ if (!occupied.has(getTelegramTopicIdentityName(name))) return name;
3328
+ }
3329
+ for (const paletteSlot of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
3330
+ for (const name of TELEGRAM_THREAD_NAME_PALETTE[paletteSlot] ?? []) {
3331
+ if (!occupied.has(getTelegramTopicIdentityName(name))) return name;
3332
+ }
3333
+ }
3334
+ return undefined;
1965
3335
  }
1966
3336
 
1967
3337
  function getTelegramThreadNameLeadingSlot(
@@ -1972,32 +3342,6 @@ function getTelegramThreadNameLeadingSlot(
1972
3342
  return first && /^[A-Z]$/.test(first) ? first : undefined;
1973
3343
  }
1974
3344
 
1975
- function isTelegramSlotOccupiedByOtherCurrentRecord(
1976
- records: readonly TelegramTopicTargetRecord[],
1977
- slot: string,
1978
- currentRecord: TelegramTopicTargetRecord,
1979
- ): boolean {
1980
- return records.some(
1981
- (record) =>
1982
- !targetMatches(record.target, currentRecord.target) &&
1983
- isCurrentThreadRecord(record) &&
1984
- record.slot === slot,
1985
- );
1986
- }
1987
-
1988
- function normalizeCurrentThreadNameSlots(
1989
- store: Pick<TelegramTopicTargetStore, "list" | "upsert">,
1990
- ): void {
1991
- for (const record of store.list()) {
1992
- if (!isCurrentThreadRecord(record)) continue;
1993
- const slot = getTelegramThreadNameLeadingSlot(record.threadName);
1994
- if (!slot || record.slot === slot) continue;
1995
- if (isTelegramSlotOccupiedByOtherCurrentRecord(store.list(), slot, record))
1996
- continue;
1997
- store.upsert({ ...record, slot });
1998
- }
1999
- }
2000
-
2001
3345
  function getNextTelegramThreadNamePaletteSlot(
2002
3346
  records: readonly TelegramTopicTargetRecord[],
2003
3347
  fallbackSlot: string | undefined,
@@ -2049,13 +3393,29 @@ export function getTelegramTopicThreadNameValidationError(
2049
3393
  if (genericLabels.has(identity.toLowerCase())) {
2050
3394
  reasons.push("it is a generic role label");
2051
3395
  }
2052
- if (/^[A-Z]$/.test(identity)) {
2053
- reasons.push("it is only a bare slot letter");
2054
- }
3396
+ if (/^[A-Z]$/.test(identity)) reasons.push("it is only a bare slot letter");
2055
3397
  if (reasons.length === 0) return undefined;
2056
3398
  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.`;
2057
3399
  }
2058
3400
 
3401
+ export function getTelegramManualThreadDisplayNameValidationError(
3402
+ threadName: string,
3403
+ ): string | undefined {
3404
+ const trimmed = threadName.trim();
3405
+ const normalized = trimmed.replace(/\s+/g, " ");
3406
+ const reasons: string[] = [];
3407
+ if (!trimmed) reasons.push("it is empty after trimming");
3408
+ if (trimmed && /[^\x20-\x7E]/.test(trimmed)) {
3409
+ reasons.push("it contains characters outside printable ASCII");
3410
+ }
3411
+ if (normalized.length > 96) reasons.push("it is longer than 96 characters");
3412
+ if (/^[A-Z]$/.test(normalized)) {
3413
+ reasons.push("a bare slot letter is reserved for reset to automatic");
3414
+ }
3415
+ if (reasons.length === 0) return undefined;
3416
+ return `Invalid Telegram Thread display name: ${reasons.join("; ")}. Use 1–96 printable ASCII characters.`;
3417
+ }
3418
+
2059
3419
  export function isTelegramTopicThreadNameValidForSlot(
2060
3420
  threadName: string,
2061
3421
  slot: string | undefined,
@@ -2121,6 +3481,35 @@ export async function promoteTelegramFollowerBindingToLeader(
2121
3481
  record.target.chatId === target.chatId &&
2122
3482
  record.target.threadId === target.threadId,
2123
3483
  );
3484
+ const workspaceIdentity = deps.cwd
3485
+ ? deps.store.claimWorkspaceIdentity(deps.cwd, deps.instanceId,
3486
+ existing?.instanceId, { existingBindingOnly: true })
3487
+ : undefined;
3488
+ const workspaceBinding = workspaceIdentity
3489
+ ? deps.store.getWorkspaceBinding(
3490
+ workspaceIdentity.cwd, workspaceIdentity.instanceSlot,
3491
+ )
3492
+ : undefined;
3493
+ const exactWorkspaceBinding = workspaceBinding &&
3494
+ targetMatches(workspaceBinding.target, target)
3495
+ ? workspaceBinding : undefined;
3496
+ const slot = existing
3497
+ ? deps.store.allocateSlot(existing.profileKey)
3498
+ : exactWorkspaceBinding ? workspaceIdentity?.slot : undefined;
3499
+ if (!slot || (deps.slot && deps.slot !== slot)) {
3500
+ deps.store.releaseWorkspaceClaim(deps.instanceId);
3501
+ return undefined;
3502
+ }
3503
+ if (exactWorkspaceBinding && exactWorkspaceBinding.slot !== slot) {
3504
+ const committed = deps.store.upsertWorkspaceBinding({
3505
+ ...exactWorkspaceBinding, slot, updatedAtMs: nowMs,
3506
+ }, deps.instanceId);
3507
+ if (!committed) {
3508
+ deps.store.releaseWorkspaceClaim(deps.instanceId);
3509
+ return undefined;
3510
+ }
3511
+ await deps.store.persist();
3512
+ }
2124
3513
  const owner: TelegramThreadOwner = {
2125
3514
  kind: "leader",
2126
3515
  cwd: deps.cwd,
@@ -2138,9 +3527,7 @@ export async function promoteTelegramFollowerBindingToLeader(
2138
3527
  ? { threadName: existing?.threadName ?? deps.threadName }
2139
3528
  : {}),
2140
3529
  instanceId: deps.instanceId,
2141
- ...((existing?.slot ?? deps.slot)
2142
- ? { slot: existing?.slot ?? deps.slot }
2143
- : {}),
3530
+ slot,
2144
3531
  ...(existing?.syncStatus ? { syncStatus: existing.syncStatus } : {}),
2145
3532
  ...(existing?.lastSyncObservedAtMs !== undefined
2146
3533
  ? { lastSyncObservedAtMs: existing.lastSyncObservedAtMs }
@@ -2151,6 +3538,7 @@ export async function promoteTelegramFollowerBindingToLeader(
2151
3538
  : {}),
2152
3539
  });
2153
3540
  await deps.store.persist();
3541
+ deps.store.releaseWorkspaceClaim(deps.instanceId);
2154
3542
  return record;
2155
3543
  }
2156
3544
 
@@ -2159,6 +3547,12 @@ export interface TelegramOwnTopicProvisionDeps {
2159
3547
  instanceId: string;
2160
3548
  cwd?: string;
2161
3549
  telegramProfile?: string;
3550
+ requestedThreadName?: string;
3551
+ preferredSlot?: string;
3552
+ workspaceBindingKey?: string;
3553
+ resolveInitialWorkspaceDisplayTitle?: (
3554
+ binding: TelegramWorkspaceDisplayBinding,
3555
+ ) => string | undefined;
2162
3556
  getNowMs?: () => number;
2163
3557
  getRandom?: () => number;
2164
3558
  getCurrentLeaderEpoch?: () => number | string | undefined;
@@ -2183,6 +3577,7 @@ export interface TelegramOwnTopicProvisionResult {
2183
3577
  target: TelegramTarget & { threadId: number };
2184
3578
  slot: string;
2185
3579
  threadName?: string;
3580
+ displayTitle?: string;
2186
3581
  reused: boolean;
2187
3582
  }
2188
3583
 
@@ -2335,7 +3730,7 @@ export async function provisionOwnBusTopic(
2335
3730
  });
2336
3731
  continue;
2337
3732
  }
2338
- if (isSameProcessInstance(record.instanceId, deps.instanceId)) {
3733
+ if (isSameTelegramProcessInstance(record.instanceId, deps.instanceId)) {
2339
3734
  deps.store.upsert({
2340
3735
  ...record,
2341
3736
  profileKey,
@@ -2413,16 +3808,18 @@ export async function provisionOwnBusTopic(
2413
3808
  }
2414
3809
  if (isCleanupTargetProtected(action.target, action)) continue;
2415
3810
  deps.store.markStaleByTarget(record.target);
2416
- deps.store.reserveThread({
2417
- target: record.target,
2418
- slot: record.slot ?? "A",
2419
- reason: "previous-process-cleaned-without-visible-probe",
2420
- createdAtMs: nowMs,
2421
- updatedAtMs: nowMs,
2422
- expiresAtMs: nowMs + TELEGRAM_THREAD_RESERVATION_TTL_MS,
2423
- instanceId: record.instanceId,
2424
- lastReconcileAction: "leader-topic-previous-instance-cleaned-no-probe",
2425
- });
3811
+ if (record.slot) {
3812
+ deps.store.reserveThread({
3813
+ target: record.target,
3814
+ slot: record.slot,
3815
+ reason: "previous-process-cleaned-without-visible-probe",
3816
+ createdAtMs: nowMs,
3817
+ updatedAtMs: nowMs,
3818
+ expiresAtMs: nowMs + TELEGRAM_THREAD_RESERVATION_TTL_MS,
3819
+ instanceId: record.instanceId,
3820
+ lastReconcileAction: "leader-topic-previous-instance-cleaned-no-probe",
3821
+ });
3822
+ }
2426
3823
  deps.store.setBotState({
2427
3824
  threadMode: "enabled",
2428
3825
  updatedAtMs: nowMs,
@@ -2448,12 +3845,24 @@ export async function provisionOwnBusTopic(
2448
3845
  getNowMs: deps.getNowMs,
2449
3846
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
2450
3847
  getRandom: deps.getRandom,
3848
+ resolveInitialWorkspaceDisplayTitle:
3849
+ deps.resolveInitialWorkspaceDisplayTitle,
2451
3850
  claimPendingTargets: false,
2452
3851
  });
2453
3852
  let result = await provision({
2454
3853
  instanceId: deps.instanceId,
2455
3854
  owner: currentLeaderOwner,
2456
3855
  profileKey,
3856
+ ...(deps.requestedThreadName
3857
+ ? { threadName: deps.requestedThreadName }
3858
+ : {}),
3859
+ ...(deps.preferredSlot ? { preferredSlot: deps.preferredSlot } : {}),
3860
+ ...(deps.workspaceBindingKey
3861
+ ? {
3862
+ workspaceBindingKey: deps.workspaceBindingKey,
3863
+ ...(deps.cwd ? { workspaceCwd: deps.cwd } : {}),
3864
+ }
3865
+ : {}),
2457
3866
  });
2458
3867
  if (result.reused) {
2459
3868
  // Reused topics may already have a human-chosen Telegram title. Do not edit
@@ -2488,12 +3897,16 @@ export async function provisionOwnBusTopic(
2488
3897
  threadName: result.record.threadName,
2489
3898
  reused: result.reused,
2490
3899
  });
3900
+ if (!result.record.slot) {
3901
+ throw new Error("Telegram Thread slot authority is unavailable.");
3902
+ }
2491
3903
  return {
2492
3904
  target: result.target,
2493
- slot: result.record.slot ?? "A",
3905
+ slot: result.record.slot,
2494
3906
  ...(result.record.threadName
2495
3907
  ? { threadName: result.record.threadName }
2496
3908
  : {}),
3909
+ ...(result.displayTitle ? { displayTitle: result.displayTitle } : {}),
2497
3910
  reused: result.reused,
2498
3911
  };
2499
3912
  }
@@ -2721,6 +4134,7 @@ export interface TelegramThreadStatusProjectionRuntimeDeps {
2721
4134
  }
2722
4135
  | undefined;
2723
4136
  getCurrentIdentity(): TelegramInstanceThreadIdentityCandidate;
4137
+ getDisplayTitle?: (target: TelegramTarget) => string | undefined;
2724
4138
  }
2725
4139
 
2726
4140
  export function createTelegramThreadStatusProjectionRuntime(
@@ -2736,25 +4150,32 @@ export function createTelegramThreadStatusProjectionRuntime(
2736
4150
  return listTelegramThreadStatusFollowers({
2737
4151
  followers: deps.listFollowers(),
2738
4152
  records: deps.listRecords(),
2739
- });
4153
+ }).map((follower) => ({
4154
+ ...follower,
4155
+ threadName: (follower.target ? deps.getDisplayTitle?.(follower.target) : undefined) ?? follower.threadName,
4156
+ }));
2740
4157
  },
2741
4158
  getLocalBus() {
2742
4159
  const leaderSocketPath = deps.getLeaderSocketPath();
2743
4160
  const followerSocketPath = deps.getFollowerSocketPath();
2744
4161
  const leaderProtocol = deps.getLeaderProtocol?.();
4162
+ const followerTarget = deps.getFollowerTarget();
2745
4163
  return {
2746
4164
  leaderSocketPath,
2747
4165
  leaderTransport: deps.getTransportKind(leaderSocketPath),
2748
4166
  followerSocketPath,
2749
4167
  followerTransport: deps.getTransportKind(followerSocketPath),
2750
4168
  followerRegistered: deps.isFollowerRegistered(),
2751
- followerTarget: deps.getFollowerTarget(),
4169
+ followerTarget,
2752
4170
  followerSlot: deps.getFollowerSlot(),
2753
- followerThreadName: deps.getFollowerThreadName(),
4171
+ followerThreadName: (followerTarget ? deps.getDisplayTitle?.(followerTarget) : undefined) ??
4172
+ deps.getFollowerThreadName(),
2754
4173
  ...(leaderProtocol ? { leaderProtocol } : {}),
2755
4174
  };
2756
4175
  },
2757
- getTopicTargets: () => listTelegramThreadStatusTargets(deps.listRecords()),
4176
+ getTopicTargets: () => listTelegramThreadStatusTargets(deps.listRecords()).map((record) => ({
4177
+ ...record, threadName: deps.getDisplayTitle?.(record.target) ?? record.threadName,
4178
+ })),
2758
4179
  getThreadReservations: () =>
2759
4180
  listTelegramThreadStatusReservations(deps.listReservations()),
2760
4181
  getTopicSyncObservations: () =>
@@ -2773,6 +4194,8 @@ export function createTelegramThreadStatusProjectionRuntime(
2773
4194
  export interface TelegramCurrentThreadAssemblyDeps {
2774
4195
  instanceId: string;
2775
4196
  listRecords: TelegramCurrentInstanceThreadRuntimeDeps["listRecords"];
4197
+ listWorkspaceBindings?: () => readonly TelegramWorkspaceThreadBinding[];
4198
+ getFollowerDisplayTitle?: () => string | undefined;
2776
4199
  getActiveTurnTarget(): TelegramTarget | undefined;
2777
4200
  getFollowerTarget(): TelegramTarget | undefined;
2778
4201
  isFollowerRegistered(): boolean;
@@ -2795,6 +4218,7 @@ export interface TelegramCurrentThreadAssemblyDeps {
2795
4218
  }
2796
4219
 
2797
4220
  export interface TelegramCurrentThreadAssembly {
4221
+ getDisplayTitle: (target: TelegramTarget) => string | undefined;
2798
4222
  current: TelegramCurrentInstanceThreadRuntime;
2799
4223
  status: TelegramThreadStatusProjectionRuntime;
2800
4224
  }
@@ -2813,7 +4237,7 @@ export function createTelegramCurrentThreadAssembly(
2813
4237
  threadName: deps.getFollowerThreadName(),
2814
4238
  };
2815
4239
  };
2816
- const current = createTelegramCurrentInstanceThreadRuntime({
4240
+ const namedCurrent = createTelegramCurrentInstanceThreadRuntime({
2817
4241
  instanceId: deps.instanceId,
2818
4242
  listRecords: deps.listRecords,
2819
4243
  getPreferredTarget: () =>
@@ -2823,7 +4247,25 @@ export function createTelegramCurrentThreadAssembly(
2823
4247
  getFollower,
2824
4248
  getLeader: deps.getLeaderIdentity,
2825
4249
  });
4250
+ const getDisplayTitle = (target: TelegramTarget): string | undefined => {
4251
+ const followerTarget = deps.getFollowerTarget();
4252
+ if (followerTarget && targetMatches(followerTarget, target) && deps.isFollowerRegistered()) {
4253
+ return deps.getFollowerDisplayTitle?.();
4254
+ }
4255
+ return deps.listWorkspaceBindings?.().find((binding) =>
4256
+ targetMatches(binding.target, target),
4257
+ )?.displayTitle;
4258
+ };
4259
+ const displayIdentity = (identity: TelegramInstanceThreadIdentityCandidate) => {
4260
+ const title = identity.target ? getDisplayTitle(identity.target) : undefined;
4261
+ return title ? { ...identity, threadName: title } : identity;
4262
+ };
4263
+ const current = {
4264
+ ...namedCurrent,
4265
+ getIdentity: (target?: TelegramTarget) => displayIdentity(namedCurrent.getIdentity(target)),
4266
+ };
2826
4267
  return {
4268
+ getDisplayTitle,
2827
4269
  current,
2828
4270
  status: createTelegramThreadStatusProjectionRuntime({
2829
4271
  ...deps.status,
@@ -2833,7 +4275,8 @@ export function createTelegramCurrentThreadAssembly(
2833
4275
  getFollowerSlot: deps.getFollowerSlot,
2834
4276
  getFollowerThreadName: deps.getFollowerThreadName,
2835
4277
  getLeaderProtocol: deps.getLeaderProtocol,
2836
- getCurrentIdentity: current.getRestorationIdentity,
4278
+ getDisplayTitle,
4279
+ getCurrentIdentity: () => displayIdentity(namedCurrent.getRestorationIdentity()),
2837
4280
  }),
2838
4281
  };
2839
4282
  }
@@ -3048,20 +4491,37 @@ export function createTelegramTopicTargetRenamer(
3048
4491
  );
3049
4492
  if (
3050
4493
  !threadName ||
3051
- !isTelegramTopicThreadNameValidForSlot(threadName, request.slot)
4494
+ !!getTelegramManualThreadDisplayNameValidationError(threadName)
3052
4495
  )
3053
4496
  return undefined;
4497
+ const occupied = new Set(
4498
+ listOccupiedTelegramThreadIdentities({
4499
+ records: deps.store.list(),
4500
+ workspaceBindings: deps.store.listWorkspaceBindings(),
4501
+ pendingProvisions: deps.store.listPendingProvisions(),
4502
+ exceptTarget: request.target,
4503
+ }),
4504
+ );
4505
+ if (occupied.has(getTelegramTopicIdentityName(threadName))) return undefined;
3054
4506
  const name = getTelegramTopicTitleForThreadName(
3055
4507
  threadName,
3056
4508
  request.slot ?? "",
3057
4509
  deps.topicNameTemplate,
3058
4510
  );
3059
- await deps.callApi("editForumTopic", {
3060
- chat_id: request.target.chatId,
3061
- message_thread_id: request.target.threadId,
3062
- name,
3063
- });
3064
- return deps.store.renameByTarget(request.target, threadName);
4511
+ deps.assertAuthority?.();
4512
+ const updateDisplayTitle = deps.shouldRenameDisplayedTitle?.() ?? true;
4513
+ if (updateDisplayTitle) {
4514
+ await deps.callApi("editForumTopic", {
4515
+ chat_id: request.target.chatId,
4516
+ message_thread_id: request.target.threadId,
4517
+ name,
4518
+ });
4519
+ }
4520
+ deps.assertAuthority?.();
4521
+ if ((deps.shouldRenameDisplayedTitle?.() ?? true) !== updateDisplayTitle) {
4522
+ throw new Error("Telegram display mode changed during Workspace rename.");
4523
+ }
4524
+ return deps.store.renameByTarget(request.target, threadName, { updateDisplayTitle });
3065
4525
  };
3066
4526
  }
3067
4527
 
@@ -3086,22 +4546,53 @@ export function createTelegramTopicTargetProvisioner(
3086
4546
  }
3087
4547
  };
3088
4548
  assertLeaderEpoch("start");
3089
- normalizeCurrentThreadNameSlots(deps.store);
3090
- const existing = deps.store.getByProfileKey(request.profileKey);
3091
4549
  const isManualFollowerRequest = request.owner?.kind === "manual-follower";
3092
4550
  const identity = deps.store.getIdentityByProfileKey(request.profileKey);
3093
4551
  const nowMs = getNowMs();
4552
+ let pendingForRequest = deps.store
4553
+ .listPendingProvisions()
4554
+ .find((pending) =>
4555
+ pending.profileKey === request.profileKey ||
4556
+ pending.instanceId === request.instanceId,
4557
+ );
4558
+ if (pendingForRequest?.target) {
4559
+ const target = pendingForRequest.target;
4560
+ const observation = deps.store.listSyncObservations().find((entry) =>
4561
+ targetMatches(entry.target, target),
4562
+ );
4563
+ assertTelegramPendingTopicRecoveryAllowed(deps.store, target);
4564
+ if (observation?.syncStatus === "deleted") {
4565
+ assertLeaderEpoch("before-deleted-provision-settlement");
4566
+ deps.store.markStaleByTarget(target, "deleted");
4567
+ await deps.store.persist();
4568
+ assertLeaderEpoch("after-deleted-provision-settlement");
4569
+ pendingForRequest = undefined;
4570
+ }
4571
+ }
4572
+ const existing = deps.store.getByProfileKey(request.profileKey);
3094
4573
  if (existing && isCurrentThreadRecord(existing)) {
3095
4574
  const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
4575
+ if (!slot) {
4576
+ throw new Error("Telegram Workspace slot reservation is unavailable.");
4577
+ }
4578
+ const occupied = listOccupiedTelegramThreadIdentities({
4579
+ records: deps.store.list(),
4580
+ workspaceBindings: deps.store.listWorkspaceBindings(),
4581
+ pendingProvisions: deps.store.listPendingProvisions(),
4582
+ exceptTarget: existing.target,
4583
+ exceptWorkspaceBindingKey: request.workspaceBindingKey,
4584
+ });
3096
4585
  const identityThreadName =
3097
4586
  identity?.threadName &&
3098
- isTelegramTopicThreadNameValidForSlot(identity.threadName, slot)
4587
+ isTelegramTopicThreadNameValidForSlot(identity.threadName, slot) &&
4588
+ !occupied.includes(getTelegramTopicIdentityName(identity.threadName))
3099
4589
  ? identity.threadName
3100
4590
  : undefined;
3101
4591
  const bakedThreadName = chooseTelegramThreadName({
3102
4592
  slot: getNextTelegramThreadNamePaletteSlot(deps.store.list(), slot),
3103
4593
  entropy: nowMs,
3104
4594
  getRandom,
4595
+ occupied,
3105
4596
  });
3106
4597
  const record = deps.store.upsert({
3107
4598
  ...existing,
@@ -3114,16 +4605,23 @@ export function createTelegramTopicTargetProvisioner(
3114
4605
  owner: request.owner ?? existing.owner,
3115
4606
  lastError: undefined,
3116
4607
  });
3117
- return { target: record.target, reused: true, record };
4608
+ const recoveredTitle = pendingForRequest?.target &&
4609
+ targetMatches(pendingForRequest.target, record.target)
4610
+ ? pendingForRequest.displayTitle : undefined;
4611
+ if (pendingForRequest?.target && targetMatches(pendingForRequest.target, record.target)) {
4612
+ if (!request.workspaceBindingKey) deps.store.removePendingProvision(pendingForRequest.id);
4613
+ await deps.store.persist();
4614
+ assertLeaderEpoch("after-recovered-current-binding");
4615
+ }
4616
+ return {
4617
+ target: record.target, reused: true, record,
4618
+ ...(recoveredTitle ? { displayTitle: recoveredTitle } : {}),
4619
+ };
3118
4620
  }
3119
- const pendingForRequest = deps.store
3120
- .listPendingProvisions()
3121
- .find(
3122
- (pending) =>
3123
- pending.profileKey === request.profileKey ||
3124
- pending.instanceId === request.instanceId,
3125
- );
3126
4621
  if (pendingForRequest?.target) {
4622
+ if (!pendingForRequest.slot) {
4623
+ throw new Error("Telegram Workspace slot reservation is unavailable.");
4624
+ }
3127
4625
  const record = deps.store.upsert({
3128
4626
  profileKey: request.profileKey,
3129
4627
  owner: request.owner,
@@ -3135,10 +4633,17 @@ export function createTelegramTopicTargetProvisioner(
3135
4633
  instanceId: request.instanceId,
3136
4634
  slot: pendingForRequest.slot,
3137
4635
  });
3138
- deps.store.removePendingProvision(pendingForRequest.id);
4636
+ if (!request.workspaceBindingKey) deps.store.removePendingProvision(pendingForRequest.id);
3139
4637
  await deps.store.persist();
3140
4638
  assertLeaderEpoch("after-recovered-binding");
3141
- return { target: record.target, reused: true, record };
4639
+ return {
4640
+ target: record.target,
4641
+ reused: true,
4642
+ record,
4643
+ ...(pendingForRequest.displayTitle
4644
+ ? { displayTitle: pendingForRequest.displayTitle }
4645
+ : {}),
4646
+ };
3142
4647
  }
3143
4648
  if (pendingForRequest) {
3144
4649
  throw new Error(
@@ -3149,6 +4654,9 @@ export function createTelegramTopicTargetProvisioner(
3149
4654
  request.instanceId,
3150
4655
  );
3151
4656
  if (activeForInstance) {
4657
+ if (!activeForInstance.slot) {
4658
+ throw new Error("Telegram Workspace slot reservation is unavailable.");
4659
+ }
3152
4660
  return {
3153
4661
  target: activeForInstance.target,
3154
4662
  reused: true,
@@ -3165,25 +4673,67 @@ export function createTelegramTopicTargetProvisioner(
3165
4673
  return { target: claimed.target, reused: true, record: claimed };
3166
4674
  }
3167
4675
  }
3168
- const candidateThreadName = identity?.threadName;
4676
+ const occupied = listOccupiedTelegramThreadIdentities({
4677
+ records: deps.store.list(),
4678
+ workspaceBindings: deps.store.listWorkspaceBindings(),
4679
+ pendingProvisions: deps.store.listPendingProvisions(),
4680
+ exceptWorkspaceBindingKey: request.workspaceBindingKey,
4681
+ });
4682
+ const requestedThreadName =
4683
+ request.threadName &&
4684
+ isTelegramTopicThreadNameValidForSlot(request.threadName, undefined) &&
4685
+ !occupied.includes(getTelegramTopicIdentityName(request.threadName))
4686
+ ? normalizeTelegramTopicTargetThreadName(request.threadName)
4687
+ : undefined;
4688
+ const candidateThreadName = requestedThreadName ?? identity?.threadName;
3169
4689
  const preferredNameSlot =
3170
4690
  getTelegramThreadNameLeadingSlot(candidateThreadName) ??
3171
4691
  getNextTelegramThreadNamePaletteSlot(deps.store.list(), undefined) ??
3172
4692
  request.preferredSlot;
3173
4693
  const slot =
3174
4694
  existing?.slot ??
3175
- (candidateThreadName ? undefined : identity?.slot) ??
3176
4695
  deps.store.allocateSlot(
3177
4696
  request.profileKey,
3178
4697
  isManualFollowerRequest
3179
4698
  ? request.preferredSlot
3180
- : (request.preferredSlot ?? preferredNameSlot),
4699
+ : (request.preferredSlot ??
4700
+ (candidateThreadName ? undefined : identity?.slot) ??
4701
+ preferredNameSlot),
4702
+ request.workspaceBindingKey,
3181
4703
  );
3182
- const requestThreadName =
4704
+ if (!slot) {
4705
+ throw new Error("Telegram Workspace slot reservation is unavailable.");
4706
+ }
4707
+ const uniqueCandidate =
3183
4708
  candidateThreadName &&
3184
- isTelegramTopicThreadNameValidForSlot(candidateThreadName, slot)
4709
+ isTelegramTopicThreadNameValidForSlot(candidateThreadName, slot) &&
4710
+ !occupied.includes(getTelegramTopicIdentityName(candidateThreadName))
3185
4711
  ? candidateThreadName
3186
- : chooseTelegramThreadName({ slot, entropy: nowMs, getRandom });
4712
+ : undefined;
4713
+ const requestThreadName =
4714
+ uniqueCandidate ??
4715
+ chooseTelegramThreadName({ slot, entropy: nowMs, getRandom, occupied });
4716
+ let displayTitle: string | undefined;
4717
+ if (
4718
+ deps.resolveInitialWorkspaceDisplayTitle &&
4719
+ request.workspaceBindingKey &&
4720
+ request.workspaceCwd
4721
+ ) {
4722
+ const projectedTitle = deps.resolveInitialWorkspaceDisplayTitle({
4723
+ bindingKey: request.workspaceBindingKey,
4724
+ cwd: request.workspaceCwd,
4725
+ slot,
4726
+ threadName: requestThreadName,
4727
+ });
4728
+ if (!projectedTitle?.trim()) {
4729
+ throw new Error("Telegram Thread display identity is missing or ambiguous.");
4730
+ }
4731
+ displayTitle = getTelegramTopicName(
4732
+ { ...request, threadName: projectedTitle },
4733
+ "{threadName}",
4734
+ slot,
4735
+ );
4736
+ }
3187
4737
  const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`;
3188
4738
  const pendingOwner =
3189
4739
  request.owner?.kind === "leader" ? "leader" : "manual-follower";
@@ -3194,6 +4744,7 @@ export function createTelegramTopicTargetProvisioner(
3194
4744
  instanceId: request.instanceId,
3195
4745
  profileKey: request.profileKey,
3196
4746
  threadName: requestThreadName,
4747
+ ...(displayTitle ? { displayTitle } : {}),
3197
4748
  slot,
3198
4749
  startedAtMs: nowMs,
3199
4750
  ...(leaderEpoch !== undefined ? { leaderEpoch } : {}),
@@ -3208,7 +4759,7 @@ export function createTelegramTopicTargetProvisioner(
3208
4759
  "createForumTopic",
3209
4760
  {
3210
4761
  chat_id: deps.topicChatId,
3211
- name: getTelegramTopicName(
4762
+ name: displayTitle ?? getTelegramTopicName(
3212
4763
  {
3213
4764
  ...request,
3214
4765
  ...(requestThreadName ? { threadName: requestThreadName } : {}),
@@ -3256,10 +4807,16 @@ export function createTelegramTopicTargetProvisioner(
3256
4807
  instanceId: request.instanceId,
3257
4808
  slot,
3258
4809
  });
3259
- deps.store.removePendingProvision(pendingId);
4810
+ // Workspace commit consumes the exact title evidence in the same publication.
4811
+ if (!request.workspaceBindingKey) deps.store.removePendingProvision(pendingId);
3260
4812
  await deps.store.persist();
3261
4813
  assertLeaderEpoch("after-active-binding");
3262
- return { target: record.target, reused: false, record };
4814
+ return {
4815
+ target: record.target,
4816
+ reused: false,
4817
+ record,
4818
+ ...(displayTitle ? { displayTitle } : {}),
4819
+ };
3263
4820
  } catch (error) {
3264
4821
  if (
3265
4822
  threadId !== undefined &&