@llblab/pi-telegram 0.51.4 → 0.51.6

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 (47) hide show
  1. package/AGENTS.md +1 -1
  2. package/BACKLOG.md +1 -0
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +1 -1
  5. package/dist/lib/bindings.d.ts +4 -1
  6. package/dist/lib/bindings.js +16 -12
  7. package/dist/lib/bus-follower.d.ts +3 -1
  8. package/dist/lib/bus-follower.js +62 -3
  9. package/dist/lib/bus-leader.d.ts +21 -0
  10. package/dist/lib/bus-leader.js +144 -1
  11. package/dist/lib/bus.d.ts +9 -0
  12. package/dist/lib/bus.js +19 -0
  13. package/dist/lib/commands.d.ts +25 -3
  14. package/dist/lib/commands.js +166 -89
  15. package/dist/lib/extension.js +42 -0
  16. package/dist/lib/lifecycle.d.ts +57 -2
  17. package/dist/lib/lifecycle.js +109 -4
  18. package/dist/lib/locks.js +5 -1
  19. package/dist/lib/model.js +2 -4
  20. package/dist/lib/polling.js +6 -2
  21. package/dist/lib/status.d.ts +3 -1
  22. package/dist/lib/status.js +31 -1
  23. package/dist/lib/sync.js +4 -4
  24. package/dist/lib/threads.d.ts +8 -0
  25. package/dist/lib/threads.js +30 -9
  26. package/dist/lib/workspace-retirement.d.ts +2 -2
  27. package/dist/lib/workspace-retirement.js +48 -12
  28. package/dist/package.json +1 -1
  29. package/docs/architecture.md +6 -5
  30. package/docs/multi-instance-bus.md +4 -2
  31. package/docs/public-api.md +3 -3
  32. package/docs/ui-style.md +4 -0
  33. package/lib/bindings.ts +17 -10
  34. package/lib/bus-follower.ts +87 -3
  35. package/lib/bus-leader.ts +197 -0
  36. package/lib/bus.ts +33 -0
  37. package/lib/commands.ts +173 -103
  38. package/lib/extension.ts +40 -0
  39. package/lib/lifecycle.ts +140 -4
  40. package/lib/locks.ts +6 -1
  41. package/lib/model.ts +2 -4
  42. package/lib/polling.ts +10 -2
  43. package/lib/status.ts +30 -1
  44. package/lib/sync.ts +4 -4
  45. package/lib/threads.ts +40 -8
  46. package/lib/workspace-retirement.ts +46 -13
  47. package/package.json +1 -1
@@ -6,6 +6,7 @@
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { pairTelegramUserIfNeeded, TELEGRAM_DEFAULT_PROFILE_NAME, } from "./config.js";
8
8
  import { escapeHtml } from "./rendering.js";
9
+ import { formatTelegramConnectionFailure } from "./status.js";
9
10
  import { createTelegramControlItemBuilder, createTelegramControlQueueController, createTelegramQueueAdmissionReceipt, } from "./queue.js";
10
11
  const TELEGRAM_EXTENSION_COMMAND_REGISTRY_KEY = "__piTelegramCommandRegistry__";
11
12
  const TELEGRAM_BOT_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
