@llblab/pi-telegram 0.45.11 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/BACKLOG.md +5 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +6 -6
  4. package/dist/api/activity.d.ts +6 -0
  5. package/dist/api/activity.js +6 -0
  6. package/dist/api/commands.d.ts +6 -0
  7. package/dist/api/commands.js +6 -0
  8. package/dist/api/delivery.d.ts +6 -0
  9. package/dist/api/delivery.js +6 -0
  10. package/dist/api/inbound.d.ts +6 -0
  11. package/dist/api/inbound.js +6 -0
  12. package/dist/api/keyboard.d.ts +6 -0
  13. package/dist/api/keyboard.js +6 -0
  14. package/dist/api/outbound.d.ts +6 -0
  15. package/dist/api/outbound.js +6 -0
  16. package/dist/api/sections.d.ts +7 -0
  17. package/dist/api/sections.js +6 -0
  18. package/dist/api/status.d.ts +6 -0
  19. package/dist/api/status.js +6 -0
  20. package/dist/api/updates.d.ts +6 -0
  21. package/dist/api/updates.js +6 -0
  22. package/dist/api/voice.d.ts +6 -0
  23. package/dist/api/voice.js +6 -0
  24. package/dist/index.d.ts +6 -0
  25. package/dist/index.js +6 -0
  26. package/dist/lib/activity-verbosity.d.ts +52 -0
  27. package/dist/lib/activity-verbosity.js +596 -0
  28. package/dist/lib/activity.d.ts +220 -0
  29. package/dist/lib/activity.js +574 -0
  30. package/dist/lib/agent-messages.d.ts +28 -0
  31. package/dist/lib/agent-messages.js +86 -0
  32. package/dist/lib/bindings.d.ts +250 -0
  33. package/dist/lib/bindings.js +936 -0
  34. package/dist/lib/bus-api.d.ts +18 -0
  35. package/dist/lib/bus-api.js +254 -0
  36. package/dist/lib/bus-follower.d.ts +464 -0
  37. package/dist/lib/bus-follower.js +1686 -0
  38. package/dist/lib/bus-leader.d.ts +292 -0
  39. package/dist/lib/bus-leader.js +2242 -0
  40. package/dist/lib/bus-transport.d.ts +64 -0
  41. package/dist/lib/bus-transport.js +140 -0
  42. package/dist/lib/bus.d.ts +518 -0
  43. package/dist/lib/bus.js +2055 -0
  44. package/dist/lib/channel-posts.d.ts +170 -0
  45. package/dist/lib/channel-posts.js +611 -0
  46. package/dist/lib/command-templates.d.ts +70 -0
  47. package/dist/lib/command-templates.js +744 -0
  48. package/dist/lib/commands.d.ts +541 -0
  49. package/dist/lib/commands.js +1155 -0
  50. package/dist/lib/config.d.ts +252 -0
  51. package/dist/lib/config.js +889 -0
  52. package/dist/lib/delivery.d.ts +163 -0
  53. package/dist/lib/delivery.js +542 -0
  54. package/dist/lib/extension.d.ts +7 -0
  55. package/dist/lib/extension.js +1608 -0
  56. package/dist/lib/generative-app-worker.mjs +104 -0
  57. package/dist/lib/generative-apps.d.ts +212 -0
  58. package/dist/lib/generative-apps.js +1006 -0
  59. package/dist/lib/inbound.d.ts +91 -0
  60. package/dist/lib/inbound.js +502 -0
  61. package/dist/lib/journal.d.ts +665 -0
  62. package/dist/lib/journal.js +3701 -0
  63. package/dist/lib/keyboard.d.ts +23 -0
  64. package/dist/lib/keyboard.js +37 -0
  65. package/dist/lib/lifecycle.d.ts +157 -0
  66. package/dist/lib/lifecycle.js +395 -0
  67. package/dist/lib/locks.d.ts +164 -0
  68. package/dist/lib/locks.js +1208 -0
  69. package/dist/lib/logging.d.ts +50 -0
  70. package/dist/lib/logging.js +274 -0
  71. package/dist/lib/media.d.ts +181 -0
  72. package/dist/lib/media.js +602 -0
  73. package/dist/lib/menu-model.d.ts +217 -0
  74. package/dist/lib/menu-model.js +663 -0
  75. package/dist/lib/menu-queue.d.ts +37 -0
  76. package/dist/lib/menu-queue.js +408 -0
  77. package/dist/lib/menu-settings.d.ts +116 -0
  78. package/dist/lib/menu-settings.js +595 -0
  79. package/dist/lib/menu-status.d.ts +32 -0
  80. package/dist/lib/menu-status.js +112 -0
  81. package/dist/lib/menu-thinking.d.ts +29 -0
  82. package/dist/lib/menu-thinking.js +82 -0
  83. package/dist/lib/menu.d.ts +171 -0
  84. package/dist/lib/menu.js +323 -0
  85. package/dist/lib/model.d.ts +127 -0
  86. package/dist/lib/model.js +409 -0
  87. package/dist/lib/outbound-attachments.d.ts +241 -0
  88. package/dist/lib/outbound-attachments.js +639 -0
  89. package/dist/lib/outbound-buttons.d.ts +69 -0
  90. package/dist/lib/outbound-buttons.js +248 -0
  91. package/dist/lib/outbound-markup.d.ts +42 -0
  92. package/dist/lib/outbound-markup.js +678 -0
  93. package/dist/lib/outbound-voice.d.ts +55 -0
  94. package/dist/lib/outbound-voice.js +152 -0
  95. package/dist/lib/outbound.d.ts +185 -0
  96. package/dist/lib/outbound.js +516 -0
  97. package/dist/lib/ownership.d.ts +77 -0
  98. package/dist/lib/ownership.js +174 -0
  99. package/dist/lib/paths.d.ts +40 -0
  100. package/dist/lib/paths.js +94 -0
  101. package/dist/lib/pi.d.ts +72 -0
  102. package/dist/lib/pi.js +121 -0
  103. package/dist/lib/polling.d.ts +351 -0
  104. package/dist/lib/polling.js +1196 -0
  105. package/dist/lib/preview.d.ts +192 -0
  106. package/dist/lib/preview.js +614 -0
  107. package/dist/lib/prompt-templates.d.ts +24 -0
  108. package/dist/lib/prompt-templates.js +118 -0
  109. package/dist/lib/prompts.d.ts +57 -0
  110. package/dist/lib/prompts.js +167 -0
  111. package/dist/lib/queue.d.ts +766 -0
  112. package/dist/lib/queue.js +1965 -0
  113. package/dist/lib/recovery.d.ts +86 -0
  114. package/dist/lib/recovery.js +285 -0
  115. package/dist/lib/rendering.d.ts +20 -0
  116. package/dist/lib/rendering.js +983 -0
  117. package/dist/lib/replies.d.ts +214 -0
  118. package/dist/lib/replies.js +737 -0
  119. package/dist/lib/routing.d.ts +207 -0
  120. package/dist/lib/routing.js +2282 -0
  121. package/dist/lib/runtime.d.ts +172 -0
  122. package/dist/lib/runtime.js +402 -0
  123. package/dist/lib/sections.d.ts +165 -0
  124. package/dist/lib/sections.js +327 -0
  125. package/dist/lib/setup.d.ts +82 -0
  126. package/dist/lib/setup.js +150 -0
  127. package/dist/lib/skills.d.ts +8 -0
  128. package/dist/lib/skills.js +12 -0
  129. package/dist/lib/status.d.ts +442 -0
  130. package/dist/lib/status.js +1006 -0
  131. package/dist/lib/sync.d.ts +179 -0
  132. package/dist/lib/sync.js +782 -0
  133. package/dist/lib/target.d.ts +20 -0
  134. package/dist/lib/target.js +27 -0
  135. package/dist/lib/telegram-api.d.ts +541 -0
  136. package/dist/lib/telegram-api.js +1159 -0
  137. package/dist/lib/text-groups.d.ts +89 -0
  138. package/dist/lib/text-groups.js +317 -0
  139. package/dist/lib/thread-cleanup-manager.d.ts +288 -0
  140. package/dist/lib/thread-cleanup-manager.js +560 -0
  141. package/dist/lib/thread-display.d.ts +46 -0
  142. package/dist/lib/thread-display.js +257 -0
  143. package/dist/lib/thread-naming.d.ts +46 -0
  144. package/dist/lib/thread-naming.js +78 -0
  145. package/dist/lib/thread-reconciler.d.ts +239 -0
  146. package/dist/lib/thread-reconciler.js +644 -0
  147. package/dist/lib/threads.d.ts +621 -0
  148. package/dist/lib/threads.js +3679 -0
  149. package/dist/lib/time-injection.d.ts +15 -0
  150. package/dist/lib/time-injection.js +56 -0
  151. package/dist/lib/turns.d.ts +109 -0
  152. package/dist/lib/turns.js +500 -0
  153. package/dist/lib/updates.d.ts +1290 -0
  154. package/dist/lib/updates.js +3634 -0
  155. package/dist/lib/voice.d.ts +117 -0
  156. package/dist/lib/voice.js +174 -0
  157. package/dist/lib/workspace-admission.d.ts +264 -0
  158. package/dist/lib/workspace-admission.js +1136 -0
  159. package/dist/lib/workspace-retirement.d.ts +219 -0
  160. package/dist/lib/workspace-retirement.js +587 -0
  161. package/dist/lib/workspace-slots.d.ts +30 -0
  162. package/dist/lib/workspace-slots.js +54 -0
  163. package/dist/package.json +126 -0
  164. package/dist/pi-telegram/index.js +1 -0
  165. package/dist/skills/generated-control-surface/SKILL.md +107 -0
  166. package/dist/skills/generated-control-surface/references/capability-adapters.md +27 -0
  167. package/dist/skills/generated-control-surface/references/layout-and-state.md +37 -0
  168. package/dist/skills/generative-apps/SKILL.md +115 -0
  169. package/dist/skills/show-me/SKILL.md +166 -0
  170. package/dist/skills/show-me/references/telegram-surfaces.md +43 -0
  171. package/dist/skills/telegram-bridge/SKILL.md +129 -0
  172. package/dist/skills/telegram-bridge/references/configuration.md +15 -0
  173. package/dist/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
  174. package/dist/skills/telegram-bridge/references/diagnosis.md +18 -0
  175. package/docs/architecture.md +1 -1
  176. package/docs/compact-matrix-literal.md +10 -3
  177. package/docs/generative-apps.md +15 -13
  178. package/docs/multi-instance-bus.md +17 -3
  179. package/docs/outbound.md +1 -1
  180. package/docs/public-api.md +2 -2
  181. package/lib/bindings.ts +89 -6
  182. package/lib/bus-follower.ts +11 -3
  183. package/lib/bus-leader.ts +57 -20
  184. package/lib/bus.ts +12 -2
  185. package/lib/commands.ts +2 -2
  186. package/lib/config.ts +14 -7
  187. package/lib/delivery.ts +38 -6
  188. package/lib/extension.ts +26 -1
  189. package/lib/generative-apps.ts +379 -13
  190. package/lib/menu-settings.ts +21 -5
  191. package/lib/outbound-markup.ts +16 -7
  192. package/lib/pi.ts +4 -0
  193. package/lib/sync.ts +3 -0
  194. package/lib/telegram-api.ts +12 -1
  195. package/lib/thread-cleanup-manager.ts +36 -6
  196. package/lib/thread-display.ts +96 -18
  197. package/lib/threads.ts +150 -30
  198. package/package.json +56 -13
  199. package/scripts/build-dist.mjs +44 -0
  200. package/scripts/measure-bus.mjs +8 -1
  201. package/scripts/measure-workspace.mjs +8 -1
  202. package/skills/generative-apps/SKILL.md +1 -1
  203. package/skills/show-me/SKILL.md +1 -1
  204. package/skills/telegram-bridge/SKILL.md +1 -1
