@llblab/pi-kit 0.6.0 → 0.7.1

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 (129) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  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-state-flow/AGENTS.md +33 -12
  35. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
  36. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
  37. package/node_modules/@llblab/pi-state-flow/README.md +203 -107
  38. package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
  39. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
  40. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
  41. package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
  42. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
  43. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
  44. package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
  45. package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
  46. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
  47. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
  48. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
  49. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
  50. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
  51. package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
  52. package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
  53. package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
  54. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
  55. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
  56. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
  57. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
  58. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
  59. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
  60. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
  61. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
  62. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
  63. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
  64. package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
  65. package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
  66. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
  67. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
  68. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
  69. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
  70. package/node_modules/@llblab/pi-state-flow/package.json +8 -2
  71. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
  72. package/node_modules/@llblab/pi-telegram/AGENTS.md +14 -9
  73. package/node_modules/@llblab/pi-telegram/BACKLOG.md +23 -5
  74. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
  75. package/node_modules/@llblab/pi-telegram/README.md +13 -9
  76. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
  77. package/node_modules/@llblab/pi-telegram/docs/architecture.md +227 -23
  78. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  79. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
  80. package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -6
  81. package/node_modules/@llblab/pi-telegram/docs/public-api.md +13 -6
  82. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
  83. package/node_modules/@llblab/pi-telegram/index.ts +4 -1418
  84. package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
  85. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +46 -1
  86. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +32 -19
  87. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
  88. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
  89. package/node_modules/@llblab/pi-telegram/lib/bus.ts +355 -26
  90. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +718 -0
  91. package/node_modules/@llblab/pi-telegram/lib/commands.ts +237 -11
  92. package/node_modules/@llblab/pi-telegram/lib/config.ts +242 -26
  93. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1851 -0
  94. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
  95. package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
  96. package/node_modules/@llblab/pi-telegram/lib/locks.ts +44 -2
  97. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +152 -13
  98. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +97 -10
  99. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
  100. package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
  101. package/node_modules/@llblab/pi-telegram/lib/preview.ts +17 -0
  102. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +6 -2
  103. package/node_modules/@llblab/pi-telegram/lib/queue.ts +118 -26
  104. package/node_modules/@llblab/pi-telegram/lib/rendering.ts +4 -1
  105. package/node_modules/@llblab/pi-telegram/lib/replies.ts +21 -2
  106. package/node_modules/@llblab/pi-telegram/lib/routing.ts +344 -112
  107. package/node_modules/@llblab/pi-telegram/lib/setup.ts +44 -4
  108. package/node_modules/@llblab/pi-telegram/lib/status.ts +51 -4
  109. package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
  110. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +353 -22
  111. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
  112. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
  113. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
  114. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
  115. package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
  116. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
  117. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
  118. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
  119. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
  120. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  121. package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
  122. package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
  123. package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
  124. package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
  125. package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
  126. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
  127. package/node_modules/@llblab/skills/package.json +2 -3
  128. package/package.json +6 -5
  129. /package/node_modules/@llblab/pi-telegram/lib/{logs.ts → logging.ts} +0 -0
@@ -6,6 +6,11 @@
6
6
  */
7
7
 
8
8
  import * as Sync from "./sync.ts";
9
+ import {
10
+ createTelegramThreadDisplayReconciler,
11
+ resolveTelegramInitialWorkspaceDisplayName,
12
+ } from "./thread-display.ts";
13
+ import type { TelegramThreadDisplayMode } from "./config.ts";
9
14
  import * as ThreadReconciler from "./thread-reconciler.ts";
