@llblab/pi-telegram 0.51.5 → 0.52.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 (181) hide show
  1. package/AGENTS.md +10 -8
  2. package/BACKLOG.md +1 -8
  3. package/CHANGELOG.md +23 -0
  4. package/LICENSE +21 -0
  5. package/README.md +16 -6
  6. package/dist/lib/activity-verbosity.d.ts +1 -5
  7. package/dist/lib/activity-verbosity.js +5 -7
  8. package/dist/lib/bindings.d.ts +9 -2
  9. package/dist/lib/bindings.js +22 -13
  10. package/dist/lib/bus-api.js +4 -2
  11. package/dist/lib/bus-follower.d.ts +55 -57
  12. package/dist/lib/bus-follower.js +213 -140
  13. package/dist/lib/bus-leader.d.ts +21 -2
  14. package/dist/lib/bus-leader.js +181 -25
  15. package/dist/lib/bus-transport.d.ts +4 -1
  16. package/dist/lib/bus-transport.js +28 -4
  17. package/dist/lib/bus.d.ts +52 -56
  18. package/dist/lib/bus.js +78 -246
  19. package/dist/lib/channel-posts.d.ts +1 -3
  20. package/dist/lib/channel-posts.js +23 -26
  21. package/dist/lib/command-templates.d.ts +0 -5
  22. package/dist/lib/command-templates.js +5 -5
  23. package/dist/lib/commands.d.ts +12 -26
  24. package/dist/lib/commands.js +152 -98
  25. package/dist/lib/config.d.ts +0 -17
  26. package/dist/lib/config.js +12 -14
  27. package/dist/lib/delivery.d.ts +0 -4
  28. package/dist/lib/delivery.js +2 -5
  29. package/dist/lib/extension.js +281 -61
  30. package/dist/lib/generative-apps.d.ts +0 -19
  31. package/dist/lib/inbound.d.ts +0 -2
  32. package/dist/lib/inbound.js +2 -2
  33. package/dist/lib/journal.d.ts +177 -16
  34. package/dist/lib/journal.js +1115 -249
  35. package/dist/lib/keyboard.d.ts +0 -2
  36. package/dist/lib/keyboard.js +2 -2
  37. package/dist/lib/lifecycle.d.ts +57 -2
  38. package/dist/lib/lifecycle.js +110 -5
  39. package/dist/lib/locks.d.ts +100 -12
  40. package/dist/lib/locks.js +380 -14
  41. package/dist/lib/logging.d.ts +30 -12
  42. package/dist/lib/logging.js +129 -72
  43. package/dist/lib/media.d.ts +28 -5
  44. package/dist/lib/media.js +26 -8
  45. package/dist/lib/menu-model.d.ts +0 -11
  46. package/dist/lib/menu-model.js +8 -8
  47. package/dist/lib/menu-settings.d.ts +0 -18
  48. package/dist/lib/menu-settings.js +18 -18
  49. package/dist/lib/menu-status.d.ts +4 -4
  50. package/dist/lib/menu-status.js +12 -7
  51. package/dist/lib/menu-thinking.d.ts +1 -1
  52. package/dist/lib/menu-thinking.js +1 -1
  53. package/dist/lib/menu.d.ts +1 -0
  54. package/dist/lib/menu.js +4 -3
  55. package/dist/lib/model.js +2 -4
  56. package/dist/lib/outbound-attachments.d.ts +0 -1
  57. package/dist/lib/outbound-attachments.js +1 -1
  58. package/dist/lib/outbound-buttons.js +1 -8
  59. package/dist/lib/outbound-markup.d.ts +1 -6
  60. package/dist/lib/outbound-markup.js +4 -21
  61. package/dist/lib/outbound.d.ts +0 -8
  62. package/dist/lib/outbound.js +5 -5
  63. package/dist/lib/paths.d.ts +50 -7
  64. package/dist/lib/paths.js +155 -17
  65. package/dist/lib/pi.d.ts +1 -0
  66. package/dist/lib/pi.js +2 -1
  67. package/dist/lib/polling.d.ts +0 -5
  68. package/dist/lib/polling.js +5 -5
  69. package/dist/lib/preview.d.ts +0 -3
  70. package/dist/lib/preview.js +3 -3
  71. package/dist/lib/prompt-templates.d.ts +0 -1
  72. package/dist/lib/prompt-templates.js +1 -1
  73. package/dist/lib/queue.d.ts +3 -15
  74. package/dist/lib/queue.js +69 -8
  75. package/dist/lib/recovery.d.ts +29 -9
  76. package/dist/lib/recovery.js +104 -33
  77. package/dist/lib/replies.d.ts +0 -4
  78. package/dist/lib/replies.js +1 -1
  79. package/dist/lib/routing.d.ts +73 -8
  80. package/dist/lib/routing.js +2206 -307
  81. package/dist/lib/sections.d.ts +0 -2
  82. package/dist/lib/sections.js +1 -1
  83. package/dist/lib/status.d.ts +46 -9
  84. package/dist/lib/status.js +179 -27
  85. package/dist/lib/sync.d.ts +0 -5
  86. package/dist/lib/sync.js +9 -8
  87. package/dist/lib/telegram-api.d.ts +11 -3
  88. package/dist/lib/telegram-api.js +34 -23
  89. package/dist/lib/thread-cleanup-manager.d.ts +1 -0
  90. package/dist/lib/thread-cleanup-manager.js +18 -21
  91. package/dist/lib/thread-naming.d.ts +32 -5
  92. package/dist/lib/thread-naming.js +191 -7
  93. package/dist/lib/thread-reconciler.d.ts +2 -0
  94. package/dist/lib/thread-reconciler.js +1 -1
  95. package/dist/lib/threads.d.ts +292 -59
  96. package/dist/lib/threads.js +1664 -373
  97. package/dist/lib/turns.d.ts +0 -1
  98. package/dist/lib/turns.js +1 -1
  99. package/dist/lib/updates.d.ts +184 -28
  100. package/dist/lib/updates.js +1032 -114
  101. package/dist/lib/wire.d.ts +12 -0
  102. package/dist/lib/wire.js +21 -0
  103. package/dist/lib/workspace-admission.d.ts +25 -17
  104. package/dist/lib/workspace-admission.js +95 -20
  105. package/dist/lib/workspace-identity.d.ts +20 -0
  106. package/dist/lib/workspace-identity.js +103 -0
  107. package/dist/lib/workspace-retirement.d.ts +45 -10
  108. package/dist/lib/workspace-retirement.js +196 -64
  109. package/dist/package.json +6 -6
  110. package/dist/skills/generated-control-surface/SKILL.md +3 -3
  111. package/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
  112. package/docs/README.md +1 -1
  113. package/docs/activity.md +1 -1
  114. package/docs/architecture.md +158 -39
  115. package/docs/callback-namespaces.md +1 -1
  116. package/docs/delivery.md +1 -1
  117. package/docs/generative-apps.md +2 -2
  118. package/docs/inbound.md +1 -1
  119. package/docs/multi-instance-bus.md +127 -19
  120. package/docs/public-api.md +5 -3
  121. package/docs/ui-style.md +26 -8
  122. package/docs/updates.md +1 -1
  123. package/lib/activity-verbosity.ts +5 -9
  124. package/lib/bindings.ts +23 -10
  125. package/lib/bus-api.ts +5 -2
  126. package/lib/bus-follower.ts +251 -226
  127. package/lib/bus-leader.ts +178 -31
  128. package/lib/bus-transport.ts +30 -8
  129. package/lib/bus.ts +110 -334
  130. package/lib/channel-posts.ts +29 -29
  131. package/lib/command-templates.ts +5 -5
  132. package/lib/commands.ts +150 -112
  133. package/lib/config.ts +12 -17
  134. package/lib/delivery.ts +2 -8
  135. package/lib/extension.ts +282 -72
  136. package/lib/generative-apps.ts +0 -22
  137. package/lib/inbound.ts +2 -2
  138. package/lib/journal.ts +1182 -326
  139. package/lib/keyboard.ts +2 -2
  140. package/lib/lifecycle.ts +141 -5
  141. package/lib/locks.ts +402 -23
  142. package/lib/logging.ts +151 -101
  143. package/lib/media.ts +51 -9
  144. package/lib/menu-model.ts +8 -8
  145. package/lib/menu-settings.ts +18 -18
  146. package/lib/menu-status.ts +11 -0
  147. package/lib/menu-thinking.ts +2 -2
  148. package/lib/menu.ts +5 -1
  149. package/lib/model.ts +2 -4
  150. package/lib/outbound-attachments.ts +1 -1
  151. package/lib/outbound-buttons.ts +1 -10
  152. package/lib/outbound-markup.ts +5 -24
  153. package/lib/outbound.ts +5 -5
  154. package/lib/paths.ts +177 -17
  155. package/lib/pi.ts +5 -2
  156. package/lib/polling.ts +5 -5
  157. package/lib/preview.ts +3 -3
  158. package/lib/prompt-templates.ts +1 -1
  159. package/lib/queue.ts +67 -9
  160. package/lib/recovery.ts +104 -44
  161. package/lib/replies.ts +1 -1
  162. package/lib/routing.ts +1955 -375
  163. package/lib/sections.ts +1 -1
  164. package/lib/status.ts +185 -36
  165. package/lib/sync.ts +8 -8
  166. package/lib/telegram-api.ts +48 -33
  167. package/lib/thread-cleanup-manager.ts +24 -21
  168. package/lib/thread-naming.ts +267 -9
  169. package/lib/thread-reconciler.ts +3 -1
  170. package/lib/threads.ts +1718 -491
  171. package/lib/turns.ts +1 -1
  172. package/lib/updates.ts +1072 -151
  173. package/lib/wire.ts +28 -0
  174. package/lib/workspace-admission.ts +97 -47
  175. package/lib/workspace-identity.ts +147 -0
  176. package/lib/workspace-retirement.ts +234 -102
  177. package/package.json +6 -6
  178. package/scripts/audit-exports.mjs +100 -0
  179. package/scripts/check-downgrade.mjs +80 -43
  180. package/skills/generated-control-surface/SKILL.md +3 -3
  181. package/skills/telegram-bridge/references/diagnosis.md +3 -3