@@ -270,121 +271,174 @@ export function registerTelegramBridgeCommands(pi, deps) {
270
271
  pi.registerCommand("telegram-connect", {
271
272
  description: "<profile> — Start Telegram bridge",
272
273
  handler: async (args, ctx) => {
274
+ const sessionGeneration = deps.getSessionGeneration?.();
275
+ let intentId;
276
+ // Pi context getters throw after replacement; check plain intent/generation first.
277
+ const isCurrent = () => (!intentId || deps.connectionIntent?.isActive(intentId) !== false) &&
278
+ (sessionGeneration === undefined || deps.getSessionGeneration?.() === sessionGeneration) &&
279
+ deps.isContextCurrent?.(ctx) !== false;
280
+ if (!isCurrent())
281
+ return;
273
282
  if (args.trim().split(/\s+/).some((word) => /^as=/i.test(word))) {
274
283
  ctx.ui.notify("Thread names are configured from Telegram, not from Pi commands.", "warning");
275
284
  deps.updateStatus(ctx);
276
285
  return;
277
286
  }
278
287
  const profileName = parseTelegramProfileArg(args);
279
- if (profileName && deps.activateProfileConfig) {
280
- const ok = await deps.activateProfileConfig(ctx, profileName);
281
- if (!ok) {
282
- ctx.ui.notify(`Profile "${profileName}" not found.`, "error");
283
- deps.updateStatus(ctx);
284
- return;
285
- }
286
- ctx.ui.notify(`Activated profile "${profileName}".`, "info");
287
- }
288
- else {
289
- await (deps.activateDefaultProfileConfig?.(ctx) ?? deps.reloadConfig());
290
- }
291
- if (!deps.hasBotToken()) {
292
- const botTokenDiagnostic = deps.getBotTokenDiagnostic?.();
293
- if (botTokenDiagnostic)
294
- ctx.ui.notify(botTokenDiagnostic, "error");
295
- const profileNames = deps.getProfileNames?.() ?? [];
296
- if (!profileName && profileNames.length > 0) {
297
- ctx.ui.notify(`No default Telegram profile configured. Available profiles: ${profileNames.join(", ")}. Use /telegram-connect <profileName> or /telegram-setup to create a default profile.`, "info");
298
- deps.updateStatus(ctx);
299
- return;
288
+ intentId = deps.connectionIntent?.begin(ctx.cwd, profileName);
289
+ try {
290
+ if (profileName && deps.activateProfileConfig) {
291
+ const ok = await deps.activateProfileConfig(ctx, profileName, isCurrent);
292
+ if (!isCurrent())
293
+ return;
294
+ if (!ok) {
295
+ ctx.ui.notify(`Profile "${profileName}" not found.`, "error");
296
+ deps.updateStatus(ctx);
297
+ return;
298
+ }
299
+ ctx.ui.notify(`Activated profile "${profileName}".`, "info");
300
300
  }
301
- await deps.promptForConfig(ctx, profileName);
302
- return;
303
- }
304
- let recoveryUsed = false;
305
- const startWithRecovery = async (options) => {
306
- try {
307
- return await deps.startPolling(ctx, options);
301
+ else {
302
+ await (deps.activateDefaultProfileConfig?.(ctx, isCurrent) ?? deps.reloadConfig());
303
+ if (!isCurrent())
304
+ return;
308
305
  }
309
- catch (error) {
310
- if (!deps.recoverPollingStart || recoveryUsed)
311
- throw error;
312
- const recovery = await deps.recoverPollingStart(error);
313
- if (recovery.kind === "unhandled")
314
- throw error;
315
- if (recovery.kind === "blocked") {
316
- return { ok: false, message: recovery.message };
306
+ if (!deps.hasBotToken()) {
307
+ const botTokenDiagnostic = deps.getBotTokenDiagnostic?.();
308
+ if (botTokenDiagnostic)
309
+ ctx.ui.notify(botTokenDiagnostic, "error");
310
+ const profileNames = deps.getProfileNames?.() ?? [];
311
+ if (!profileName && profileNames.length > 0) {
312
+ ctx.ui.notify(`No default Telegram profile configured. Available profiles: ${profileNames.join(", ")}. Use /telegram-connect <profileName> or /telegram-setup to create a default profile.`, "info");
313
+ deps.updateStatus(ctx);
314
+ return;
317
315
  }
318
- recoveryUsed = true;
316
+ await deps.promptForConfig(ctx, profileName);
317
+ return;
318
+ }
319
+ let recoveryUsed = false;
320
+ const startWithRecovery = async (options) => {
319
321
  try {
320
- const retry = await deps.startPolling(ctx, options);
321
- if (!retry) {
322
- return { ok: true, message: recovery.message };
322
+ return await deps.startPolling(ctx, options);
323
+ }
324
+ catch (error) {
325
+ if (!isCurrent())
326
+ return;
327
+ if (!deps.recoverPollingStart || recoveryUsed)
328
+ throw error;
329
+ deps.recordConnectionEvent?.(error, "polling-start");
330
+ const recovery = await deps.recoverPollingStart(error);
331
+ if (!isCurrent())
332
+ return;
333
+ if (recovery.kind === "unhandled")
334
+ throw error;
335
+ if (recovery.kind === "blocked") {
336
+ return { ok: false, message: recovery.message,
337
+ notice: "Telegram recovery blocked. Check /telegram-status --debug." };
338
+ }
339
+ recoveryUsed = true;
340
+ deps.recordConnectionEvent?.(recovery.message, "recovery");
341
+ try {
342
+ const retry = await deps.startPolling(ctx, options);
343
+ if (!isCurrent())
344
+ return;
345
+ if (!retry)
346
+ return { ok: true, message: "Telegram bridge connected; temporary state recovered." };
347
+ return {
348
+ ...retry,
349
+ message: retry.ok
350
+ ? "Telegram bridge connected; temporary state recovered."
351
+ : retry.message,
352
+ };
353
+ }
354
+ catch (error) {
355
+ if (!isCurrent())
356
+ return;
357
+ deps.recordConnectionEvent?.(error, "recovery-retry");
358
+ return {
359
+ ok: false,
360
+ notice: "Telegram recovery failed. Restart this Pi instance.",
361
+ };
323
362
  }
324
- return {
325
- ...retry,
326
- message: retry.ok
327
- ? `${recovery.message} ${retry.message ?? "Telegram bridge connected."}`
328
- : retry.message,
329
- };
330
363
  }
331
- catch {
332
- return {
333
- ok: false,
334
- message: "Telegram temporary state was recovered, but the bridge could not restart. Restart this Pi instance and run /telegram-connect again.",
335
- };
364
+ };
365
+ let result = await startWithRecovery({ forceFreshLeaderThread: true });
366
+ if (!isCurrent())
367
+ return;
368
+ if (result && !result.ok && result.canTakeover) {
369
+ const confirmed = await ctx.ui.confirm(formatTelegramTakeoverTitle(ctx), formatTelegramTakeoverPrompt(ctx, result.owner));
370
+ if (!isCurrent())
371
+ return;
372
+ if (!confirmed) {
373
+ ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
374
+ deps.updateStatus(ctx);
375
+ return;
336
376
  }
377
+ result = await startWithRecovery({ force: true, forceFreshLeaderThread: true });
378
+ if (!isCurrent())
379
+ return;
337
380
  }
338
- };
339
- let result = await startWithRecovery({
340
- forceFreshLeaderThread: true,
341
- });
342
- if (result && !result.ok && result.canTakeover) {
343
- const confirmed = await ctx.ui.confirm(formatTelegramTakeoverTitle(ctx), formatTelegramTakeoverPrompt(ctx, result.owner));
344
- if (!confirmed) {
345
- ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
346
- deps.updateStatus(ctx);
347
- return;
381
+ if (result && !result.ok) {
382
+ if (result.message)
383
+ deps.recordConnectionEvent?.(result.message, "connect-refused");
384
+ ctx.ui.notify(result.notice ?? formatTelegramConnectionFailure(result.message), "warning");
348
385
  }
349
- result = await startWithRecovery({
350
- force: true,
351
- forceFreshLeaderThread: true,
352
- });
386
+ else if (result?.message) {
387
+ ctx.ui.notify(result.message, "info");
388
+ }
389
+ if (!result || result.ok)
390
+ deps.queueAgentConnectionContext?.(true);
391
+ deps.updateStatus(ctx);
353
392
  }
354
- if (result?.message) {
355
- ctx.ui.notify(result.message, result.ok ? "info" : "warning");
393
+ catch (error) {
394
+ if (!isCurrent())
395
+ return;
396
+ deps.recordConnectionEvent?.(error, "connect");
397
+ ctx.ui.notify(formatTelegramConnectionFailure(error), "warning");
398
+ deps.updateStatus(ctx);
356
399
  }
357
- if (!result || result.ok) {
358
- deps.queueAgentConnectionContext?.(true);
400
+ finally {
401
+ if (intentId)
402
+ deps.connectionIntent?.finish(intentId);
359
403
  }
360
- deps.updateStatus(ctx);
361
404
  },
362
405
  });
363
406
  pi.registerCommand("telegram-disconnect", {
364
407
  description: "Stop Telegram and delete current thread in Threaded Mode",
365
408
  handler: async (_args, ctx) => {
366
- const threadName = deps.getDisconnectThreadName?.();
367
- if (threadName) {
368
- const confirmed = await ctx.ui.confirm(ctx.ui.theme.fg("accent", "pi-telegram"), `Delete Telegram thread ${ctx.ui.theme.fg("warning", threadName)} and disconnect this Pi session?`);
369
- if (!confirmed) {
370
- ctx.ui.notify("Telegram disconnect cancelled.", "info");
371
- deps.updateStatus(ctx);
372
- return;
373
- }
374
- }
409
+ const generation = deps.getSessionGeneration?.();
410
+ const isCurrent = () => (generation === undefined || deps.getSessionGeneration?.() === generation) &&
411
+ deps.isContextCurrent?.(ctx) !== false;
412
+ if (!isCurrent())
413
+ return;
414
+ deps.connectionIntent?.cancel();
375
415
  try {
416
+ const threadName = deps.getDisconnectThreadName?.();
417
+ if (threadName) {
418
+ const confirmed = await ctx.ui.confirm(ctx.ui.theme.fg("accent", "pi-telegram"), `Delete Telegram thread ${ctx.ui.theme.fg("warning", threadName)} and disconnect this Pi session?`);
419
+ if (!isCurrent())
420
+ return;
421
+ if (!confirmed) {
422
+ ctx.ui.notify("Telegram disconnect cancelled.", "info");
423
+ return;
424
+ }
425
+ }
376
426
  const message = await deps.stopPolling();
427
+ if (!isCurrent())
428
+ return;
377
429
  if (message)
378
430
  ctx.ui.notify(message, "info");
379
431
  deps.queueAgentConnectionContext?.(false);
380
432
  }
381
433
  catch (error) {
382
- const detail = error instanceof Error ? error.message : String(error);
383
- ctx.ui.notify(`Telegram disconnect did not complete: ${detail} Keep this Pi session open, restore leader connectivity, inspect /telegram-status --debug, and retry /telegram-disconnect.`, "warning");
384
- throw error;
434
+ deps.recordConnectionEvent?.(error, "disconnect");
435
+ if (!isCurrent())
436
+ return;
437
+ ctx.ui.notify("Telegram disconnect incomplete; keep Pi open. Check /telegram-status --debug.", "warning");
385
438
  }
386
439
  finally {
387
- deps.updateStatus(ctx);
440
+ if (isCurrent())
441
+ deps.updateStatus(ctx);
388
442
  }
389
443
  },
390
444
  });
@@ -1294,7 +1348,15 @@ export function createTelegramSessionActionAssembly(deps) {
1294
1348
  sendUserMessage: deps.sendUserMessage,
1295
1349
  notifyResult(target, result) { return sendTerminalResult(target, result); },
1296
1350
  async prepareReplacement(ctx, updateId, target) {
1297
- await deps.store.load();
1351
+ const follower = !deps.ownsPersistence() && typeof target.threadId === "number" &&
1352
+ deps.follower?.isRegisteredFor(target)
1353
+ ? deps.follower
1354
+ : undefined;
1355
+ // Follower memory is not authority; reread the leader-published snapshot.
1356
+ if (follower && deps.store.refresh)
1357
+ await deps.store.refresh();
1358
+ else
1359
+ await deps.store.load();
1298
1360
  const sessionId = ctx.sessionManager.getSessionId();
1299
1361
  const binding = typeof target.threadId === "number"
1300
1362
  ? deps.store.getWorkspaceBindingByTarget(target)
@@ -1304,7 +1366,7 @@ export function createTelegramSessionActionAssembly(deps) {
1304
1366
  throw new Error("Telegram session replacement binding is unavailable.");
1305
1367
  }
1306
1368
  const createdAtMs = now();
1307
- if (!await deps.store.commitSessionReplacementIntent({
1369
+ const intent = {
1308
1370
  continuity: binding ? "workspace-thread" : "classic-chat",
1309
1371
  cwd: binding?.cwd ?? ctx.cwd,
1310
1372
  profileName: deps.getProfileName() ?? "default",
@@ -1317,7 +1379,11 @@ export function createTelegramSessionActionAssembly(deps) {
1317
1379
  ? { threadName: binding.manualThreadName ?? binding.threadName } : {}),
1318
1380
  createdAtMs,
1319
1381
  expiresAtMs: createdAtMs + deps.handoffTtlMs,
1320
- }, deps.ownsPersistence)) {
1382
+ ...(follower ? { sourceInstanceId: follower.instanceId } : {}),
1383
+ };
1384
+ if (!await (follower
1385
+ ? follower.requestSessionReplacement("publish", intent)
1386
+ : deps.store.commitSessionReplacementIntent(intent, deps.ownsPersistence))) {
1321
1387
  throw new Error("Telegram session replacement intent was not persisted.");
1322
1388
  }
1323
1389
  },
@@ -1331,11 +1397,22 @@ export function createTelegramSessionActionAssembly(deps) {
1331
1397
  return {
1332
1398
  async getIntent() { await deps.store.refresh?.(); return deps.store.getSessionReplacementIntent(); },
1333
1399
  hasSuccessorContinuity(intent) {
1334
- return intent.continuity === "classic-chat" ||
1335
- deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd === intent.cwd;
1400
+ if (intent.continuity === "classic-chat")
1401
+ return true;
1402
+ if (deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd !==
1403
+ intent.cwd)
1404
+ return false;
1405
+ // A follower successor claims only after its own re-registration is live.
1406
+ return intent.sourceInstanceId === undefined || deps.ownsPersistence() ||
1407
+ deps.follower?.isRegisteredFor(intent.target) === true;
1336
1408
  },
1337
1409
  editSuccess(intent) { return deps.sendResult(intent.target, "<b>🆕 New session started.</b>"); },
1338
- clearIntent(intent) { return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence); },
1410
+ async clearIntent(intent) {
1411
+ if (intent.sourceInstanceId === undefined || deps.ownsPersistence()) {
1412
+ return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence);
1413
+ }
1414
+ return await deps.follower?.requestSessionReplacement("settle", intent) ?? false;
1415
+ },
1339
1416
  profileName: deps.getProfileName() ?? "default",
1340
1417
  cwd: ctx.cwd,
1341
1418
  sessionId,
@@ -60,6 +60,7 @@ const telegramBusProtocolIdentity = Bus.createTelegramCurrentBusProtocolIdentity
60
60
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
61
61
  Bus.TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
62
62
  Bus.TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
63
+ Bus.TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
63
64
  ]);
64
65
  // --- Extension Runtime ---
65
66
  export default function (pi) {
@@ -191,6 +192,21 @@ export default function (pi) {
191
192
  store: threadStore,
192
193
  getProfileName: configStore.getActiveProfileName,
193
194
  ownsPersistence: lockRuntime.owns,
195
+ follower: {
196
+ instanceId: telegramInstanceId,
197
+ isRegisteredFor(target) {
198
+ const registered = telegramBusFollowerRegistrationState.getTarget();
199
+ return telegramBusFollowerRegistrationState.isRegistered() &&
200
+ registered?.chatId === target.chatId &&
201
+ registered.threadId === target.threadId;
202
+ },
203
+ async requestSessionReplacement(operation, intent) {
204
+ const request = telegramBusFollowerRegistration.requestSessionReplacement;
205
+ if (!request)
206
+ return false;
207
+ return await request(operation, intent);
208
+ },
209
+ },
194
210
  async sendResult(target, html) {
195
211
  const delivery = await Delivery.sendTelegramView({ text: html, parseMode: "html", replyMarkup: { inline_keyboard: [] } }, { scope: { kind: "target", target } });
196
212
  return delivery.ok ? { ok: true } : { ok: false,
@@ -1345,6 +1361,28 @@ export default function (pi) {
1345
1361
  resolveAutomaticThreadCleanupEnabled: configControls.resolveAutomaticThreadCleanupEnabled,
1346
1362
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1347
1363
  });
1364
+ const connectionIntent = Lifecycle.createTelegramConnectionIntentRuntime();
1365
+ const connectionLifecycle = Lifecycle.createTelegramConnectionLifecycle({
1366
+ intent: connectionIntent,
1367
+ getGeneration: telegramSessionContextStore.getGeneration,
1368
+ isCurrent: telegramSessionContextStore.isCurrent,
1369
+ getProfileName: configStore.getActiveProfileName,
1370
+ isConnected() {
1371
+ return lockRuntime.owns() || telegramBusFollowerRegistrationState.isRegistered();
1372
+ },
1373
+ async activateProfile(profileName, isCurrent) {
1374
+ await configStore.load();
1375
+ if (!isCurrent())
1376
+ return false;
1377
+ return configStore.activateProfile(profileName) && configStore.hasBotToken();
1378
+ },
1379
+ start(ctx) {
1380
+ return lockedPollingRuntime.start(ctx);
1381
+ },
1382
+ recordError(error) {
1383
+ recordRuntimeEvent("connection", error, { phase: "resume-connect" });
1384
+ },
1385
+ });
1348
1386
  const telegramBridgeSessionLifecycleDeps = Lifecycle.createTelegramBridgeSessionLifecycleDeps({
1349
1387
  contextStore: telegramSessionContextStore,
1350
1388
  queue: {
@@ -1392,6 +1430,7 @@ export default function (pi) {
1392
1430
  },
1393
1431
  delivery: deliveryLifecycleRuntime,
1394
1432
  polling: lockedPollingRuntime,
1433
+ connection: connectionLifecycle,
1395
1434
  inboundWorker: {
1396
1435
  onSessionShutdown: updateAdmissionRuntimeBinding.onSessionShutdown,
1397
1436
  },
@@ -1520,6 +1559,9 @@ export default function (pi) {
1520
1559
  modelContextAvailabilityRuntime.reconcile();
1521
1560
  },
1522
1561
  getStatusLines,
1562
+ isContextCurrent: telegramSessionContextStore.isCurrent,
1563
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
1564
+ connectionIntent,
1523
1565
  buttonActionStore,
1524
1566
  sendMarkdownReply,
1525
1567
  async sendChannelMarkdownMessage(channel, markdown, options) {
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Telegram lifecycle hook registration helpers
2
+ * Telegram session lifecycle coordination and hook registration
3
3
  * Zones: pi agent lifecycle, telegram session
4
- * Binds prepared Telegram lifecycle runtimes to pi extension lifecycle events
4
+ * Owns context generations, bounded connect intent across resume, and session sequencing.
5
+ * Transport authority, durable bindings, and queue custody remain with their owners.
5
6
  */
6
7
  import * as BusFollower from "./bus-follower.ts";
7
8
  import * as Queue from "./queue.ts";
@@ -50,6 +51,58 @@ export interface TelegramSessionLifecycleHooks {
50
51
  onSessionStart: (event: SessionStartEvent, ctx: ExtensionContext) => Promise<void>;
51
52
  onSessionShutdown: (event: SessionShutdownEvent, ctx: ExtensionContext) => Promise<void>;
52
53
  }
54
+ export interface TelegramConnectionIntent {
55
+ id: string;
56
+ cwd: string;
57
+ profileName?: string;
58
+ }
59
+ interface TelegramConnectionHandoff extends TelegramConnectionIntent {
60
+ pid: number;
61
+ targetSessionFile: string;
62
+ expiresAtMs: number;
63
+ }
64
+ export interface TelegramConnectionHandoffStore {
65
+ pending?: TelegramConnectionHandoff;
66
+ }
67
+ export interface TelegramConnectionLifecycle {
68
+ prepare(event: SessionStartEvent, ctx: ExtensionContext): (() => void) | undefined;
69
+ onSessionShutdown(event: SessionShutdownEvent, ctx: ExtensionContext): void;
70
+ }
71
+ export declare function createTelegramConnectionLifecycle(deps: {
72
+ intent: ReturnType<typeof createTelegramConnectionIntentRuntime>;
73
+ getGeneration(): number;
74
+ isCurrent(ctx: ExtensionContext): boolean;
75
+ getProfileName(): string | undefined;
76
+ isConnected(): boolean;
77
+ activateProfile(profileName: string | undefined, isCurrent: () => boolean): Promise<boolean>;
78
+ start(ctx: ExtensionContext): Promise<{
79
+ ok: boolean;
80
+ message?: string;
81
+ }>;
82
+ recordError(error: unknown): void;
83
+ }): TelegramConnectionLifecycle;
84
+ export declare function createTelegramConnectionIntentRuntime(options?: {
85
+ store?: TelegramConnectionHandoffStore;
86
+ now?: () => number;
87
+ pid?: number;
88
+ }): {
89
+ begin(cwd: string, profileName?: string): string;
90
+ isActive(id: string): boolean;
91
+ finish(id: string): void;
92
+ cancel(): void;
93
+ suspend(input: {
94
+ reason: string;
95
+ cwd: string;
96
+ targetSessionFile?: string;
97
+ connected: boolean;
98
+ profileName?: string;
99
+ }): void;
100
+ resume(input: {
101
+ reason: string;
102
+ cwd: string;
103
+ sessionFile?: string;
104
+ }): TelegramConnectionIntent | undefined;
105
+ };
53
106
  export interface TelegramSessionContextStore<TContext> {
54
107
  get: () => TContext | undefined;
55
108
  getGeneration: () => number;
@@ -62,6 +115,7 @@ export declare function createTelegramSessionContextStore<TContext>(options?: {
62
115
  }): TelegramSessionContextStore<TContext>;
63
116
  export declare function createTelegramSessionGenerationFence(store: TelegramSessionContextStore<ExtensionContext>, hooks: TelegramSessionLifecycleHooks): TelegramSessionLifecycleHooks;
64
117
  export interface TelegramBridgeSessionServiceRuntime {
118
+ connection?: TelegramConnectionLifecycle;
65
119
  resumeGroupedInput(ctx: ExtensionContext): void;
66
120
  suspendGroupedInput(): void;
67
121
  delivery: {
@@ -108,6 +162,7 @@ export interface TelegramBridgeSessionLifecyclePorts<TQueueItem, TModel = unknow
108
162
  };
109
163
  delivery: TelegramBridgeSessionServiceRuntime["delivery"];
110
164
  polling: TelegramBridgeSessionServiceRuntime["polling"];
165
+ connection?: TelegramBridgeSessionServiceRuntime["connection"];
111
166
  inboundWorker: TelegramBridgeSessionServiceRuntime["inboundWorker"];
112
167
  capabilityMonitor: TelegramBridgeSessionServiceRuntime["capabilityMonitor"];
113
168
  queueWatchdog: TelegramBridgeSessionServiceRuntime["queueWatchdog"];
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Telegram lifecycle hook registration helpers
2
+ * Telegram session lifecycle coordination and hook registration
3
3
  * Zones: pi agent lifecycle, telegram session
4
- * Binds prepared Telegram lifecycle runtimes to pi extension lifecycle events
4
+ * Owns context generations, bounded connect intent across resume, and session sequencing.
5
+ * Transport authority, durable bindings, and queue custody remain with their owners.
5
6
  */
7
+ import { randomUUID } from "node:crypto";
8
+ import { formatTelegramConnectionFailure } from "./status.js";
6
9
  import * as BusFollower from "./bus-follower.js";
7
10
  import * as Queue from "./queue.js";
8
11
  import * as TextGroups from "./text-groups.js";
@@ -23,6 +26,98 @@ export function createAgentStartDedupHook(inner, schedulePublication) {
23
26
  return inner(event, ctx);
24
27
  };
25
28
  }
29
+ const HANDOFF_KEY = Symbol.for("pi-telegram.connection-resume.v1");
30
+ function processHandoffStore() {
31
+ const globals = globalThis;
32
+ return globals[HANDOFF_KEY] ??= {};
33
+ }
34
+ export function createTelegramConnectionLifecycle(deps) {
35
+ return {
36
+ onSessionShutdown(event, ctx) {
37
+ deps.intent.suspend({ reason: event.reason, cwd: ctx.cwd,
38
+ targetSessionFile: event.targetSessionFile, connected: deps.isConnected(),
39
+ profileName: deps.getProfileName() });
40
+ },
41
+ prepare(event, ctx) {
42
+ const intent = deps.intent.resume({ reason: event.reason, cwd: ctx.cwd,
43
+ sessionFile: event.reason === "resume" ? ctx.sessionManager.getSessionFile() : undefined });
44
+ if (!intent)
45
+ return undefined;
46
+ const generation = deps.getGeneration();
47
+ const isCurrent = () => deps.intent.isActive(intent.id) &&
48
+ generation === deps.getGeneration() && deps.isCurrent(ctx);
49
+ return () => {
50
+ // Startup remains extension-owned background work; no captured command ctx crosses resume.
51
+ void (async () => {
52
+ if (!isCurrent())
53
+ return;
54
+ if (!await deps.activateProfile(intent.profileName, isCurrent)) {
55
+ if (isCurrent())
56
+ ctx.ui.notify("Telegram profile unavailable. Run /telegram-setup.", "warning");
57
+ return;
58
+ }
59
+ if (!isCurrent())
60
+ return;
61
+ const result = await deps.start(ctx);
62
+ if (!isCurrent())
63
+ return;
64
+ if (!result.ok) {
65
+ deps.recordError(new Error(result.message ?? "Telegram resume connection failed."));
66
+ ctx.ui.notify(formatTelegramConnectionFailure(result.message), "warning");
67
+ }
68
+ })().catch((error) => {
69
+ deps.recordError(error);
70
+ if (isCurrent())
71
+ ctx.ui.notify(formatTelegramConnectionFailure(error), "warning");
72
+ }).finally(() => deps.intent.finish(intent.id));
73
+ };
74
+ },
75
+ };
76
+ }
77
+ export function createTelegramConnectionIntentRuntime(options = {}) {
78
+ const store = options.store ?? processHandoffStore();
79
+ const now = options.now ?? Date.now;
80
+ const pid = options.pid ?? process.pid;
81
+ let active;
82
+ return {
83
+ begin(cwd, profileName) {
84
+ store.pending = undefined;
85
+ active = { id: randomUUID(), cwd, profileName };
86
+ return active.id;
87
+ },
88
+ isActive(id) {
89
+ return active?.id === id;
90
+ },
91
+ finish(id) {
92
+ if (active?.id === id)
93
+ active = undefined;
94
+ },
95
+ cancel() {
96
+ active = undefined;
97
+ store.pending = undefined;
98
+ },
99
+ suspend(input) {
100
+ const intent = active ?? (input.connected
101
+ ? { id: randomUUID(), cwd: input.cwd, profileName: input.profileName }
102
+ : undefined);
103
+ active = undefined;
104
+ store.pending = input.reason === "resume" && input.targetSessionFile &&
105
+ intent?.cwd === input.cwd
106
+ ? { ...intent, pid, targetSessionFile: input.targetSessionFile, expiresAtMs: now() + 30_000 }
107
+ : undefined;
108
+ },
109
+ resume(input) {
110
+ const handoff = store.pending;
111
+ store.pending = undefined;
112
+ if (!handoff || handoff.pid !== pid || now() >= handoff.expiresAtMs ||
113
+ input.reason !== "resume" || input.cwd !== handoff.cwd ||
114
+ input.sessionFile !== handoff.targetSessionFile)
115
+ return undefined;
116
+ active = { id: randomUUID(), cwd: handoff.cwd, profileName: handoff.profileName };
117
+ return { ...active };
118
+ },
119
+ };
120
+ }
26
121
  export function createTelegramSessionContextStore(options = {}) {
27
122
  let currentContext;
28
123
  let currentIdentity;
@@ -112,6 +207,7 @@ export function createTelegramBridgeSessionLifecycleDeps(ports) {
112
207
  }),
113
208
  delivery: ports.services.delivery,
114
209
  polling: ports.services.polling,
210
+ connection: ports.services.connection,
115
211
  inboundWorker: ports.services.inboundWorker,
116
212
  capabilityMonitor: ports.services.capabilityMonitor,
117
213
  queueWatchdog: ports.services.queueWatchdog,
@@ -128,20 +224,27 @@ export function createTelegramBridgeSessionLifecycleAssembly(deps) {
128
224
  suspendPolling: deps.follower.suspendPolling,
129
225
  recordRuntimeEvent: deps.follower.recordRuntimeEvent,
130
226
  });
227
+ let preserveTarget = true;
131
228
  const queueLifecycle = Queue.createTelegramSessionLifecycleRuntime({
132
229
  ...deps.queue,
133
230
  isSessionActive,
134
- stopPolling: suspendForReplacement,
231
+ stopPolling: () => suspendForReplacement(preserveTarget),
135
232
  clearPendingMediaGroups: deps.services.suspendGroupedInput,
136
233
  });
137
234
  const servicesLifecycle = {
138
235
  async onSessionStart(event, ctx) {
236
+ const resumeConnection = deps.services.connection?.prepare(event, ctx);
139
237
  await queueLifecycle.onSessionStart(event, ctx);
140
238
  if (!isSessionActive(ctx))
141
239
  return;
142
240
  deps.services.resumeGroupedInput(ctx);
143
241
  await deps.services.delivery.onSessionStart();
144
- await deps.services.polling.onSessionStart(event, ctx);
242
+ if (!isSessionActive(ctx))
243
+ return;
244
+ if (resumeConnection)
245
+ resumeConnection();
246
+ else
247
+ await deps.services.polling.onSessionStart(event, ctx);
145
248
  deps.services.capabilityMonitor.start(ctx);
146
249
  deps.services.queueWatchdog.start(ctx);
147
250
  },
@@ -150,6 +253,8 @@ export function createTelegramBridgeSessionLifecycleAssembly(deps) {
150
253
  const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
151
254
  if (!isCurrent())
152
255
  return;
256
+ deps.services.connection?.onSessionShutdown(event, ctx);
257
+ preserveTarget = event.reason !== "resume";
153
258
  let preserveThread;
154
259
  if (event.reason === "quit") {
155
260
  try {