10
15
  import {
11
16
  getTelegramApiErrorRequestTarget,
@@ -32,11 +37,37 @@ import {
32
37
  type TelegramBusSocketPathSource,
33
38
  TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION,
34
39
  TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF,
40
+ TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT,
41
+ TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
42
+ TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
35
43
  } from "./bus.ts";
36
44
  import { getTelegramBusTransportRetryPolicy } from "./bus-transport.ts";
37
45
  import type { TelegramQueueHandoffPayload } from "./queue.ts";
46
+ import {
47
+ createTelegramWorkspaceOperationRuntime,
48
+ type TelegramWorkspaceOperationRunner,
49
+ } from "./workspace-retirement.ts";
50
+ import {
51
+ createTelegramWorkspaceAdmissionOperationId,
52
+ runWithTelegramWorkspaceAdmissionsAsync,
53
+ type TelegramWorkspaceAdmissionLedger,
54
+ } from "./workspace-admission.ts";
55
+
56
+ export const TELEGRAM_BUS_FOLLOWER_STALE_AFTER_MS = 15_000;
57
+
58
+ export type TelegramBusWorkspaceAdmissionRunner =
59
+ TelegramWorkspaceOperationRunner;
38
60
 
39
61
  export interface TelegramBusLeaderRuntime<TContext> {
62
+ runWorkspaceOperation?: TelegramBusWorkspaceAdmissionRunner;
63
+ captureWorkspaceExternalProtection?: (
64
+ binding: Threads.TelegramWorkspaceThreadBinding,
65
+ ) => Threads.TelegramWorkspaceExternalProtectionEvidence;
66
+ reconcileThreadDisplay?: () => Promise<{ changed: number }>;
67
+ setThreadDisplayMode?: (mode: TelegramThreadDisplayMode) => Promise<void>;
68
+ renameLeaderThread?: (
69
+ threadName: string,
70
+ ) => Promise<Threads.TelegramTopicTargetRecord>;
40
71
  startPolling: (ctx: TContext) => Promise<void>;
41
72
  stopPolling: () => Promise<void>;
42
73
  routeQueueHandoff: (input: {
@@ -84,10 +115,15 @@ export interface TelegramBusLeaderTargetProvisionerDeps<TContext> {
84
115
  getCwd?: (ctx: TContext) => string | undefined;
85
116
  getTelegramProfile?: () => string | undefined;
86
117
  shouldForceFreshUnnamed?: () => boolean;
118
+ getRequestedThreadName?: () => string | undefined;
119
+ resolveInitialWorkspaceDisplayTitle?: (
120
+ binding: Threads.TelegramWorkspaceDisplayBinding,
121
+ ) => string | undefined;
87
122
  topicTargetStore: Threads.TelegramTopicTargetStore;
88
123
  callApi: <TResponse>(
89
124
  method: string,
90
125
  body: Record<string, unknown>,
126
+ options?: TelegramApiCallOptions,
91
127
  ) => Promise<TResponse>;
92
128
  getCurrentLeaderEpoch?: () => number | string | undefined;
93
129
  getThreadReconciliationMachineState?: () =>
@@ -124,6 +160,10 @@ export interface TelegramBusFollowerTargetProvisionerDeps {
124
160
  setSyncState: (state: Sync.TelegramSyncState) => void;
125
161
  onProvisioningStart?: () => void;
126
162
  onProvisioningEnd?: () => void;
163
+ resolveInitialWorkspaceDisplayTitle?: (
164
+ binding: Threads.TelegramWorkspaceDisplayBinding,
165
+ ) => string | undefined;
166
+ runWorkspaceOperation?: TelegramBusWorkspaceAdmissionRunner;
127
167
  getNowMs?: () => number;
128
168
  recordRuntimeEvent: (
129
169
  category: string,
@@ -137,6 +177,7 @@ export interface TelegramBusFollowerDisconnectHandlerDeps {
137
177
  Threads.TelegramTopicTargetStore,
138
178
  | "list"
139
179
  | "markStaleByTarget"
180
+ | "markWorkspaceBindingInactiveByTarget"
140
181
  | "persist"
141
182
  | "upsertPendingCleanup"
142
183
  | "removePendingCleanup"
@@ -178,6 +219,9 @@ export interface TelegramBusLeaderApiProxyDeps {
178
219
  }
179
220
 
180
221
  export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
222
+ getThreadDisplayMode?: () => TelegramThreadDisplayMode;
223
+ persistThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
224
+ onThreadDisplayChanged?: () => void;
181
225
  runtime: Omit<
182
226
  TelegramBusLeaderRuntimeDeps<TContext>,
183
227
  | "callApi"
@@ -192,6 +236,7 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
192
236
  getCwd?: (ctx: TContext) => string | undefined;
193
237
  getTelegramProfile?: () => string | undefined;
194
238
  shouldForceFreshUnnamed?: () => boolean;
239
+ getRequestedThreadName?: () => string | undefined;
195
240
  topicTargetStore: Threads.TelegramTopicTargetStore;
196
241
  callApi: TelegramBusLeaderTargetProvisionerDeps<TContext>["callApi"];
197
242
  callMultipart: TelegramBusLeaderApiProxyDeps["callMultipart"];
@@ -208,11 +253,60 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
208
253
  recordRuntimeEvent: NonNullable<
209
254
  TelegramBusLeaderRuntimeDeps<TContext>["recordRuntimeEvent"]
210
255
  >;
256
+ captureWorkspaceExternalProtection?: (
257
+ binding: Threads.TelegramWorkspaceThreadBinding,
258
+ ) => Threads.TelegramWorkspaceExternalProtectionEvidence;
259
+ getWorkspaceAdmission?: () => Pick<
260
+ TelegramWorkspaceAdmissionLedger,
261
+ "acquireAdmission" | "releaseAdmission"
262
+ > | undefined;
263
+ runWorkspaceOperation?: TelegramBusWorkspaceAdmissionRunner;
211
264
  }
212
265
 
213
266
  export function createTelegramBusLeaderRuntimeAssembly<TContext>(
214
267
  deps: TelegramBusLeaderRuntimeAssemblyDeps<TContext>,
215
- ): TelegramBusLeaderRuntime<TContext> {
268
+ ): TelegramBusLeaderRuntime<TContext> & {
269
+ renameLeaderThreadAdmitted: (
270
+ threadName: string,
271
+ expectedTarget?: Threads.TelegramTopicTargetRecord["target"],
272
+ ) => Promise<Threads.TelegramTopicTargetRecord>;
273
+ resetLeaderThreadName: (
274
+ expectedTarget: Threads.TelegramTopicTargetRecord["target"],
275
+ ) => Promise<{ threadName: string }>;
276
+ resetThreadNameAdmitted: (
277
+ target: Threads.TelegramTopicTargetRecord["target"],
278
+ ) => Promise<{ threadName: string }>;
279
+ } {
280
+ const runWorkspaceAdmission: TelegramBusWorkspaceAdmissionRunner | undefined =
281
+ deps.getWorkspaceAdmission
282
+ ? (input, operation) => {
283
+ const admission = deps.getWorkspaceAdmission?.();
284
+ if (!admission) {
285
+ throw new Error("Telegram Workspace admission authority is unavailable.");
286
+ }
287
+ return runWithTelegramWorkspaceAdmissionsAsync({
288
+ ledger: admission,
289
+ ...input,
290
+ operation,
291
+ onReleaseError(error) {
292
+ deps.recordRuntimeEvent("bus", error, {
293
+ phase: "workspace-admission-release",
294
+ operationKind: input.operationKind,
295
+ });
296
+ },
297
+ });
298
+ }
299
+ : undefined;
300
+ const runWorkspaceOperation = deps.runWorkspaceOperation ??
301
+ createTelegramWorkspaceOperationRuntime({
302
+ getWorkspaceAdmission: deps.getWorkspaceAdmission,
303
+ onReleaseError(error, operationKind) {
304
+ deps.recordRuntimeEvent("bus", error, {
305
+ phase: "workspace-admission-release",
306
+ operationKind,
307
+ });
308
+ },
309
+ }).run;
216
310
  const provisionerPorts = {
217
311
  getAllowedUserId: deps.getAllowedUserId,
218
312
  topicTargetStore: deps.topicTargetStore,
@@ -222,31 +316,397 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
222
316
  setSyncState: deps.setSyncState,
223
317
  onProvisioningStart: deps.onProvisioningStart,
224
318
  onProvisioningEnd: deps.onProvisioningEnd,
319
+ ...(deps.getThreadDisplayMode
320
+ ? {
321
+ resolveInitialWorkspaceDisplayTitle(
322
+ binding: Threads.TelegramWorkspaceDisplayBinding,
323
+ ) {
324
+ return resolveTelegramInitialWorkspaceDisplayName({
325
+ bindings: deps.topicTargetStore.listWorkspaceBindings(),
326
+ binding,
327
+ mode: deps.getThreadDisplayMode!(),
328
+ });
329
+ },
330
+ }
331
+ : {}),
332
+ runWorkspaceOperation,
225
333
  recordRuntimeEvent: deps.recordRuntimeEvent,
226
334
  };
227
- return createTelegramBusLeaderRuntime({
228
- ...deps.runtime,
229
- provisionLeaderTarget: createTelegramBusLeaderTargetProvisioner({
230
- ...provisionerPorts,
335
+ const display = deps.getThreadDisplayMode ? createTelegramThreadDisplayReconciler({
336
+ store: deps.topicTargetStore,
337
+ getMode: deps.getThreadDisplayMode,
338
+ getProfileKey: () => deps.getTelegramProfile?.() ?? "default",
339
+ getLeaderEpoch: () => deps.getCurrentLeaderEpoch?.(),
340
+ captureBindingAuthority(binding) {
341
+ const matches = (target: TelegramTarget | undefined) =>
342
+ target?.chatId === binding.target.chatId && target?.threadId === binding.target.threadId;
343
+ const leader = deps.topicTargetStore.getActiveByInstanceId(deps.instanceId);
344
+ if (leader && matches(leader.target)) {
345
+ return () => matches(deps.topicTargetStore.getActiveByInstanceId(deps.instanceId)?.target);
346
+ }
347
+ const follower = deps.runtime.followerRegistry.getByTarget(binding.target);
348
+ if (!follower?.registrationGeneration) return undefined;
349
+ const generation = follower.registrationGeneration;
350
+ return () => {
351
+ const current = deps.runtime.followerRegistry.get(follower.instanceId);
352
+ return current?.registrationGeneration === generation && matches(current.target);
353
+ };
354
+ },
355
+ callApi: deps.callApi,
356
+ }) : undefined;
357
+ const reconcileThreadDisplayOperation = async () => {
358
+ const result = await display!.reconcile();
359
+ deps.onThreadDisplayChanged?.();
360
+ return result;
361
+ };
362
+ const reconcileThreadDisplay = () => runWorkspaceOperation(
363
+ {
364
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
365
+ operationKind: "workspace.reconcile-display",
366
+ scopes: [{ kind: "profile" }],
367
+ },
368
+ reconcileThreadDisplayOperation,
369
+ );
370
+ const scheduleDisplay = () => {
371
+ if (!display) return;
372
+ void reconcileThreadDisplay().catch((error) => {
373
+ deps.recordRuntimeEvent("bus", error, { phase: "thread-display-reconcile" });
374
+ });
375
+ };
376
+ let modeTail: Promise<void> = Promise.resolve();
377
+ const applyThreadDisplayMode = (mode: TelegramThreadDisplayMode, isCurrent: () => boolean): Promise<void> => {
378
+ const epoch = deps.getCurrentLeaderEpoch?.();
379
+ const profile = deps.getTelegramProfile?.();
380
+ const current = () => epoch !== undefined && deps.getCurrentLeaderEpoch?.() === epoch &&
381
+ deps.getTelegramProfile?.() === profile && isCurrent();
382
+ const run = modeTail.then(() => runWorkspaceOperation(
383
+ {
384
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
385
+ operationKind: "workspace.set-display-mode",
386
+ scopes: [{ kind: "profile" }],
387
+ },
388
+ async () => {
389
+ if (!display || !deps.persistThreadDisplayMode || !current()) {
390
+ throw new Error("Telegram Thread display setting requires current leader authority.");
391
+ }
392
+ const assertDisplayPeers = () => {
393
+ if (mode !== "names" && deps.runtime.followerRegistry.list().some((follower) =>
394
+ !hasTelegramBusCapability(follower.protocol, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE),
395
+ )) throw new Error("Update or restart all connected followers before changing Thread display mode.");
396
+ };
397
+ assertDisplayPeers();
398
+ await deps.persistThreadDisplayMode(mode, current);
399
+ assertDisplayPeers();
400
+ if (!current() || deps.getThreadDisplayMode?.() !== mode) {
401
+ throw new Error("Telegram Thread display preference changed before application.");
402
+ }
403
+ await reconcileThreadDisplayOperation();
404
+ if (!current() || deps.getThreadDisplayMode?.() !== mode) {
405
+ throw new Error("Telegram Thread display application lost its originating authority.");
406
+ }
407
+ },
408
+ ));
409
+ modeTail = run.catch(() => undefined);
410
+ return run;
411
+ };
412
+ const provisionLeaderTarget = createTelegramBusLeaderTargetProvisioner({
413
+ ...provisionerPorts,
414
+ instanceId: deps.instanceId,
415
+ getCwd: deps.getCwd,
416
+ getTelegramProfile: deps.getTelegramProfile,
417
+ shouldForceFreshUnnamed: deps.shouldForceFreshUnnamed,
418
+ getRequestedThreadName: deps.getRequestedThreadName,
419
+ getThreadReconciliationMachineState:
420
+ deps.getThreadReconciliationMachineState,
421
+ recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
422
+ setLeaderTarget: deps.setLeaderTarget,
423
+ });
424
+ const disconnectFollower = createTelegramBusFollowerDisconnectHandler({
425
+ ...provisionerPorts,
426
+ });
427
+ const cleanupConfirmedDeadFollower = createTelegramBusFollowerConfirmedDeadHandler({
428
+ ...provisionerPorts,
429
+ });
430
+ const provisionFollowerTarget = createTelegramBusFollowerTargetProvisioner({
431
+ ...provisionerPorts,
432
+ });
433
+ const renameLeaderThreadAdmitted = async (
434
+ threadName: string,
435
+ expectedTarget?: Threads.TelegramTopicTargetRecord["target"],
436
+ ) => {
437
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
438
+ if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
439
+ throw new Error(
440
+ "Telegram Workspace Thread rename requires leader ownership.",
441
+ );
442
+ }
443
+ const record = Threads.findCurrentTelegramInstanceThreadRecord({
444
+ records: deps.topicTargetStore.list(),
231
445
  instanceId: deps.instanceId,
232
- getCwd: deps.getCwd,
233
- getTelegramProfile: deps.getTelegramProfile,
234
- shouldForceFreshUnnamed: deps.shouldForceFreshUnnamed,
235
- getThreadReconciliationMachineState:
236
- deps.getThreadReconciliationMachineState,
237
- recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
238
- setLeaderTarget: deps.setLeaderTarget,
239
- }),
240
- onFollowerDisconnected: createTelegramBusFollowerDisconnectHandler({
241
- ...provisionerPorts,
242
- }),
243
- onFollowerConfirmedDead: createTelegramBusFollowerConfirmedDeadHandler({
244
- ...provisionerPorts,
245
- }),
246
- provisionFollowerTarget: createTelegramBusFollowerTargetProvisioner({
247
- ...provisionerPorts,
248
- }),
446
+ });
447
+ if (typeof record?.target.threadId !== "number") {
448
+ throw new Error("No Workspace Thread is bound to this Pi instance.");
449
+ }
450
+ if (expectedTarget && (record.target.chatId !== expectedTarget.chatId ||
451
+ record.target.threadId !== expectedTarget.threadId)) {
452
+ throw new Error("Telegram Workspace Thread rename target changed.");
453
+ }
454
+ const rename = Threads.createTelegramTopicTargetRenamer({
455
+ store: deps.topicTargetStore,
456
+ callApi: deps.callApi,
457
+ assertAuthority() {
458
+ if (
459
+ deps.getCurrentLeaderEpoch &&
460
+ deps.getCurrentLeaderEpoch() !== leaderEpoch
461
+ ) {
462
+ throw new Error(
463
+ "Telegram Workspace Thread rename lost leader ownership.",
464
+ );
465
+ }
466
+ const current = Threads.findCurrentTelegramInstanceThreadRecord({
467
+ records: deps.topicTargetStore.list(),
468
+ instanceId: deps.instanceId,
469
+ });
470
+ if (current?.target.chatId !== record.target.chatId ||
471
+ current.target.threadId !== record.target.threadId) {
472
+ throw new Error("Telegram Workspace Thread rename target changed.");
473
+ }
474
+ },
475
+ });
476
+ const renamed = await rename({
477
+ target: {
478
+ chatId: record.target.chatId,
479
+ threadId: record.target.threadId,
480
+ },
481
+ threadName,
482
+ slot: record.slot,
483
+ });
484
+ if (!renamed) {
485
+ throw new Error(
486
+ Threads.getTelegramTopicThreadNameValidationError(
487
+ threadName,
488
+ record.slot,
489
+ ) ?? "Telegram Workspace Thread name is already reserved.",
490
+ );
491
+ }
492
+ await deps.topicTargetStore.persist();
493
+ return renamed;
494
+ };
495
+ const resetThreadNameAdmitted = async (
496
+ target: Threads.TelegramTopicTargetRecord["target"],
497
+ assertTargetCurrent: () => void = () => undefined,
498
+ ): Promise<{ threadName: string }> => {
499
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
500
+ if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
501
+ throw new Error("Telegram Workspace Thread reset requires leader ownership.");
502
+ }
503
+ const binding = deps.topicTargetStore.listWorkspaceBindings().find(
504
+ (candidate) => candidate.target.chatId === target.chatId &&
505
+ candidate.target.threadId === target.threadId,
506
+ );
507
+ if (!binding) throw new Error("No Workspace Thread binding is available to reset.");
508
+ const automaticTitle = resolveTelegramInitialWorkspaceDisplayName({
509
+ bindings: deps.topicTargetStore.listWorkspaceBindings(),
510
+ binding: { ...binding, manualThreadName: undefined },
511
+ mode: deps.getThreadDisplayMode?.() ?? "letters",
512
+ preserveRetainedManualName: false,
513
+ });
514
+ if (!automaticTitle) {
515
+ throw new Error("Telegram Workspace automatic title is unavailable.");
516
+ }
517
+ assertTargetCurrent();
518
+ await deps.callApi("editForumTopic", {
519
+ chat_id: target.chatId,
520
+ message_thread_id: target.threadId,
521
+ name: automaticTitle,
522
+ });
523
+ if (deps.getCurrentLeaderEpoch &&
524
+ deps.getCurrentLeaderEpoch() !== leaderEpoch) {
525
+ throw new Error("Telegram Workspace Thread reset lost leader ownership.");
526
+ }
527
+ assertTargetCurrent();
528
+ const reset = deps.topicTargetStore.clearManualNameByTarget(
529
+ target,
530
+ automaticTitle,
531
+ );
532
+ if (!reset) throw new Error("Telegram Workspace Thread reset changed binding.");
533
+ await deps.topicTargetStore.persist();
534
+ return { threadName: automaticTitle };
535
+ };
536
+ const renameLeaderThread = (threadName: string) => runWorkspaceOperation({
537
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
538
+ operationKind: "workspace.rename-leader",
539
+ scopes: [{ kind: "profile" }],
540
+ }, () => renameLeaderThreadAdmitted(threadName));
541
+ const resetLeaderThreadName = (
542
+ expectedTarget: Threads.TelegramTopicTargetRecord["target"],
543
+ ) => runWorkspaceOperation({
544
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
545
+ operationKind: "workspace.reset-leader-name",
546
+ scopes: [{ kind: "profile" }],
547
+ }, () => {
548
+ const record = Threads.findCurrentTelegramInstanceThreadRecord({
549
+ records: deps.topicTargetStore.list(),
550
+ instanceId: deps.instanceId,
551
+ });
552
+ if (!record || typeof record.target.threadId !== "number") {
553
+ throw new Error("No Workspace Thread is bound to this Pi instance.");
554
+ }
555
+ if (record.target.chatId !== expectedTarget.chatId ||
556
+ record.target.threadId !== expectedTarget.threadId) {
557
+ throw new Error("Telegram Workspace Thread reset target changed.");
558
+ }
559
+ return resetThreadNameAdmitted(record.target, () => {
560
+ const current = Threads.findCurrentTelegramInstanceThreadRecord({
561
+ records: deps.topicTargetStore.list(),
562
+ instanceId: deps.instanceId,
563
+ });
564
+ if (current?.target.chatId !== expectedTarget.chatId ||
565
+ current.target.threadId !== expectedTarget.threadId) {
566
+ throw new Error("Telegram Workspace Thread reset target changed.");
567
+ }
568
+ });
569
+ });
570
+ const runtime = createTelegramBusLeaderRuntime({
571
+ ...deps.runtime,
572
+ applyThreadDisplayMode,
573
+ getThreadDisplayMode: deps.getThreadDisplayMode,
574
+ onFollowerRegistered: scheduleDisplay,
575
+ provisionLeaderTarget: (ctx) => {
576
+ const chatId = deps.getAllowedUserId();
577
+ return runWorkspaceOperation(
578
+ {
579
+ operationId: `leader-provision:${deps.instanceId}`,
580
+ operationKind: "workspace.provision-leader",
581
+ scopes: [
582
+ typeof chatId === "number"
583
+ ? { kind: "chat", chatId }
584
+ : { kind: "profile" },
585
+ ],
586
+ },
587
+ () => provisionLeaderTarget(ctx),
588
+ );
589
+ },
590
+ getFollowerDisplayTitle(follower) {
591
+ const binding = deps.topicTargetStore.listWorkspaceBindings().find((binding) =>
592
+ binding.target.chatId === follower.target?.chatId &&
593
+ binding.target.threadId === follower.target?.threadId,
594
+ );
595
+ return binding?.displayTitle ?? binding?.threadName;
596
+ },
597
+ onFollowerDisconnected: (follower) => runWorkspaceOperation(
598
+ {
599
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
600
+ operationKind: "workspace.disconnect-follower",
601
+ scopes: [{ kind: "profile" }],
602
+ },
603
+ () => disconnectFollower(follower),
604
+ ),
605
+ async renameFollowerThread(follower, threadName) {
606
+ return runWorkspaceOperation({
607
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
608
+ operationKind: "workspace.rename-follower",
609
+ scopes: [{ kind: "profile" }],
610
+ }, async () => {
611
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
612
+ if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
613
+ throw new Error(
614
+ "Telegram Workspace Thread rename requires leader ownership.",
615
+ );
616
+ }
617
+ if (typeof follower.target?.threadId !== "number") {
618
+ throw new Error("Telegram follower has no bound Workspace Thread.");
619
+ }
620
+ const rename = Threads.createTelegramTopicTargetRenamer({
621
+ store: deps.topicTargetStore,
622
+ callApi: deps.callApi,
623
+ assertAuthority() {
624
+ if (
625
+ deps.getCurrentLeaderEpoch &&
626
+ deps.getCurrentLeaderEpoch() !== leaderEpoch
627
+ ) {
628
+ throw new Error(
629
+ "Telegram Workspace Thread rename lost leader ownership.",
630
+ );
631
+ }
632
+ const current = deps.runtime.followerRegistry.get(follower.instanceId);
633
+ if (current?.registrationGeneration !== follower.registrationGeneration ||
634
+ current?.target?.chatId !== follower.target?.chatId ||
635
+ current?.target?.threadId !== follower.target?.threadId) {
636
+ throw new Error("Telegram follower Workspace Thread target changed.");
637
+ }
638
+ },
639
+ });
640
+ const renamed = await rename({
641
+ target: {
642
+ chatId: follower.target.chatId,
643
+ threadId: follower.target.threadId,
644
+ },
645
+ threadName,
646
+ slot: follower.slot,
647
+ });
648
+ if (
649
+ deps.getCurrentLeaderEpoch &&
650
+ deps.getCurrentLeaderEpoch() !== leaderEpoch
651
+ ) {
652
+ throw new Error(
653
+ "Telegram Workspace Thread rename lost leader ownership.",
654
+ );
655
+ }
656
+ if (!renamed) {
657
+ throw new Error(
658
+ Threads.getTelegramTopicThreadNameValidationError(
659
+ threadName,
660
+ follower.slot,
661
+ ) ?? "Telegram Workspace Thread name is already reserved.",
662
+ );
663
+ }
664
+ await deps.topicTargetStore.persist();
665
+ return { threadName: renamed.manualThreadName ?? threadName };
666
+ });
667
+ },
668
+ resetFollowerThreadName: (follower) => runWorkspaceOperation(
669
+ {
670
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
671
+ operationKind: "workspace.reset-follower-name",
672
+ scopes: [{ kind: "profile" }],
673
+ },
674
+ () => {
675
+ if (!follower.target || typeof follower.target.threadId !== "number") {
676
+ throw new Error("Telegram follower has no bound Workspace Thread.");
677
+ }
678
+ const target = {
679
+ chatId: follower.target.chatId,
680
+ threadId: follower.target.threadId,
681
+ };
682
+ return resetThreadNameAdmitted(target, () => {
683
+ const current = deps.runtime.followerRegistry.get(follower.instanceId);
684
+ if (current?.registrationGeneration !== follower.registrationGeneration ||
685
+ current?.target?.chatId !== target.chatId ||
686
+ current?.target?.threadId !== target.threadId) {
687
+ throw new Error("Telegram follower Workspace Thread target changed.");
688
+ }
689
+ });
690
+ },
691
+ ),
692
+ onFollowerConfirmedDead: (follower) => runWorkspaceOperation(
693
+ {
694
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
695
+ operationKind: "workspace.cleanup-dead-follower",
696
+ scopes: [{ kind: "profile" }],
697
+ },
698
+ () => cleanupConfirmedDeadFollower(follower),
699
+ ),
700
+ provisionFollowerTarget: (registration, options) => runWorkspaceOperation(
701
+ {
702
+ operationId: `follower-provision:${registration.instanceId}:${registration.registrationGeneration}`,
703
+ operationKind: "workspace.provision-follower",
704
+ scopes: [{ kind: "profile" }],
705
+ },
706
+ () => provisionFollowerTarget(registration, options),
707
+ ),
249
708
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
709
+ runWorkspaceAdmission,
250
710
  callApi: createTelegramBusLeaderApiProxy({
251
711
  call: deps.callApi,
252
712
  callMultipart: deps.callMultipart,
@@ -255,6 +715,25 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
255
715
  }),
256
716
  recordRuntimeEvent: deps.recordRuntimeEvent,
257
717
  });
718
+ const assembled = {
719
+ ...runtime,
720
+ runWorkspaceOperation,
721
+ renameLeaderThread,
722
+ renameLeaderThreadAdmitted,
723
+ resetLeaderThreadName,
724
+ resetThreadNameAdmitted,
725
+ captureWorkspaceExternalProtection: deps.captureWorkspaceExternalProtection,
726
+ async startPolling(ctx: TContext) {
727
+ await runtime.startPolling(ctx);
728
+ if (display) scheduleDisplay();
729
+ },
730
+ };
731
+ if (!display) return assembled;
732
+ return {
733
+ ...assembled,
734
+ reconcileThreadDisplay,
735
+ setThreadDisplayMode: (mode) => applyThreadDisplayMode(mode, () => true),
736
+ };
258
737
  }
259
738
 
260
739
  export interface TelegramBusFollowerMessageOwnershipRecord {
@@ -306,9 +785,22 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
306
785
  ) => Promise<unknown> | unknown;
307
786
  provisionFollowerTarget?: (
308
787
  registration: TelegramBusInstanceRegistration,
788
+ options?: { existingWorkspaceBindingOnly?: boolean },
309
789
  ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
790
+ renameFollowerThread?: (
791
+ follower: TelegramBusFollowerView,
792
+ threadName: string,
793
+ ) => Promise<{ threadName: string }> | { threadName: string };
794
+ resetFollowerThreadName?: (
795
+ follower: TelegramBusFollowerView,
796
+ ) => Promise<{ threadName: string }> | { threadName: string };
797
+ getFollowerDisplayTitle?: (follower: TelegramBusFollowerView) => string | undefined;
798
+ onFollowerRegistered?: () => void;
799
+ applyThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
800
+ getThreadDisplayMode?: () => TelegramThreadDisplayMode;
310
801
  getCurrentLeaderEpoch?: () => number | string | undefined;
311
802
  provisionLeaderTarget?: (ctx: TContext) => Promise<void> | void;
803
+ runWorkspaceAdmission?: TelegramBusWorkspaceAdmissionRunner;
312
804
  getNowMs?: () => number;
313
805
  timeoutMs?: number;
314
806
  followerPruneIntervalMs?: number;
@@ -388,6 +880,7 @@ export function createTelegramBusFollowerTargetProvisioner(
388
880
  deps: TelegramBusFollowerTargetProvisionerDeps,
389
881
  ): (
390
882
  registration: TelegramBusInstanceRegistration,
883
+ options?: { existingWorkspaceBindingOnly?: boolean },
391
884
  ) => Promise<
392
885
  (TelegramTarget & { slot?: string; threadName?: string }) | undefined
393
886
  > {
@@ -398,11 +891,42 @@ export function createTelegramBusFollowerTargetProvisioner(
398
891
  (TelegramTarget & { slot?: string; threadName?: string }) | undefined
399
892
  >
400
893
  >();
401
- return async (registration) => {
894
+ return async (registration, options) => {
895
+ if (options?.existingWorkspaceBindingOnly && !registration.cwd) {
896
+ return undefined;
897
+ }
402
898
  const registrationStartedAtMs = Date.now();
403
899
  const chatId = deps.getAllowedUserId();
404
900
  if (typeof chatId !== "number") return registration.target;
405
901
  await deps.topicTargetStore.load();
902
+ let capacityUnavailable = false;
903
+ const workspaceIdentity = registration.cwd
904
+ ? deps.topicTargetStore.claimWorkspaceIdentity(
905
+ registration.cwd,
906
+ registration.instanceId,
907
+ registration.previousInstanceId,
908
+ {
909
+ existingBindingOnly:
910
+ options?.existingWorkspaceBindingOnly === true,
911
+ onCapacityUnavailable() {
912
+ capacityUnavailable = true;
913
+ },
914
+ },
915
+ )
916
+ : undefined;
917
+ if (registration.cwd && !workspaceIdentity) {
918
+ if (options?.existingWorkspaceBindingOnly) return undefined;
919
+ if (capacityUnavailable) {
920
+ throw new Error("Telegram Workspace slot reservation is unavailable.");
921
+ }
922
+ throw new Error("Telegram Workspace identity is already claimed.");
923
+ }
924
+ const workspaceBinding = workspaceIdentity
925
+ ? deps.topicTargetStore.getWorkspaceBinding(
926
+ workspaceIdentity.cwd,
927
+ workspaceIdentity.instanceSlot,
928
+ )
929
+ : undefined;
406
930
  const provision = Threads.createTelegramTopicTargetProvisioner({
407
931
  topicChatId: chatId,
408
932
  store: deps.topicTargetStore,
@@ -414,11 +938,13 @@ export function createTelegramBusFollowerTargetProvisioner(
414
938
  // silently claim old offline/failed tabs: they may be closed/deleted in
415
939
  // Telegram and therefore invisible to the operator.
416
940
  claimPendingTargets: false,
941
+ resolveInitialWorkspaceDisplayTitle:
942
+ deps.resolveInitialWorkspaceDisplayTitle,
417
943
  });
418
944
  const recordsBeforeProvision = deps.topicTargetStore.list();
419
945
  const followerProfileKey =
420
946
  registration.profileKey ?? `manual:${registration.instanceId}`;
421
- const requestedTarget = registration.target;
947
+ const requestedTarget = registration.target ?? workspaceBinding?.target;
422
948
  const reconnectRecord = recordsBeforeProvision.find((record) => {
423
949
  const matchesRequestedTarget =
424
950
  !requestedTarget ||
@@ -432,10 +958,14 @@ export function createTelegramBusFollowerTargetProvisioner(
432
958
  !!registration.previousInstanceId &&
433
959
  record.instanceId === registration.previousInstanceId &&
434
960
  matchesRequestedTarget;
961
+ const matchesWorkspaceRecovery =
962
+ !!workspaceBinding &&
963
+ record.status === "probe-required" &&
964
+ matchesRequestedTarget;
435
965
  return (
436
966
  record.owner?.kind === "manual-follower" &&
437
967
  ((matchesCurrentIdentity && matchesRequestedTarget) ||
438
- matchesSessionHandoff)
968
+ matchesSessionHandoff || matchesWorkspaceRecovery)
439
969
  );
440
970
  });
441
971
  const followerOwner =
@@ -456,7 +986,10 @@ export function createTelegramBusFollowerTargetProvisioner(
456
986
  recoverableTarget,
457
987
  )
458
988
  : undefined;
459
- const registrationKey = followerProfileKey || registration.instanceId;
989
+ const registrationKey =
990
+ workspaceIdentity?.bindingKey ||
991
+ followerProfileKey ||
992
+ registration.instanceId;
460
993
  const pendingRegistration = pendingRegistrations.get(registrationKey);
461
994
  if (pendingRegistration) return pendingRegistration;
462
995
  const provisionTarget = async () => {
@@ -472,7 +1005,14 @@ export function createTelegramBusFollowerTargetProvisioner(
472
1005
  instanceId: registration.instanceId,
473
1006
  },
474
1007
  profileKey: followerProfileKey,
475
- threadName: registration.threadName,
1008
+ threadName: workspaceBinding?.threadName ?? registration.threadName,
1009
+ preferredSlot: workspaceIdentity?.slot ?? workspaceBinding?.slot ?? registration.slot,
1010
+ ...(workspaceIdentity
1011
+ ? {
1012
+ workspaceBindingKey: workspaceIdentity.bindingKey,
1013
+ workspaceCwd: workspaceIdentity.cwd,
1014
+ }
1015
+ : {}),
476
1016
  });
477
1017
  } finally {
478
1018
  deps.onProvisioningEnd?.();
@@ -480,7 +1020,8 @@ export function createTelegramBusFollowerTargetProvisioner(
480
1020
  };
481
1021
  const recoverRequestedTarget = async () => {
482
1022
  const nowMs = getNowMs();
483
- const carriedThreadName = registration.threadName;
1023
+ const carriedThreadName =
1024
+ workspaceBinding?.threadName ?? registration.threadName;
484
1025
  const requestedThreadName =
485
1026
  carriedThreadName &&
486
1027
  Threads.isTelegramTopicThreadNameValidForSlot(
@@ -495,7 +1036,17 @@ export function createTelegramBusFollowerTargetProvisioner(
495
1036
  )
496
1037
  ? recoveryHint.threadName
497
1038
  : undefined;
498
- let recoveredRecord: Threads.TelegramTopicTargetRecord = {
1039
+ const requestedSlot =
1040
+ workspaceIdentity?.slot ?? workspaceBinding?.slot ?? registration.slot ?? recoveryHint?.slot;
1041
+ const recoveredSlot = deps.topicTargetStore.allocateSlot(
1042
+ followerProfileKey,
1043
+ requestedSlot,
1044
+ workspaceIdentity?.bindingKey,
1045
+ );
1046
+ if (!recoveredSlot) {
1047
+ throw new Error("Telegram Workspace slot reservation is unavailable.");
1048
+ }
1049
+ const recoveredRecord: Threads.TelegramTopicTargetRecord = {
499
1050
  profileKey: followerProfileKey,
500
1051
  owner:
501
1052
  followerOwner.kind === "manual-follower"
@@ -513,17 +1064,10 @@ export function createTelegramBusFollowerTargetProvisioner(
513
1064
  updatedAtMs: nowMs,
514
1065
  instanceId: registration.instanceId,
515
1066
  ...(requestedThreadName ? { threadName: requestedThreadName } : {}),
1067
+ slot: recoveredSlot,
516
1068
  lastSyncObservedAtMs: nowMs,
517
1069
  lastReconcileAction: "follower-live-target-recovery",
518
1070
  };
519
- const requestedSlot = registration.slot ?? recoveryHint?.slot;
520
- const requestedSlotAvailable =
521
- !!requestedSlot &&
522
- /^[A-Z]$/.test(requestedSlot) &&
523
- !recordsBeforeProvision.some((record) => record.slot === requestedSlot);
524
- if (requestedSlotAvailable) {
525
- recoveredRecord = { ...recoveredRecord, slot: requestedSlot };
526
- }
527
1071
  return {
528
1072
  target: recoveredRecord.target,
529
1073
  reused: true,
@@ -533,13 +1077,22 @@ export function createTelegramBusFollowerTargetProvisioner(
533
1077
  const runRegistration = async (): Promise<
534
1078
  (TelegramTarget & { slot?: string; threadName?: string }) | undefined
535
1079
  > => {
536
- let result = reconnectRecord
1080
+ const recoveryTarget = reconnectRecord?.target ?? recoverableTarget;
1081
+ if (recoveryTarget) {
1082
+ Threads.assertTelegramPendingTopicRecoveryAllowed(deps.topicTargetStore, recoveryTarget);
1083
+ }
1084
+ const pendingTargetRecovery = recoverableTarget && deps.topicTargetStore.listPendingProvisions().some(
1085
+ (entry) => entry.target?.chatId === recoverableTarget.chatId &&
1086
+ entry.target?.threadId === recoverableTarget.threadId &&
1087
+ (entry.instanceId === registration.instanceId || entry.profileKey === followerProfileKey),
1088
+ );
1089
+ let result: Threads.TelegramTopicTargetProvisionResult = reconnectRecord
537
1090
  ? {
538
1091
  target: reconnectRecord.target,
539
1092
  reused: true,
540
1093
  record: reconnectRecord,
541
1094
  }
542
- : recoverableTarget
1095
+ : recoverableTarget && !pendingTargetRecovery
543
1096
  ? await recoverRequestedTarget()
544
1097
  : await provisionTarget();
545
1098
  const crossSessionReuse =
@@ -577,7 +1130,10 @@ export function createTelegramBusFollowerTargetProvisioner(
577
1130
  !result.reused || requiresVisibilityProbe
578
1131
  ? createTelegramBusInstanceLifecycleAnnouncement({
579
1132
  target: result.target,
580
- threadName: result.record.threadName,
1133
+ threadName: result.displayTitle ??
1134
+ (workspaceBinding?.target.chatId === result.target.chatId &&
1135
+ workspaceBinding.target.threadId === result.target.threadId
1136
+ ? workspaceBinding.displayTitle : undefined) ?? result.record.threadName,
581
1137
  slot: result.record.slot,
582
1138
  state: "connected",
583
1139
  })
@@ -602,6 +1158,19 @@ export function createTelegramBusFollowerTargetProvisioner(
602
1158
  if (recoverableTarget || probeRequiredRecord) {
603
1159
  const activatedRecord = deps.topicTargetStore.upsert({
604
1160
  ...result.record,
1161
+ ...(probeRequiredRecord && crossSessionReuse
1162
+ ? {
1163
+ profileKey: followerProfileKey,
1164
+ owner:
1165
+ followerOwner.kind === "manual-follower"
1166
+ ? followerOwner
1167
+ : {
1168
+ kind: "manual-follower" as const,
1169
+ instanceId: registration.instanceId,
1170
+ },
1171
+ instanceId: registration.instanceId,
1172
+ }
1173
+ : {}),
605
1174
  status: "active",
606
1175
  updatedAtMs: getNowMs(),
607
1176
  lastSyncObservedAtMs: getNowMs(),
@@ -650,7 +1219,7 @@ export function createTelegramBusFollowerTargetProvisioner(
650
1219
  connectedAnnouncement =
651
1220
  createTelegramBusInstanceLifecycleAnnouncement({
652
1221
  target: result.target,
653
- threadName: result.record.threadName,
1222
+ threadName: result.displayTitle ?? result.record.threadName,
654
1223
  slot: result.record.slot,
655
1224
  state: "connected",
656
1225
  });
@@ -676,6 +1245,34 @@ export function createTelegramBusFollowerTargetProvisioner(
676
1245
  }
677
1246
  }
678
1247
  }
1248
+ if (workspaceIdentity) {
1249
+ const workspaceCommit =
1250
+ Threads.commitTelegramWorkspaceProvisionBinding({
1251
+ store: deps.topicTargetStore,
1252
+ instanceId: registration.instanceId,
1253
+ profileKey: followerProfileKey,
1254
+ displayTitle: result.displayTitle,
1255
+ binding: {
1256
+ ...workspaceIdentity,
1257
+ target: { ...result.target },
1258
+ ...(result.record.threadName
1259
+ ? { threadName: result.record.threadName }
1260
+ : {}),
1261
+ ...(result.record.slot ? { slot: result.record.slot } : {}),
1262
+ journalBindingKeys: [followerProfileKey],
1263
+ journalBindingsComplete: true,
1264
+ updatedAtMs: getNowMs(),
1265
+ },
1266
+ });
1267
+ if (connectedAnnouncement && workspaceCommit.displayTitle) {
1268
+ connectedAnnouncement = createTelegramBusInstanceLifecycleAnnouncement({
1269
+ target: result.target, threadName: workspaceCommit.displayTitle,
1270
+ slot: result.record.slot, state: "connected",
1271
+ });
1272
+ }
1273
+ deps.topicTargetStore.markWorkspaceBindingActiveByTarget(result.target);
1274
+ await deps.topicTargetStore.persist();
1275
+ }
679
1276
  deps.setSyncState(
680
1277
  Sync.markTelegramSyncSliceFresh(
681
1278
  deps.getSyncState(),
@@ -712,7 +1309,7 @@ export function createTelegramBusFollowerTargetProvisioner(
712
1309
  });
713
1310
  }
714
1311
  }
715
- try {
1312
+ const reconcile = async (): Promise<void> => {
716
1313
  await ThreadReconciler.applyThreadReconciliationPlan(
717
1314
  ThreadReconciler.planThreadReconciliation({
718
1315
  nowMs: getNowMs(),
@@ -747,6 +1344,20 @@ export function createTelegramBusFollowerTargetProvisioner(
747
1344
  },
748
1345
  );
749
1346
  await deps.topicTargetStore.persist();
1347
+ };
1348
+ try {
1349
+ if (deps.runWorkspaceOperation) {
1350
+ await deps.runWorkspaceOperation(
1351
+ {
1352
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
1353
+ operationKind: "workspace.reconcile-follower-provision",
1354
+ scopes: [{ kind: "profile" }],
1355
+ },
1356
+ reconcile,
1357
+ );
1358
+ } else {
1359
+ await reconcile();
1360
+ }
750
1361
  } catch (error) {
751
1362
  deps.recordRuntimeEvent("telegram", error, {
752
1363
  phase: "follower-register-background-reconcile",
@@ -776,6 +1387,7 @@ export function createTelegramBusFollowerTargetProvisioner(
776
1387
  };
777
1388
  const registrationPromise = runRegistration().finally(() => {
778
1389
  pendingRegistrations.delete(registrationKey);
1390
+ deps.topicTargetStore.releaseWorkspaceClaim(registration.instanceId);
779
1391
  });
780
1392
  pendingRegistrations.set(registrationKey, registrationPromise);
781
1393
  return registrationPromise;
@@ -823,7 +1435,15 @@ function createTelegramBusFollowerCleanupHandler(
823
1435
  {
824
1436
  isCleanupTargetProtected,
825
1437
  callApi: deps.callApi,
826
- markStaleByTarget: deps.topicTargetStore.markStaleByTarget,
1438
+ markStaleByTarget(target, syncStatus, lastSyncError) {
1439
+ const stale = deps.topicTargetStore.markStaleByTarget(
1440
+ target, syncStatus, lastSyncError,
1441
+ );
1442
+ const inactive = deps.topicTargetStore.markWorkspaceBindingInactiveByTarget(
1443
+ target, (deps.getNowMs ?? Date.now)(),
1444
+ );
1445
+ return stale || inactive;
1446
+ },
827
1447
  removeCleanupIntentById: deps.topicTargetStore.removePendingCleanup,
828
1448
  persist: deps.topicTargetStore.persist,
829
1449
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
@@ -893,9 +1513,13 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
893
1513
  );
894
1514
  }
895
1515
  await deps.topicTargetStore.load();
1516
+ const cwd = deps.getCwd?.(ctx);
1517
+ const normalizedCwd = cwd
1518
+ ? Threads.normalizeTelegramWorkspacePath(cwd)
1519
+ : undefined;
896
1520
  const profileKey = Threads.getTelegramThreadOwnerKey({
897
1521
  kind: "leader",
898
- cwd: deps.getCwd?.(ctx),
1522
+ cwd: normalizedCwd,
899
1523
  instanceId: deps.instanceId,
900
1524
  telegramProfile: deps.getTelegramProfile?.(),
901
1525
  });
@@ -942,9 +1566,12 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
942
1566
  ownTarget = await Sync.ensureTelegramLeaderThreadBinding({
943
1567
  getAllowedUserId: deps.getAllowedUserId,
944
1568
  instanceId: deps.instanceId,
945
- cwd: deps.getCwd?.(ctx),
1569
+ cwd: normalizedCwd,
946
1570
  telegramProfile: deps.getTelegramProfile?.(),
947
1571
  forceFreshUnnamed: deps.shouldForceFreshUnnamed?.(),
1572
+ requestedThreadName: deps.getRequestedThreadName?.(),
1573
+ resolveInitialWorkspaceDisplayTitle:
1574
+ deps.resolveInitialWorkspaceDisplayTitle,
948
1575
  getNowMs,
949
1576
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
950
1577
  getThreadReconciliationMachineState:
@@ -952,6 +1579,20 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
952
1579
  recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
953
1580
  topicTargetStore: deps.topicTargetStore,
954
1581
  callApi: deps.callApi,
1582
+ probeWorkspaceBinding: async (binding) => {
1583
+ const announcement = createTelegramBusInstanceLifecycleAnnouncement({
1584
+ target: binding.target,
1585
+ threadName: binding.displayTitle ?? binding.threadName,
1586
+ slot: binding.slot,
1587
+ state: "connected",
1588
+ });
1589
+ await deps.callApi("sendMessage", {
1590
+ chat_id: announcement.target.chatId,
1591
+ message_thread_id: announcement.target.threadId,
1592
+ text: announcement.text,
1593
+ parse_mode: announcement.parseMode,
1594
+ });
1595
+ },
955
1596
  recordEvent: deps.recordRuntimeEvent,
956
1597
  });
957
1598
  } finally {
@@ -1002,7 +1643,7 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
1002
1643
  const connectedAnnouncement =
1003
1644
  createTelegramBusInstanceLifecycleAnnouncement({
1004
1645
  target: ownTarget.target,
1005
- threadName: ownTarget.threadName,
1646
+ threadName: ownTarget.displayTitle ?? ownTarget.threadName,
1006
1647
  slot: ownTarget.slot,
1007
1648
  state: "connected",
1008
1649
  });
@@ -1130,6 +1771,7 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1130
1771
  ) => Promise<unknown> | unknown;
1131
1772
  provisionFollowerTarget?: (
1132
1773
  registration: TelegramBusInstanceRegistration,
1774
+ options?: { existingWorkspaceBindingOnly?: boolean },
1133
1775
  ) =>
1134
1776
  | Promise<
1135
1777
  | (TelegramTarget & { slot?: string; threadName?: string })
@@ -1140,8 +1782,20 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1140
1782
  onFollowerDisconnected?: (
1141
1783
  follower: TelegramBusFollowerView,
1142
1784
  ) => Promise<void> | void;
1785
+ renameFollowerThread?: (
1786
+ follower: TelegramBusFollowerView,
1787
+ threadName: string,
1788
+ ) => Promise<{ threadName: string }> | { threadName: string };
1789
+ resetFollowerThreadName?: (
1790
+ follower: TelegramBusFollowerView,
1791
+ ) => Promise<{ threadName: string }> | { threadName: string };
1792
+ getFollowerDisplayTitle?: (follower: TelegramBusFollowerView) => string | undefined;
1793
+ onFollowerRegistered?: () => void;
1794
+ applyThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
1795
+ getThreadDisplayMode?: () => TelegramThreadDisplayMode;
1143
1796
  getCurrentLeaderEpoch?: () => number | string | undefined;
1144
1797
  runFollowerMutation?: TelegramBusFollowerMutationRunner;
1798
+ runWorkspaceAdmission?: TelegramBusWorkspaceAdmissionRunner;
1145
1799
  }): (
1146
1800
  envelope: TelegramBusEnvelope,
1147
1801
  ) => Promise<TelegramBusEnvelope> | TelegramBusEnvelope {
@@ -1423,22 +2077,45 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1423
2077
  return createUnauthorizedBusAck(envelope.requestId);
1424
2078
  }
1425
2079
  switch (envelope.kind) {
1426
- case "follower.register": {
2080
+ case "follower.register":
2081
+ case "follower.restoreWorkspace": {
1427
2082
  const compatibility = getTelegramBusProtocolCompatibility({
1428
2083
  local: deps.protocolIdentity,
1429
2084
  remote: envelope.registration.protocol,
1430
2085
  });
1431
- if (!compatibility.compatible) {
2086
+ const displayCompatible = () =>
2087
+ (deps.getThreadDisplayMode?.() ?? "names") === "names" || (
2088
+ hasTelegramBusCapability(deps.protocolIdentity, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE) &&
2089
+ hasTelegramBusCapability(envelope.registration.protocol, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE)
2090
+ );
2091
+ const restoringWorkspace =
2092
+ envelope.kind === "follower.restoreWorkspace";
2093
+ const supportsWorkspaceAutoConnect =
2094
+ hasTelegramBusCapability(
2095
+ deps.protocolIdentity,
2096
+ TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT,
2097
+ ) &&
2098
+ hasTelegramBusCapability(
2099
+ envelope.registration.protocol,
2100
+ TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT,
2101
+ );
2102
+ if (
2103
+ !compatibility.compatible || !displayCompatible() ||
2104
+ (restoringWorkspace && !supportsWorkspaceAutoConnect)
2105
+ ) {
1432
2106
  return {
1433
2107
  kind: "bus.ack" as const,
1434
2108
  requestId: envelope.requestId,
1435
2109
  ok: false,
1436
2110
  protocol: deps.protocolIdentity,
1437
2111
  error: { code: "incompatible-protocol" as const },
1438
- message: `Incompatible Telegram bus protocol: ${compatibility.reason}.`,
2112
+ message: `Incompatible Telegram bus protocol: ${
2113
+ compatibility.reason ?? "missing-capability"
2114
+ }.`,
1439
2115
  };
1440
2116
  }
1441
- return runFollowerMutation(envelope.registration, async () => {
2117
+ const registrationOperation = () =>
2118
+ runFollowerMutation(envelope.registration, async () => {
1442
2119
  try {
1443
2120
  if (!envelope.registration.registrationGeneration) {
1444
2121
  throw new Error(
@@ -1453,7 +2130,18 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1453
2130
  }
1454
2131
  const target = await deps.provisionFollowerTarget?.(
1455
2132
  envelope.registration,
2133
+ { existingWorkspaceBindingOnly: restoringWorkspace },
1456
2134
  );
2135
+ if (restoringWorkspace && !target) {
2136
+ return {
2137
+ kind: "bus.ack" as const,
2138
+ requestId: envelope.requestId,
2139
+ ok: false,
2140
+ protocol: deps.protocolIdentity,
2141
+ error: { code: "workspace-binding-unavailable" as const },
2142
+ message: "No remembered Telegram Workspace Thread is available.",
2143
+ };
2144
+ }
1457
2145
  if (
1458
2146
  deps.getCurrentLeaderEpoch &&
1459
2147
  deps.getCurrentLeaderEpoch() !== leaderEpoch
@@ -1462,14 +2150,21 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1462
2150
  "Telegram follower registration lost leader ownership.",
1463
2151
  );
1464
2152
  }
2153
+ if (!displayCompatible()) {
2154
+ throw new Error("Thread display mode changed; update or restart this follower before connecting.");
2155
+ }
1465
2156
  const registeredTarget = target ?? envelope.registration.target;
2157
+ const registeredSlot = target?.slot ?? envelope.registration.slot;
2158
+ if (deps.provisionFollowerTarget &&
2159
+ registeredTarget?.threadId !== undefined &&
2160
+ (!registeredSlot || !/^[A-Z]$/u.test(registeredSlot))) {
2161
+ throw new Error("Telegram Thread slot authority is unavailable.");
2162
+ }
1466
2163
  deps.followerRegistry.register({
1467
2164
  ...envelope.registration,
1468
2165
  connectedAtMs: getNowMs(),
1469
2166
  target: registeredTarget,
1470
- ...((target?.slot ?? envelope.registration.slot)
1471
- ? { slot: target?.slot ?? envelope.registration.slot }
1472
- : {}),
2167
+ ...(registeredSlot ? { slot: registeredSlot } : {}),
1473
2168
  ...((target?.threadName ?? envelope.registration.threadName)
1474
2169
  ? {
1475
2170
  threadName:
@@ -1477,12 +2172,20 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1477
2172
  }
1478
2173
  : {}),
1479
2174
  });
2175
+ const follower = deps.followerRegistry.get(envelope.registration.instanceId);
2176
+ const displayTitle = follower ? deps.getFollowerDisplayTitle?.(follower) : undefined;
2177
+ deps.onFollowerRegistered?.();
1480
2178
  return {
1481
2179
  kind: "bus.ack" as const,
1482
2180
  requestId: envelope.requestId,
1483
2181
  ok: true,
1484
2182
  protocol: deps.protocolIdentity,
1485
- ...(registeredTarget ? { result: registeredTarget } : {}),
2183
+ ...(registeredTarget ? {
2184
+ result: {
2185
+ ...registeredTarget,
2186
+ ...(displayTitle !== undefined ? { displayTitle } : {}),
2187
+ },
2188
+ } : {}),
1486
2189
  };
1487
2190
  } catch (error) {
1488
2191
  return {
@@ -1498,9 +2201,203 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1498
2201
  }
1499
2202
  },
1500
2203
  );
2204
+ if (!deps.runWorkspaceAdmission) return registrationOperation();
2205
+ try {
2206
+ return await deps.runWorkspaceAdmission(
2207
+ {
2208
+ operationId: `follower-registration:${envelope.requestId}`,
2209
+ operationKind: "workspace.register-follower",
2210
+ scopes: [{ kind: "profile" }],
2211
+ },
2212
+ registrationOperation,
2213
+ );
2214
+ } catch (error) {
2215
+ return {
2216
+ kind: "bus.ack" as const,
2217
+ requestId: envelope.requestId,
2218
+ ok: false,
2219
+ protocol: deps.protocolIdentity,
2220
+ message:
2221
+ error instanceof Error
2222
+ ? error.message
2223
+ : "Telegram bus follower registration admission failed.",
2224
+ };
2225
+ }
1501
2226
  }
1502
2227
  case "follower.offerQueueHandoff":
1503
2228
  return routeQueueHandoff(envelope);
2229
+ case "follower.setThreadDisplayMode": {
2230
+ const epoch = deps.getCurrentLeaderEpoch?.();
2231
+ const current = () => epoch !== undefined && deps.getCurrentLeaderEpoch?.() === epoch &&
2232
+ deps.followerRegistry.get(envelope.instanceId)?.registrationGeneration === envelope.registrationGeneration;
2233
+ const follower = deps.followerRegistry.get(envelope.instanceId);
2234
+ if (!current() || !follower?.registrationGeneration || !deps.applyThreadDisplayMode ||
2235
+ !hasTelegramBusCapability(deps.protocolIdentity, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE) ||
2236
+ !hasTelegramBusCapability(follower.protocol, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE)) {
2237
+ return { kind: "bus.ack", requestId: envelope.requestId, ok: false,
2238
+ message: "Thread display settings require current registration and compatible leader authority." };
2239
+ }
2240
+ try {
2241
+ await deps.applyThreadDisplayMode(envelope.mode, current);
2242
+ if (!current()) throw new Error("Thread display setting completed for a stale registration.");
2243
+ return { kind: "bus.ack", requestId: envelope.requestId, ok: true, result: { mode: envelope.mode } };
2244
+ } catch (error) {
2245
+ return { kind: "bus.ack", requestId: envelope.requestId, ok: false,
2246
+ message: error instanceof Error ? error.message : "Thread display setting was not fully applied." };
2247
+ }
2248
+ }
2249
+ case "follower.renameThread": {
2250
+ const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
2251
+ return runFollowerMutation(
2252
+ registeredFollower ?? { instanceId: envelope.instanceId },
2253
+ async () => {
2254
+ const follower = deps.followerRegistry.get(envelope.instanceId);
2255
+ if (!follower) {
2256
+ return {
2257
+ kind: "bus.ack" as const,
2258
+ requestId: envelope.requestId,
2259
+ ok: false,
2260
+ message: "Unknown Telegram bus follower instance.",
2261
+ };
2262
+ }
2263
+ if (
2264
+ !follower.registrationGeneration ||
2265
+ envelope.registrationGeneration !== follower.registrationGeneration ||
2266
+ follower.target?.chatId !== envelope.target.chatId ||
2267
+ follower.target?.threadId !== envelope.target.threadId
2268
+ ) {
2269
+ return {
2270
+ kind: "bus.ack" as const,
2271
+ requestId: envelope.requestId,
2272
+ ok: false,
2273
+ message: "Stale Telegram bus follower registration generation.",
2274
+ };
2275
+ }
2276
+ if (
2277
+ !hasTelegramBusCapability(
2278
+ deps.protocolIdentity,
2279
+ TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
2280
+ ) ||
2281
+ !hasTelegramBusCapability(
2282
+ follower.protocol,
2283
+ TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
2284
+ ) ||
2285
+ !deps.renameFollowerThread
2286
+ ) {
2287
+ return {
2288
+ kind: "bus.ack" as const,
2289
+ requestId: envelope.requestId,
2290
+ ok: false,
2291
+ message: "Telegram Workspace Thread rename is unavailable.",
2292
+ };
2293
+ }
2294
+ try {
2295
+ const result = await deps.renameFollowerThread(
2296
+ follower,
2297
+ envelope.threadName,
2298
+ );
2299
+ const current = deps.followerRegistry.get(follower.instanceId);
2300
+ if (
2301
+ current?.registrationGeneration !== follower.registrationGeneration
2302
+ ) {
2303
+ return {
2304
+ kind: "bus.ack" as const,
2305
+ requestId: envelope.requestId,
2306
+ ok: false,
2307
+ message: "Stale Telegram bus follower registration generation.",
2308
+ };
2309
+ }
2310
+ deps.followerRegistry.register({
2311
+ ...current,
2312
+ threadName: result.threadName,
2313
+ connectedAtMs: current.connectedAtMs,
2314
+ });
2315
+ return {
2316
+ kind: "bus.ack" as const,
2317
+ requestId: envelope.requestId,
2318
+ ok: true,
2319
+ result,
2320
+ };
2321
+ } catch (error) {
2322
+ return {
2323
+ kind: "bus.ack" as const,
2324
+ requestId: envelope.requestId,
2325
+ ok: false,
2326
+ message:
2327
+ error instanceof Error
2328
+ ? error.message
2329
+ : "Telegram Workspace Thread rename failed.",
2330
+ };
2331
+ }
2332
+ },
2333
+ );
2334
+ }
2335
+ case "follower.resetThreadName": {
2336
+ const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
2337
+ return runFollowerMutation(
2338
+ registeredFollower ?? { instanceId: envelope.instanceId },
2339
+ async () => {
2340
+ const follower = deps.followerRegistry.get(envelope.instanceId);
2341
+ if (!follower || !follower.registrationGeneration ||
2342
+ envelope.registrationGeneration !== follower.registrationGeneration ||
2343
+ follower.target?.chatId !== envelope.target.chatId ||
2344
+ follower.target?.threadId !== envelope.target.threadId) {
2345
+ return {
2346
+ kind: "bus.ack" as const,
2347
+ requestId: envelope.requestId,
2348
+ ok: false,
2349
+ message: "Stale or unknown Telegram bus follower registration.",
2350
+ };
2351
+ }
2352
+ if (!hasTelegramBusCapability(
2353
+ deps.protocolIdentity,
2354
+ TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
2355
+ ) || !hasTelegramBusCapability(
2356
+ follower.protocol,
2357
+ TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
2358
+ ) || !deps.resetFollowerThreadName) {
2359
+ return {
2360
+ kind: "bus.ack" as const,
2361
+ requestId: envelope.requestId,
2362
+ ok: false,
2363
+ message: "Telegram Workspace Thread reset is unavailable.",
2364
+ };
2365
+ }
2366
+ try {
2367
+ const result = await deps.resetFollowerThreadName(follower);
2368
+ const current = deps.followerRegistry.get(follower.instanceId);
2369
+ if (current?.registrationGeneration !==
2370
+ follower.registrationGeneration) {
2371
+ return {
2372
+ kind: "bus.ack" as const,
2373
+ requestId: envelope.requestId,
2374
+ ok: false,
2375
+ message: "Stale Telegram bus follower registration generation.",
2376
+ };
2377
+ }
2378
+ deps.followerRegistry.register({
2379
+ ...current,
2380
+ threadName: result.threadName,
2381
+ connectedAtMs: current.connectedAtMs,
2382
+ });
2383
+ return {
2384
+ kind: "bus.ack" as const,
2385
+ requestId: envelope.requestId,
2386
+ ok: true,
2387
+ result,
2388
+ };
2389
+ } catch (error) {
2390
+ return {
2391
+ kind: "bus.ack" as const,
2392
+ requestId: envelope.requestId,
2393
+ ok: false,
2394
+ message: error instanceof Error
2395
+ ? error.message : "Telegram Workspace Thread reset failed.",
2396
+ };
2397
+ }
2398
+ },
2399
+ );
2400
+ }
1504
2401
  case "follower.disconnect": {
1505
2402
  const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
1506
2403
  return runFollowerMutation(
@@ -1573,12 +2470,14 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1573
2470
  envelope.instanceId,
1574
2471
  getNowMs(),
1575
2472
  );
2473
+ const displayTitle = follower ? deps.getFollowerDisplayTitle?.(follower) : undefined;
1576
2474
  return follower
1577
2475
  ? {
1578
2476
  kind: "bus.ack",
1579
2477
  requestId: envelope.requestId,
1580
2478
  ok: true,
1581
2479
  result: {
2480
+ ...(displayTitle !== undefined ? { displayTitle } : {}),
1582
2481
  eligibleElectionSlots: deps.followerRegistry
1583
2482
  .list()
1584
2483
  .filter(
@@ -1860,7 +2759,8 @@ export function createTelegramBusLeaderRuntime<TContext>(
1860
2759
  ): TelegramBusLeaderRuntime<TContext> {
1861
2760
  const getNowMs = deps.getNowMs ?? Date.now;
1862
2761
  const followerPruneIntervalMs = deps.followerPruneIntervalMs ?? 1000;
1863
- const followerStaleAfterMs = deps.followerStaleAfterMs ?? 5000;
2762
+ const followerStaleAfterMs =
2763
+ deps.followerStaleAfterMs ?? TELEGRAM_BUS_FOLLOWER_STALE_AFTER_MS;
1864
2764
  const runFollowerMutation = createTelegramBusFollowerMutationRunner();
1865
2765
  let pruneInterval: ReturnType<typeof setInterval> | undefined;
1866
2766
  let pruneGeneration = 0;
@@ -2014,8 +2914,15 @@ export function createTelegramBusLeaderRuntime<TContext>(
2014
2914
  routeQueueHandoff: deps.routeQueueHandoff,
2015
2915
  provisionFollowerTarget: deps.provisionFollowerTarget,
2016
2916
  onFollowerDisconnected: deps.onFollowerDisconnected,
2917
+ renameFollowerThread: deps.renameFollowerThread,
2918
+ resetFollowerThreadName: deps.resetFollowerThreadName,
2919
+ getFollowerDisplayTitle: deps.getFollowerDisplayTitle,
2920
+ onFollowerRegistered: deps.onFollowerRegistered,
2921
+ applyThreadDisplayMode: deps.applyThreadDisplayMode,
2922
+ getThreadDisplayMode: deps.getThreadDisplayMode,
2017
2923
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
2018
2924
  runFollowerMutation,
2925
+ runWorkspaceAdmission: deps.runWorkspaceAdmission,
2019
2926
  });
2020
2927
  const routeQueueHandoffEnvelope = async (
2021
2928
  input: Parameters<TelegramBusLeaderRuntime<TContext>["routeQueueHandoff"]>[0],