@@ -8,12 +8,18 @@ import { chmodSync, closeSync, constants, createReadStream, fstatSync, lstatSync
8
8
  readFileSync, unlinkSync, writeFileSync } from "node:fs";
9
9
  import { lstat } from "node:fs/promises";
10
10
  import { basename, dirname } from "node:path";
11
+ import { getTelegramJournalPublicationPaths } from "./paths.ts";
11
12
  import { createHash, randomUUID } from "node:crypto";
12
13
 
13
14
  import { Type } from "@sinclair/typebox";
14
15
 
15
16
  import { renameTelegramPathWithRetry, withTelegramFileTransaction } from "./locks.ts";
16
17
  import type { ExtensionAPI } from "./pi.ts";
18
+ import {
19
+ isWireRecord as isRecord,
20
+ hasOnlyWireKeys as hasOnlyKeys,
21
+ isNonNegativeWireInteger as isSafeTime,
22
+ } from "./wire.ts";
17
23
 
18
24
  const CHANNEL_POST_JOURNAL_VERSION = 1;
19
25
  const DEFAULT_MAX_RECORDS = 256;
@@ -31,12 +37,12 @@ export interface TelegramChannelPostMediaIntent {
31
37
  sha256: string;
32
38
  }
33
39
 
34
- export const TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES: Record<TelegramChannelPostMediaKind, number> = {
40
+ const TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES: Record<TelegramChannelPostMediaKind, number> = {
35
41
  photo: 10 * 1024 * 1024,
36
42
  video: 50 * 1024 * 1024,
37
43
  };
38
- export const TELEGRAM_CHANNEL_POST_CAPTION_MAX_LENGTH = 1024;
39
- export const TELEGRAM_CHANNEL_POST_MEDIA_FILE_NAME_MAX_LENGTH = 255;
44
+ const TELEGRAM_CHANNEL_POST_CAPTION_MAX_LENGTH = 1024;
45
+ const TELEGRAM_CHANNEL_POST_MEDIA_FILE_NAME_MAX_LENGTH = 255;
40
46
 
41
47
  /** Safe, content-free local validation failure for channel media publication intent. */
42
48
  export class TelegramChannelPostValidationError extends Error {
@@ -195,6 +201,7 @@ interface TelegramChannelPostJournalFile {
195
201
 
196
202
  export interface TelegramChannelPostJournalStoreOptions {
197
203
  path: string;
204
+ runtimeDir?: string;
198
205
  profileName: string;
199
206
  tokenSha256: string;
200
207
  maxRecords?: number;
@@ -329,19 +336,6 @@ export function registerTelegramChannelPostListTool(
329
336
  });
330
337
  }
331
338
 
332
- function isRecord(value: unknown): value is Record<string, unknown> {
333
- return !!value && typeof value === "object" && !Array.isArray(value);
334
- }
335
-
336
- function hasOnlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
337
- const allowed = new Set(keys);
338
- return Object.keys(value).every(key => allowed.has(key));
339
- }
340
-
341
- function isSafeTime(value: unknown): value is number {
342
- return Number.isSafeInteger(value) && (value as number) >= 0;
343
- }
344
-
345
339
  function normalizeChannel(value: unknown): TelegramChannelPostAddress {
346
340
  if (Number.isSafeInteger(value) && (value as number) < 0) return value as number;
347
341
  if (typeof value === "string" && /^@[A-Za-z0-9_]{5,32}$/u.test(value)) {
@@ -454,9 +448,24 @@ function validateRecord(value: unknown): TelegramChannelPostRecord {
454
448
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains an invalid state.");
455
449
  }
456
450
 
451
+ function parseTelegramChannelPostJournalFile(value: unknown, profileName: string, tokenSha256: string): TelegramChannelPostJournalFile {
452
+ if (!isRecord(value) || !hasOnlyKeys(value, ["version", "profile", "tokenSha256", "records"]) ||
453
+ value.version !== CHANNEL_POST_JOURNAL_VERSION || value.profile !== profileName ||
454
+ value.tokenSha256 !== tokenSha256 || !Array.isArray(value.records)) {
455
+ throw new TelegramChannelPostJournalError("conflict", "Telegram channel post journal identity or schema does not match.");
456
+ }
457
+ const records = value.records.map(validateRecord);
458
+ if (new Set(records.map(record => record.operationId)).size !== records.length) {
459
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains duplicate operation IDs.");
460
+ }
461
+ return { version: CHANNEL_POST_JOURNAL_VERSION, profile: profileName, tokenSha256, records };
462
+ }
463
+
457
464
  export function createTelegramChannelPostJournalStore(
458
465
  options: TelegramChannelPostJournalStoreOptions,
459
466
  ): TelegramChannelPostJournalStore {
467
+ options = { ...options };
468
+ const publicationPaths = getTelegramJournalPublicationPaths(options.path, options.runtimeDir);
460
469
  const maxRecords = options.maxRecords ?? DEFAULT_MAX_RECORDS;
461
470
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
462
471
  const now = options.getNowMs ?? Date.now;
@@ -491,17 +500,7 @@ export function createTelegramChannelPostJournalStore(
491
500
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post journal changed during inspection.");
492
501
  }
493
502
  const value = JSON.parse(readFileSync(fd, "utf8")) as unknown;
494
- if (!isRecord(value) || !hasOnlyKeys(value, ["version", "profile", "tokenSha256", "records"]) ||
495
- value.version !== CHANNEL_POST_JOURNAL_VERSION || value.profile !== options.profileName ||
496
- value.tokenSha256 !== options.tokenSha256 || !Array.isArray(value.records)) {
497
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post journal identity or schema does not match.");
498
- }
499
- const records = value.records.map(validateRecord);
500
- if (new Set(records.map(record => record.operationId)).size !== records.length) {
501
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains duplicate operation IDs.");
502
- }
503
- return { version: CHANNEL_POST_JOURNAL_VERSION, profile: options.profileName,
504
- tokenSha256: options.tokenSha256, records };
503
+ return parseTelegramChannelPostJournalFile(value, options.profileName, options.tokenSha256);
505
504
  } catch (error) {
506
505
  if (error instanceof TelegramChannelPostJournalError) throw error;
507
506
  throw new TelegramChannelPostJournalError("io", "Could not read Telegram channel post journal.", error);
@@ -517,8 +516,9 @@ export function createTelegramChannelPostJournalStore(
517
516
  if (Buffer.byteLength(serialized) > maxBytes) {
518
517
  throw new TelegramChannelPostJournalError("capacity", "Telegram channel post journal byte limit reached.");
519
518
  }
520
- const temporaryPath = `${options.path}.${process.pid}.${randomUUID()}.tmp`;
519
+ const temporaryPath = `${publicationPaths.temporaryBasePath}.${process.pid}.${randomUUID()}.tmp`;
521
520
  mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
521
+ mkdirSync(dirname(temporaryPath), { recursive: true, mode: 0o700 });
522
522
  try {
523
523
  writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 });
524
524
  chmodSync(temporaryPath, 0o600);
@@ -532,7 +532,7 @@ export function createTelegramChannelPostJournalStore(
532
532
  };
533
533
  const mutate = <T>(operation: (file: TelegramChannelPostJournalFile) => T): T => {
534
534
  try {
535
- return withTelegramFileTransaction(`${options.path}.transaction`, () => operation(read()));
535
+ return withTelegramFileTransaction(publicationPaths.transactionPath, () => operation(read()));
536
536
  } catch (error) {
537
537
  if (error instanceof TelegramChannelPostJournalError) throw error;
538
538
  throw new TelegramChannelPostJournalError("io", "Telegram channel post journal mutation failed.", error);
@@ -121,7 +121,7 @@ function normalizeCommandTemplateDefaults(
121
121
  return normalized;
122
122
  }
123
123
 
124
- export function resolveInheritedDefaultReferences(
124
+ function resolveInheritedDefaultReferences(
125
125
  ownDefaults: Record<string, unknown> | undefined,
126
126
  inheritedDefaults: Record<string, unknown> | undefined,
127
127
  runtimeValues: Record<string, unknown> = {},
@@ -142,7 +142,7 @@ export function resolveInheritedDefaultReferences(
142
142
  return resolved;
143
143
  }
144
144
 
145
- export function resolveCommandTemplateRepeat(
145
+ function resolveCommandTemplateRepeat(
146
146
  value: number | string | undefined,
147
147
  values: Record<string, unknown> = {},
148
148
  ): number | undefined {
@@ -367,7 +367,7 @@ function pad(value: number, width: number): string {
367
367
  return String(value).padStart(width, "0");
368
368
  }
369
369
 
370
- export function getCommandTemplateRepeatDefaults(
370
+ function getCommandTemplateRepeatDefaults(
371
371
  index: number,
372
372
  repeat: number,
373
373
  ): Record<string, string> {
@@ -516,7 +516,7 @@ function parseCommandTemplatePlaceholderContent(
516
516
  };
517
517
  }
518
518
 
519
- export function getCommandTemplateDefaults(
519
+ function getCommandTemplateDefaults(
520
520
  config: CommandTemplateConfig | undefined,
521
521
  ): Record<string, string> {
522
522
  const normalizedConfig = config
@@ -588,7 +588,7 @@ export function splitCommandTemplate(input: string): string[] {
588
588
  return words;
589
589
  }
590
590
 
591
- export function expandCommandTemplateExecutable(
591
+ function expandCommandTemplateExecutable(
592
592
  command: string,
593
593
  cwd: string,
594
594
  ): string {
package/lib/commands.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  import type * as Pi from "./pi.ts";
15
15
  import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
16
16
  import { escapeHtml } from "./rendering.ts";
17
- import type { TelegramBridgeStatusLineOptions } from "./status.ts";
17
+ import { formatTelegramConnectionFailure, type TelegramBridgeStatusLineOptions } from "./status.ts";
18
18
  import type { TelegramSessionReplacementIntent } from "./threads.ts";
19
19
  import {
20
20
  createTelegramControlItemBuilder,
@@ -91,11 +91,11 @@ function getOrCreateTelegramCommandRegistry(): TelegramExtensionCommandRegistry
91
91
  return registry;
92
92
  }
93
93
 
94
- export function normalizeTelegramExtensionCommandName(name: string): string {
94
+ function normalizeTelegramExtensionCommandName(name: string): string {
95
95
  return name.trim().replace(/^\/+/, "").toLowerCase();
96
96
  }
97
97
 
98
- export function isTelegramExtensionCommandName(name: string): boolean {
98
+ function isTelegramExtensionCommandName(name: string): boolean {
99
99
  return TELEGRAM_BOT_COMMAND_NAME_PATTERN.test(name);
100
100
  }
101
101
 
@@ -144,7 +144,7 @@ export function registerTelegramCommand(
144
144
  };
145
145
  }
146
146
 
147
- export function getTelegramExtensionCommands(): RegisteredTelegramExtensionCommand[] {
147
+ function getTelegramExtensionCommands(): RegisteredTelegramExtensionCommand[] {
148
148
  return Array.from(
149
149
  getOrCreateTelegramCommandRegistry().commands.values(),
150
150
  ).sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
@@ -181,7 +181,7 @@ export const TELEGRAM_COMMAND_EMOJI = {
181
181
 
182
182
  export type TelegramCommandEmojiName = keyof typeof TELEGRAM_COMMAND_EMOJI;
183
183
 
184
- export function getTelegramCommandEmoji(
184
+ function getTelegramCommandEmoji(
185
185
  command: TelegramCommandEmojiName,
186
186
  ): string {
187
187
  return TELEGRAM_COMMAND_EMOJI[command];
@@ -235,12 +235,12 @@ export function formatTelegramAutomaticThreadDisplayNameRestoredHeading(
235
235
  return `<b>✅ Automatic Thread display name restored as <i>${escapeHtml(name)}</i>.</b>`;
236
236
  }
237
237
 
238
- export const TELEGRAM_COMPACTION_STARTED_TEXT =
238
+ const TELEGRAM_COMPACTION_STARTED_TEXT =
239
239
  formatTelegramInformationHeading(
240
240
  getTelegramCommandEmoji("compact"),
241
241
  "Compaction started.",
242
242
  );
243
- export const TELEGRAM_COMPACTION_COMPLETED_TEXT =
243
+ const TELEGRAM_COMPACTION_COMPLETED_TEXT =
244
244
  formatTelegramInformationHeading("✅", "Compaction completed.");
245
245
  export const TELEGRAM_COMPACTION_STARTED_MARKDOWN =
246
246
  `**${formatTelegramCommandEmojiPrefix("compact")}Compaction started.**`;
@@ -254,7 +254,7 @@ function formatTelegramBotCommandDescription(
254
254
  return `${formatTelegramCommandEmojiPrefix(command)}${description}`;
255
255
  }
256
256
 
257
- export const TELEGRAM_BUILTIN_BOT_COMMANDS: readonly TelegramBotCommandDefinition[] =
257
+ const TELEGRAM_BUILTIN_BOT_COMMANDS: readonly TelegramBotCommandDefinition[] =
258
258
  [
259
259
  {
260
260
  command: "start",
@@ -399,14 +399,24 @@ export interface TelegramBridgeCommandRegistrationDeps {
399
399
  recoverPollingStart?: (
400
400
  error: unknown,
401
401
  ) => Promise<TelegramPollingStartRecoveryResult>;
402
+ recordConnectionEvent?: (error: unknown, phase: string) => void;
402
403
  getDisconnectThreadName?: () => string | undefined;
403
404
  queueAgentConnectionContext?: (connected: boolean) => void;
404
405
  updateStatus: (ctx: ExtensionCommandContext) => void;
406
+ isContextCurrent?: (ctx: ExtensionCommandContext) => boolean;
407
+ getSessionGeneration?: () => number;
408
+ connectionIntent?: {
409
+ begin(cwd: string, profileName?: string): string;
410
+ finish(id: string): void;
411
+ isActive(id: string): boolean;
412
+ cancel(): void;
413
+ };
405
414
  getProfileNames?: () => string[];
406
- activateDefaultProfileConfig?: (ctx: ExtensionCommandContext) => Promise<void>;
415
+ activateDefaultProfileConfig?: (ctx: ExtensionCommandContext, isCurrent: () => boolean) => Promise<void>;
407
416
  activateProfileConfig?: (
408
417
  ctx: ExtensionCommandContext,
409
418
  profileName: string,
419
+ isCurrent: () => boolean,
410
420
  ) => Promise<boolean>;
411
421
  }
412
422
 
@@ -504,6 +514,14 @@ export function registerTelegramBridgeCommands(
504
514
  pi.registerCommand("telegram-connect", {
505
515
  description: "<profile> — Start Telegram bridge",
506
516
  handler: async (args, ctx) => {
517
+ const sessionGeneration = deps.getSessionGeneration?.();
518
+ let intentId: string | undefined;
519
+ // Pi context getters throw after replacement; check plain intent/generation first.
520
+ const isCurrent = () =>
521
+ (!intentId || deps.connectionIntent?.isActive(intentId) !== false) &&
522
+ (sessionGeneration === undefined || deps.getSessionGeneration?.() === sessionGeneration) &&
523
+ deps.isContextCurrent?.(ctx) !== false;
524
+ if (!isCurrent()) return;
507
525
  if (args.trim().split(/\s+/).some((word) => /^as=/i.test(word))) {
508
526
  ctx.ui.notify(
509
527
  "Thread names are configured from Telegram, not from Pi commands.",
@@ -513,121 +531,141 @@ export function registerTelegramBridgeCommands(
513
531
  return;
514
532
  }
515
533
  const profileName = parseTelegramProfileArg(args);
516
- if (profileName && deps.activateProfileConfig) {
517
- const ok = await deps.activateProfileConfig(ctx, profileName);
518
- if (!ok) {
519
- ctx.ui.notify(`Profile "${profileName}" not found.`, "error");
520
- deps.updateStatus(ctx);
521
- return;
534
+ intentId = deps.connectionIntent?.begin(ctx.cwd, profileName);
535
+ try {
536
+ if (profileName && deps.activateProfileConfig) {
537
+ const ok = await deps.activateProfileConfig(ctx, profileName, isCurrent);
538
+ if (!isCurrent()) return;
539
+ if (!ok) {
540
+ ctx.ui.notify(`Profile "${profileName}" not found.`, "error");
541
+ deps.updateStatus(ctx);
542
+ return;
543
+ }
544
+ ctx.ui.notify(`Activated profile "${profileName}".`, "info");
545
+ } else {
546
+ await (deps.activateDefaultProfileConfig?.(ctx, isCurrent) ?? deps.reloadConfig());
547
+ if (!isCurrent()) return;
522
548
  }
523
- ctx.ui.notify(`Activated profile "${profileName}".`, "info");
524
- } else {
525
- await (deps.activateDefaultProfileConfig?.(ctx) ?? deps.reloadConfig());
526
- }
527
- if (!deps.hasBotToken()) {
528
- const botTokenDiagnostic = deps.getBotTokenDiagnostic?.();
529
- if (botTokenDiagnostic) ctx.ui.notify(botTokenDiagnostic, "error");
530
- const profileNames = deps.getProfileNames?.() ?? [];
531
- if (!profileName && profileNames.length > 0) {
532
- ctx.ui.notify(
533
- `No default Telegram profile configured. Available profiles: ${profileNames.join(", ")}. Use /telegram-connect <profileName> or /telegram-setup to create a default profile.`,
534
- "info",
535
- );
536
- deps.updateStatus(ctx);
549
+ if (!deps.hasBotToken()) {
550
+ const botTokenDiagnostic = deps.getBotTokenDiagnostic?.();
551
+ if (botTokenDiagnostic) ctx.ui.notify(botTokenDiagnostic, "error");
552
+ const profileNames = deps.getProfileNames?.() ?? [];
553
+ if (!profileName && profileNames.length > 0) {
554
+ ctx.ui.notify(
555
+ `No default Telegram profile configured. Available profiles: ${profileNames.join(", ")}. Use /telegram-connect <profileName> or /telegram-setup to create a default profile.`,
556
+ "info",
557
+ );
558
+ deps.updateStatus(ctx);
559
+ return;
560
+ }
561
+ await deps.promptForConfig(ctx, profileName);
537
562
  return;
538
563
  }
539
- await deps.promptForConfig(ctx, profileName);
540
- return;
541
- }
542
- let recoveryUsed = false;
543
- const startWithRecovery = async (
544
- options: TelegramBridgeCommandStartPollingOptions,
545
- ): Promise<void | TelegramBridgeCommandStartPollingResult> => {
546
- try {
547
- return await deps.startPolling(ctx, options);
548
- } catch (error) {
549
- if (!deps.recoverPollingStart || recoveryUsed) throw error;
550
- const recovery = await deps.recoverPollingStart(error);
551
- if (recovery.kind === "unhandled") throw error;
552
- if (recovery.kind === "blocked") {
553
- return { ok: false, message: recovery.message };
554
- }
555
- recoveryUsed = true;
564
+ let recoveryUsed = false;
565
+ const startWithRecovery = async (
566
+ options: TelegramBridgeCommandStartPollingOptions,
567
+ ): Promise<void | (TelegramBridgeCommandStartPollingResult & { notice?: string })> => {
556
568
  try {
557
- const retry = await deps.startPolling(ctx, options);
558
- if (!retry) {
559
- return { ok: true, message: recovery.message };
569
+ return await deps.startPolling(ctx, options);
570
+ } catch (error) {
571
+ if (!isCurrent()) return;
572
+ if (!deps.recoverPollingStart || recoveryUsed) throw error;
573
+ deps.recordConnectionEvent?.(error, "polling-start");
574
+ const recovery = await deps.recoverPollingStart(error);
575
+ if (!isCurrent()) return;
576
+ if (recovery.kind === "unhandled") throw error;
577
+ if (recovery.kind === "blocked") {
578
+ return { ok: false, message: recovery.message,
579
+ notice: "Telegram recovery blocked. Check /telegram-status --debug." };
580
+ }
581
+ recoveryUsed = true;
582
+ deps.recordConnectionEvent?.(recovery.message, "recovery");
583
+ try {
584
+ const retry = await deps.startPolling(ctx, options);
585
+ if (!isCurrent()) return;
586
+ if (!retry) return { ok: true, message: "Telegram bridge connected; temporary state recovered." };
587
+ return {
588
+ ...retry,
589
+ message: retry.ok
590
+ ? "Telegram bridge connected; temporary state recovered."
591
+ : retry.message,
592
+ };
593
+ } catch (error) {
594
+ if (!isCurrent()) return;
595
+ deps.recordConnectionEvent?.(error, "recovery-retry");
596
+ return {
597
+ ok: false,
598
+ notice: "Telegram recovery failed. Restart this Pi instance.",
599
+ };
560
600
  }
561
- return {
562
- ...retry,
563
- message: retry.ok
564
- ? `${recovery.message} ${retry.message ?? "Telegram bridge connected."}`
565
- : retry.message,
566
- };
567
- } catch {
568
- return {
569
- ok: false,
570
- message:
571
- "Telegram temporary state was recovered, but the bridge could not restart. Restart this Pi instance and run /telegram-connect again.",
572
- };
573
601
  }
602
+ };
603
+ let result = await startWithRecovery({ forceFreshLeaderThread: true });
604
+ if (!isCurrent()) return;
605
+ if (result && !result.ok && result.canTakeover) {
606
+ const confirmed = await ctx.ui.confirm(
607
+ formatTelegramTakeoverTitle(ctx),
608
+ formatTelegramTakeoverPrompt(ctx, result.owner),
609
+ );
610
+ if (!isCurrent()) return;
611
+ if (!confirmed) {
612
+ ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
613
+ deps.updateStatus(ctx);
614
+ return;
615
+ }
616
+ result = await startWithRecovery({ force: true, forceFreshLeaderThread: true });
617
+ if (!isCurrent()) return;
574
618
  }
575
- };
576
- let result = await startWithRecovery({
577
- forceFreshLeaderThread: true,
578
- });
579
- if (result && !result.ok && result.canTakeover) {
580
- const confirmed = await ctx.ui.confirm(
581
- formatTelegramTakeoverTitle(ctx),
582
- formatTelegramTakeoverPrompt(ctx, result.owner),
583
- );
584
- if (!confirmed) {
585
- ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
586
- deps.updateStatus(ctx);
587
- return;
619
+ if (result && !result.ok) {
620
+ if (result.message) deps.recordConnectionEvent?.(result.message, "connect-refused");
621
+ ctx.ui.notify(result.notice ?? formatTelegramConnectionFailure(result.message), "warning");
622
+ } else if (result?.message) {
623
+ ctx.ui.notify(result.message, "info");
588
624
  }
589
- result = await startWithRecovery({
590
- force: true,
591
- forceFreshLeaderThread: true,
592
- });
593
- }
594
- if (result?.message) {
595
- ctx.ui.notify(result.message, result.ok ? "info" : "warning");
596
- }
597
- if (!result || result.ok) {
598
- deps.queueAgentConnectionContext?.(true);
625
+ if (!result || result.ok) deps.queueAgentConnectionContext?.(true);
626
+ deps.updateStatus(ctx);
627
+ } catch (error) {
628
+ if (!isCurrent()) return;
629
+ deps.recordConnectionEvent?.(error, "connect");
630
+ ctx.ui.notify(formatTelegramConnectionFailure(error), "warning");
631
+ deps.updateStatus(ctx);
632
+ } finally {
633
+ if (intentId) deps.connectionIntent?.finish(intentId);
599
634
  }
600
- deps.updateStatus(ctx);
601
635
  },
602
636
  });
603
637
  pi.registerCommand("telegram-disconnect", {
604
638
  description: "Stop Telegram and delete current thread in Threaded Mode",
605
639
  handler: async (_args, ctx) => {
606
- const threadName = deps.getDisconnectThreadName?.();
607
- if (threadName) {
608
- const confirmed = await ctx.ui.confirm(
609
- ctx.ui.theme.fg("accent", "pi-telegram"),
610
- `Delete Telegram thread ${ctx.ui.theme.fg("warning", threadName)} and disconnect this Pi session?`,
611
- );
612
- if (!confirmed) {
613
- ctx.ui.notify("Telegram disconnect cancelled.", "info");
614
- deps.updateStatus(ctx);
615
- return;
616
- }
617
- }
640
+ const generation = deps.getSessionGeneration?.();
641
+ const isCurrent = () =>
642
+ (generation === undefined || deps.getSessionGeneration?.() === generation) &&
643
+ deps.isContextCurrent?.(ctx) !== false;
644
+ if (!isCurrent()) return;
645
+ deps.connectionIntent?.cancel();
618
646
  try {
647
+ const threadName = deps.getDisconnectThreadName?.();
648
+ if (threadName) {
649
+ const confirmed = await ctx.ui.confirm(
650
+ ctx.ui.theme.fg("accent", "pi-telegram"),
651
+ `Delete Telegram thread ${ctx.ui.theme.fg("warning", threadName)} and disconnect this Pi session?`,
652
+ );
653
+ if (!isCurrent()) return;
654
+ if (!confirmed) {
655
+ ctx.ui.notify("Telegram disconnect cancelled.", "info");
656
+ return;
657
+ }
658
+ }
619
659
  const message = await deps.stopPolling();
660
+ if (!isCurrent()) return;
620
661
  if (message) ctx.ui.notify(message, "info");
621
662
  deps.queueAgentConnectionContext?.(false);
622
663
  } catch (error) {
623
- const detail = error instanceof Error ? error.message : String(error);
624
- ctx.ui.notify(
625
- `Telegram disconnect did not complete: ${detail} Keep this Pi session open, restore leader connectivity, inspect /telegram-status --debug, and retry /telegram-disconnect.`,
626
- "warning",
627
- );
628
- throw error;
664
+ deps.recordConnectionEvent?.(error, "disconnect");
665
+ if (!isCurrent()) return;
666
+ ctx.ui.notify("Telegram disconnect incomplete; keep Pi open. Check /telegram-status --debug.", "warning");
629
667
  } finally {
630
- deps.updateStatus(ctx);
668
+ if (isCurrent()) deps.updateStatus(ctx);
631
669
  }
632
670
  },
633
671
  });
@@ -1466,7 +1504,7 @@ export async function handleTelegramNextCommand(deps: {
1466
1504
  deps.updateStatus();
1467
1505
  }
1468
1506
 
1469
- export async function handleTelegramContinueCommand<TMessage, TContext>(
1507
+ async function handleTelegramContinueCommand<TMessage, TContext>(
1470
1508
  message: TMessage,
1471
1509
  ctx: TContext,
1472
1510
  deps: {
@@ -1492,7 +1530,7 @@ function dispatchNextQueuedTelegramTurnAfterCompact(
1492
1530
  deps.dispatchNextQueuedTelegramTurn();
1493
1531
  }
1494
1532
 
1495
- export function buildTelegramNewConfirmationReplyMarkup(): TelegramCompactConfirmationReplyMarkup {
1533
+ function buildTelegramNewConfirmationReplyMarkup(): TelegramCompactConfirmationReplyMarkup {
1496
1534
  return {
1497
1535
  inline_keyboard: [
1498
1536
  [
@@ -1503,7 +1541,7 @@ export function buildTelegramNewConfirmationReplyMarkup(): TelegramCompactConfir
1503
1541
  };
1504
1542
  }
1505
1543
 
1506
- export function getTelegramNewConfirmationHtml(): string {
1544
+ function getTelegramNewConfirmationHtml(): string {
1507
1545
  return "<b>Start a new session?</b>";
1508
1546
  }
1509
1547
 
@@ -1550,7 +1588,7 @@ export async function handleTelegramNewConfirmationCallback<TContext>(
1550
1588
  return true;
1551
1589
  }
1552
1590
 
1553
- export function buildTelegramCompactConfirmationReplyMarkup(): TelegramCompactConfirmationReplyMarkup {
1591
+ function buildTelegramCompactConfirmationReplyMarkup(): TelegramCompactConfirmationReplyMarkup {
1554
1592
  return {
1555
1593
  inline_keyboard: [
1556
1594
  [
@@ -1561,11 +1599,11 @@ export function buildTelegramCompactConfirmationReplyMarkup(): TelegramCompactCo
1561
1599
  };
1562
1600
  }
1563
1601
 
1564
- export function getTelegramCompactConfirmationHtml(): string {
1602
+ function getTelegramCompactConfirmationHtml(): string {
1565
1603
  return "<b>Compact session?</b>";
1566
1604
  }
1567
1605
 
1568
- export async function openTelegramCompactConfirmation(
1606
+ async function openTelegramCompactConfirmation(
1569
1607
  target: TelegramCommandMessageTarget,
1570
1608
  deps: TelegramCompactConfirmationDeps,
1571
1609
  ): Promise<void> {
@@ -2335,7 +2373,7 @@ export const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION =
2335
2373
  export const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE =
2336
2374
  "This internal Telegram command cannot be run manually.";
2337
2375
 
2338
- export function delayTelegramSessionAction(delayMs: number): Promise<void> {
2376
+ function delayTelegramSessionAction(delayMs: number): Promise<void> {
2339
2377
  return new Promise<void>((resolve) => setTimeout(resolve, delayMs));
2340
2378
  }
2341
2379
 
@@ -2395,7 +2433,7 @@ export async function settleTelegramSessionReplacement(
2395
2433
  return "stale";
2396
2434
  }
2397
2435
 
2398
- export function createTelegramSessionReplacementSettlementRuntime<TContext>(deps: {
2436
+ function createTelegramSessionReplacementSettlementRuntime<TContext>(deps: {
2399
2437
  resolve: (ctx: TContext) => TelegramSessionReplacementSettlementDeps | undefined;
2400
2438
  onResult?: (result: "none" | "settled" | "expired" | "failed" | "stale") => void;
2401
2439
  onError?: (error: unknown) => void;