@@ -504,14 +504,23 @@ function parseTelegramAdaptiveActionPayloadRows(
504
504
  return undefined;
505
505
  };
506
506
 
507
+ const parseVerticalSequence = (): Record<string, unknown>[][] | undefined => {
508
+ const rows: Record<string, unknown>[][] = [];
509
+ while (offset < source.length) {
510
+ skipWhitespace();
511
+ if (source[offset] !== "{") break;
512
+ const cell = parseCell();
513
+ if (!cell) return undefined;
514
+ rows.push([cell]);
515
+ if (!consumeOptionalSeparator()) return undefined;
516
+ }
517
+ return rows.length > 0 ? rows : undefined;
518
+ };
519
+
507
520
  skipWhitespace();
508
- let rows: Record<string, unknown>[][] | undefined;
509
- if (source[offset] === "{") {
510
- const cell = parseCell();
511
- rows = cell ? [[cell]] : undefined;
512
- } else {
513
- rows = parseMatrix();
514
- }
521
+ const rows = source[offset] === "{"
522
+ ? parseVerticalSequence()
523
+ : parseMatrix();
515
524
  if (!rows) return undefined;
516
525
  skipWhitespace();
517
526
  if (!options.allowTrailing && offset !== source.length) return undefined;
package/lib/pi.ts CHANGED
@@ -260,6 +260,10 @@ export function getExtensionContextCwd(ctx: ExtensionContext): string {
260
260
  return ctx.cwd;
261
261
  }
262
262
 
263
+ export function getExtensionContextSessionId(ctx: ExtensionContext): string {
264
+ return ctx.sessionManager.getSessionId();
265
+ }
266
+
263
267
  export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
264
268
  return ctx.isIdle();
265
269
  }
package/lib/sync.ts CHANGED
@@ -46,6 +46,7 @@ export interface TelegramLeaderThreadSyncDeps {
46
46
  getAllowedUserId: () => number | undefined;
47
47
  instanceId: string;
48
48
  cwd?: string;
49
+ sessionId?: string;
49
50
  telegramProfile?: string;
50
51
  forceFreshUnnamed?: boolean;
51
52
  requestedThreadName?: string;
@@ -654,6 +655,7 @@ export async function ensureTelegramLeaderThreadBinding(
654
655
  normalizedLeaderCwd,
655
656
  deps.instanceId,
656
657
  legacyLeaderRecord?.instanceId,
658
+ { sessionId: deps.sessionId },
657
659
  )
658
660
  : undefined;
659
661
  if (deps.cwd && !workspaceIdentity) {
@@ -715,6 +717,7 @@ export async function ensureTelegramLeaderThreadBinding(
715
717
  ? deps.topicTargetStore.getWorkspaceBinding(
716
718
  workspaceIdentity.cwd,
717
719
  workspaceIdentity.instanceSlot,
720
+ workspaceIdentity.sessionId,
718
721
  )
719
722
  : undefined;
720
723
  const legacyWorkspaceBinding =
@@ -964,7 +964,7 @@ export function isTelegramApiMethodRetrySafe(method: string): boolean {
964
964
  return TELEGRAM_RETRY_SAFE_METHODS.has(method);
965
965
  }
966
966
 
967
- function isRetryableTelegramApiError(error: unknown): boolean {
967
+ export function isRetryableTelegramApiError(error: unknown): boolean {
968
968
  return (
969
969
  error instanceof TelegramApiHttpError &&
970
970
  (error.status === 429 ||
@@ -972,6 +972,17 @@ function isRetryableTelegramApiError(error: unknown): boolean {
972
972
  );
973
973
  }
974
974
 
975
+ export function getTelegramApiRetryAfterMs(error: unknown): number | undefined {
976
+ return error instanceof TelegramApiHttpError && error.retryAfterSeconds !== undefined
977
+ ? Math.max(0, error.retryAfterSeconds * 1000)
978
+ : undefined;
979
+ }
980
+
981
+ export function isTelegramMessageUnavailableError(error: unknown): boolean {
982
+ return error instanceof TelegramApiHttpError && error.status === 400 &&
983
+ /Bad Request: (message to edit not found|message not found|message_id_invalid)/iu.test(error.message);
984
+ }
985
+
975
986
  function getTelegramRetryDelayMs(
976
987
  error: unknown,
977
988
  attempt: number,
@@ -17,6 +17,8 @@ import type { TelegramWorkspaceDeletionPermit,
17
17
  export interface TelegramThreadCleanupBindingSnapshot {
18
18
  cwd: string;
19
19
  workspaceKey: string;
20
+ sessionId?: string;
21
+ sessionKey?: string;
20
22
  instanceSlot: string;
21
23
  slot?: string;
22
24
  bindingKey: string;
@@ -40,6 +42,8 @@ export interface TelegramThreadCleanupCandidate {
40
42
  bindingKey: string;
41
43
  cwd: string;
42
44
  workspaceKey: string;
45
+ sessionId?: string;
46
+ sessionKey?: string;
43
47
  instanceSlot: string;
44
48
  slot: string;
45
49
  target: { chatId: number; threadId: number };
@@ -56,6 +60,21 @@ function validTarget(target: { chatId: number; threadId: number }): boolean {
56
60
  target.threadId > 0;
57
61
  }
58
62
 
63
+ function validSessionIdentity(value: {
64
+ sessionId?: unknown;
65
+ sessionKey?: unknown;
66
+ }): boolean {
67
+ if (value.sessionId === undefined && value.sessionKey === undefined) return true;
68
+ if (typeof value.sessionId !== "string" || !value.sessionId ||
69
+ value.sessionId !== value.sessionId.trim() ||
70
+ Buffer.byteLength(value.sessionId, "utf8") > 256 ||
71
+ typeof value.sessionKey !== "string" || !/^[a-f0-9]{64}$/u.test(value.sessionKey)) {
72
+ return false;
73
+ }
74
+ return createHash("sha256").update(value.sessionId).digest("hex") ===
75
+ value.sessionKey;
76
+ }
77
+
59
78
  /** Returns no candidates when any identity/evidence ambiguity exists. */
60
79
  export function planTelegramInactiveThreadCleanup(input: {
61
80
  profileName: string;
@@ -72,7 +91,7 @@ export function planTelegramInactiveThreadCleanup(input: {
72
91
  const key = targetKey(binding.target);
73
92
  const slot = binding.slot;
74
93
  if (!binding.bindingKey || !binding.cwd || !binding.workspaceKey || !binding.instanceSlot ||
75
- typeof slot !== "string" || !slot ||
94
+ typeof slot !== "string" || !slot || !validSessionIdentity(binding) ||
76
95
  !validTarget(binding.target) || bindingKeys.has(binding.bindingKey) || bindingTargets.has(key)) return [];
77
96
  bindingKeys.add(binding.bindingKey);
78
97
  bindingTargets.add(key);
@@ -101,8 +120,11 @@ export function planTelegramInactiveThreadCleanup(input: {
101
120
  evidence.liveOwner !== "clear" || evidence.acceptedWork !== "clear" ||
102
121
  evidence.deliveryAuthority !== "clear" || competing.has(targetKey(binding.target))) continue;
103
122
  candidates.push({ profileName: input.profileName, bindingKey: binding.bindingKey,
104
- cwd: binding.cwd, workspaceKey: binding.workspaceKey, instanceSlot: binding.instanceSlot,
105
- slot, target: { ...binding.target }, inactiveSinceMs,
123
+ cwd: binding.cwd, workspaceKey: binding.workspaceKey,
124
+ ...(binding.sessionId && binding.sessionKey
125
+ ? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
126
+ : {}),
127
+ instanceSlot: binding.instanceSlot, slot, target: { ...binding.target }, inactiveSinceMs,
106
128
  bindingUpdatedAtMs: binding.updatedAtMs });
107
129
  }
108
130
  return candidates.sort((left, right) => left.bindingKey.localeCompare(right.bindingKey));
@@ -124,7 +146,11 @@ export function captureTelegramInactiveThreadCleanupEvidence<
124
146
  }): Parameters<typeof planTelegramInactiveThreadCleanup>[0] {
125
147
  const sourceBindings = input.listBindings();
126
148
  const bindings = sourceBindings.map(binding => ({
127
- cwd: binding.cwd, workspaceKey: binding.workspaceKey, instanceSlot: binding.instanceSlot,
149
+ cwd: binding.cwd, workspaceKey: binding.workspaceKey,
150
+ ...(binding.sessionId && binding.sessionKey
151
+ ? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
152
+ : {}),
153
+ instanceSlot: binding.instanceSlot,
128
154
  ...(binding.slot === undefined ? {} : { slot: binding.slot }), bindingKey: binding.bindingKey, target: { ...binding.target },
129
155
  ...(binding.inactiveSinceMs === undefined ? {} : { inactiveSinceMs: binding.inactiveSinceMs }),
130
156
  updatedAtMs: binding.updatedAtMs,
@@ -237,16 +263,20 @@ function validateWorkSet(value: unknown, profileName: string): TelegramThreadCle
237
263
  const createdAtMs = value.createdAtMs as number;
238
264
  const entries = value.entries.map(raw => {
239
265
  if (!isObject(raw) || !onlyKeys(raw, ["profileName", "bindingKey", "cwd", "workspaceKey",
240
- "instanceSlot", "slot", "target", "inactiveSinceMs", "bindingUpdatedAtMs", "state", "updatedAtMs",
266
+ "sessionId", "sessionKey", "instanceSlot", "slot", "target", "inactiveSinceMs", "bindingUpdatedAtMs", "state", "updatedAtMs",
241
267
  "issuedAtMs", "permitOperationId", "permitIntentId", "permitLeaderEpoch", "deletedAtMs"]) || raw.profileName !== profileName || typeof raw.bindingKey !== "string" ||
242
268
  !raw.bindingKey || typeof raw.cwd !== "string" || !raw.cwd || typeof raw.workspaceKey !== "string" ||
243
- !raw.workspaceKey || typeof raw.instanceSlot !== "string" || !raw.instanceSlot ||
269
+ !raw.workspaceKey || !validSessionIdentity(raw) ||
270
+ typeof raw.instanceSlot !== "string" || !raw.instanceSlot ||
244
271
  typeof raw.slot !== "string" || !raw.slot || !isObject(raw.target) ||
245
272
  !onlyKeys(raw.target, ["chatId", "threadId"]) || !validTarget(raw.target as unknown as { chatId: number; threadId: number }) ||
246
273
  !safeTime(raw.inactiveSinceMs) || !safeTime(raw.bindingUpdatedAtMs) ||
247
274
  (raw.bindingUpdatedAtMs as number) < (raw.inactiveSinceMs as number) || !safeTime(raw.updatedAtMs))
248
275
  throw new Error("Telegram Thread cleanup entry schema is invalid.");
249
276
  const base = { profileName, bindingKey: raw.bindingKey, cwd: raw.cwd, workspaceKey: raw.workspaceKey,
277
+ ...(typeof raw.sessionId === "string" && typeof raw.sessionKey === "string"
278
+ ? { sessionId: raw.sessionId, sessionKey: raw.sessionKey }
279
+ : {}),
250
280
  instanceSlot: raw.instanceSlot, slot: raw.slot, target: { chatId: raw.target.chatId as number,
251
281
  threadId: raw.target.threadId as number }, inactiveSinceMs: raw.inactiveSinceMs as number,
252
282
  bindingUpdatedAtMs: raw.bindingUpdatedAtMs as number, updatedAtMs: raw.updatedAtMs as number };
@@ -26,30 +26,88 @@ function boundedLabel(base: string, suffix = ""): string {
26
26
  return `${prefix}${suffix}`;
27
27
  }
28
28
 
29
- function directoryLabel(cwd: string, directories: readonly string[]): string {
30
- const parts = cwd.split("/").filter(Boolean);
31
- if (!parts.length) return "/";
29
+ function directorySegments(cwd: string): string[] {
30
+ return cwd.split("/").filter(Boolean);
31
+ }
32
+
33
+ function distinguishingDirectorySegments(cwd: string, directories: readonly string[]): string[] {
34
+ const parts = directorySegments(cwd);
35
+ if (!parts.length) return [];
32
36
  for (let depth = 1; depth <= parts.length; depth++) {
33
37
  const candidate = labelText(parts.slice(-depth).join("/"));
34
38
  const collides = directories.some((other) => other !== cwd &&
35
- labelText(other.split("/").filter(Boolean).slice(-depth).join("/")).toLowerCase() ===
39
+ labelText(directorySegments(other).slice(-depth).join("/")).toLowerCase() ===
36
40
  candidate.toLowerCase(),
37
41
  );
38
- if (!collides) return candidate;
42
+ if (!collides) return parts.slice(-depth);
39
43
  }
40
- return labelText(cwd);
44
+ return parts;
45
+ }
46
+
47
+ function directoryLabel(cwd: string, directories: readonly string[]): string {
48
+ const parts = distinguishingDirectorySegments(cwd, directories);
49
+ return parts.length ? labelText(parts.join("/")) : "/";
50
+ }
51
+
52
+ /** Pure directory tokenization shared by previews, initial titles, and reconciliation. */
53
+ export function tokenizeTelegramDirectorySegment(segment: string): string[] {
54
+ return segment
55
+ .replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, "$1\u0000$2")
56
+ .replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, "$1\u0000$2")
57
+ .split(/[^\p{L}\p{N}]+|\u0000/gu)
58
+ .filter(Boolean);
59
+ }
60
+
61
+ function formatDirectoryLabel(
62
+ cwd: string,
63
+ directories: readonly string[],
64
+ mode: "directory-snake" | "directory-title",
65
+ ): string {
66
+ const segments = distinguishingDirectorySegments(cwd, directories);
67
+ if (!segments.length) return "/";
68
+ const formatted = segments.map((segment) => {
69
+ const tokens = tokenizeTelegramDirectorySegment(segment);
70
+ if (!tokens.length) return "";
71
+ if (mode === "directory-snake") return tokens.map((token) => token.toLowerCase()).join("_");
72
+ return tokens.map((token) => /\p{L}/u.test(token) && token === token.toUpperCase()
73
+ ? token
74
+ : `${token.slice(0, 1).toUpperCase()}${token.slice(1).toLowerCase()}`).join(" ");
75
+ }).filter(Boolean);
76
+ if (!formatted.length) return directoryLabel(cwd, directories);
77
+ return formatted.join(mode === "directory-snake" ? "_" : " / ");
78
+ }
79
+
80
+ /** Maps one leader-captured authenticated owner roster onto retained binding identities. */
81
+ export function resolveTelegramLiveWorkspaceBindingKeys(
82
+ bindings: readonly TelegramWorkspaceThreadBinding[],
83
+ leaderTarget: { chatId: number; threadId?: number } | undefined,
84
+ followers: readonly { target?: { chatId: number; threadId?: number } }[],
85
+ ): ReadonlySet<string> {
86
+ const targets = new Set<string>();
87
+ const add = (target: { chatId: number; threadId?: number } | undefined) => {
88
+ if (target && typeof target.threadId === "number") {
89
+ targets.add(`${target.chatId}:${target.threadId}`);
90
+ }
91
+ };
92
+ add(leaderTarget);
93
+ for (const follower of followers) add(follower.target);
94
+ return new Set(bindings.filter((binding) =>
95
+ targets.has(`${binding.target.chatId}:${binding.target.threadId}`),
96
+ ).map((binding) => binding.bindingKey));
41
97
  }
42
98
 
43
99
  /** Missing or ambiguous metadata yields no label rather than inventing identity. */
44
100
  export function resolveTelegramWorkspaceDisplayNames(
45
101
  bindings: readonly TelegramWorkspaceDisplayBinding[],
46
102
  mode: TelegramThreadDisplayMode,
103
+ liveBindingKeys: ReadonlySet<string> = new Set(),
47
104
  ): Map<string, string> {
48
105
  const labels = new Map<string, string>();
49
106
  const directories = Array.from(new Set(bindings.map((binding) => binding.cwd)));
50
- const directoryCounts = new Map<string, number>();
107
+ const liveDirectoryCounts = new Map<string, number>();
51
108
  for (const binding of bindings) {
52
- directoryCounts.set(binding.cwd, (directoryCounts.get(binding.cwd) ?? 0) + 1);
109
+ if (!liveBindingKeys.has(binding.bindingKey)) continue;
110
+ liveDirectoryCounts.set(binding.cwd, (liveDirectoryCounts.get(binding.cwd) ?? 0) + 1);
53
111
  }
54
112
  const bases = new Map<string, string>();
55
113
  for (const binding of bindings) {
@@ -65,18 +123,27 @@ export function resolveTelegramWorkspaceDisplayNames(
65
123
  const name = binding.threadName ? labelText(binding.threadName) : slot;
66
124
  if (name) labels.set(binding.bindingKey, boundedLabel(name));
67
125
  } else {
68
- const base = directoryLabel(binding.cwd, directories);
126
+ const base = mode === "directories"
127
+ ? directoryLabel(binding.cwd, directories)
128
+ : formatDirectoryLabel(binding.cwd, directories, mode);
69
129
  bases.set(binding.bindingKey, base);
70
- const showSuffix = binding.showSlotSuffix ||
71
- (directoryCounts.get(binding.cwd) ?? 0) > 1;
130
+ const showSuffix = mode === "directories"
131
+ ? binding.showSlotSuffix || bindings.filter((candidate) => candidate.cwd === binding.cwd).length > 1
132
+ : (liveDirectoryCounts.get(binding.cwd) ?? 0) > 1;
72
133
  if (showSuffix && !slot) continue;
73
- labels.set(binding.bindingKey, boundedLabel(base, showSuffix ? `_${slot!.toLowerCase()}` : ""));
134
+ const suffix = !showSuffix ? "" : mode === "directory-title"
135
+ ? ` · ${slot}`
136
+ : `_${slot!.toLowerCase()}`;
137
+ labels.set(binding.bindingKey, boundedLabel(base, suffix));
74
138
  }
75
139
  }
76
140
  // Long or whitespace-normalized paths can collide even after qualification.
77
- if (mode === "directories") {
141
+ if (mode === "directories" || mode === "directory-snake" || mode === "directory-title") {
78
142
  const counts = new Map<string, number>();
79
- for (const label of labels.values()) {
143
+ for (const binding of bindings) {
144
+ const label = labels.get(binding.bindingKey);
145
+ if (!label || ((mode === "directory-snake" || mode === "directory-title") &&
146
+ !liveBindingKeys.has(binding.bindingKey))) continue;
80
147
  const key = label.toLowerCase();
81
148
  counts.set(key, (counts.get(key) ?? 0) + 1);
82
149
  }
@@ -84,16 +151,24 @@ export function resolveTelegramWorkspaceDisplayNames(
84
151
  const label = labels.get(binding.bindingKey);
85
152
  if (binding.manualThreadName || !label ||
86
153
  (counts.get(label.toLowerCase()) ?? 0) < 2) continue;
154
+ if ((mode === "directory-snake" || mode === "directory-title") &&
155
+ (liveDirectoryCounts.get(binding.cwd) ?? 0) < 2) {
156
+ labels.delete(binding.bindingKey);
157
+ continue;
158
+ }
87
159
  if (!binding.slot || !/^[A-Z]$/u.test(binding.slot)) {
88
160
  labels.delete(binding.bindingKey);
89
161
  continue;
90
162
  }
91
163
  labels.set(binding.bindingKey, boundedLabel(bases.get(binding.bindingKey)!,
92
- `_${binding.slot.toLowerCase()}`));
164
+ mode === "directory-title" ? ` · ${binding.slot}` : `_${binding.slot.toLowerCase()}`));
93
165
  }
94
166
  }
95
167
  const counts = new Map<string, number>();
96
- for (const label of labels.values()) {
168
+ for (const binding of bindings) {
169
+ const label = labels.get(binding.bindingKey);
170
+ if (!label || ((mode === "directory-snake" || mode === "directory-title") &&
171
+ !liveBindingKeys.has(binding.bindingKey))) continue;
97
172
  const key = label.toLowerCase();
98
173
  counts.set(key, (counts.get(key) ?? 0) + 1);
99
174
  }
@@ -108,6 +183,7 @@ export function resolveTelegramInitialWorkspaceDisplayName(input: {
108
183
  binding: TelegramWorkspaceDisplayBinding;
109
184
  mode: TelegramThreadDisplayMode;
110
185
  preserveRetainedManualName?: boolean;
186
+ liveBindingKeys?: ReadonlySet<string>;
111
187
  }): string | undefined {
112
188
  const retained = input.bindings.find((binding) =>
113
189
  binding.bindingKey === input.binding.bindingKey,
@@ -126,7 +202,7 @@ export function resolveTelegramInitialWorkspaceDisplayName(input: {
126
202
  candidate.bindingKey !== binding.bindingKey,
127
203
  ),
128
204
  binding,
129
- ], input.mode).get(binding.bindingKey);
205
+ ], input.mode, new Set([...(input.liveBindingKeys ?? []), binding.bindingKey])).get(binding.bindingKey);
130
206
  }
131
207
 
132
208
  export async function applyTelegramThreadDisplaySetting(
@@ -155,6 +231,7 @@ export interface TelegramThreadDisplayReconcilerDeps {
155
231
  getProfileKey(): string;
156
232
  getLeaderEpoch(): string | number | undefined;
157
233
  captureBindingAuthority(binding: TelegramWorkspaceThreadBinding): (() => boolean) | undefined;
234
+ captureLiveBindingKeys(bindings: readonly TelegramWorkspaceThreadBinding[]): ReadonlySet<string>;
158
235
  callApi<TResponse>(
159
236
  method: string,
160
237
  body: Record<string, unknown>,
@@ -179,7 +256,8 @@ export function createTelegramThreadDisplayReconciler(
179
256
  };
180
257
  assertAuthority();
181
258
  const bindings = deps.store.listWorkspaceBindings();
182
- const titles = resolveTelegramWorkspaceDisplayNames(bindings, mode);
259
+ const liveBindingKeys = deps.captureLiveBindingKeys(bindings);
260
+ const titles = resolveTelegramWorkspaceDisplayNames(bindings, mode, liveBindingKeys);
183
261
  let changed = 0;
184
262
  for (const binding of bindings) {
185
263
  const isBindingCurrent = deps.captureBindingAuthority(binding);