@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
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,
@@ -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
  });
@@ -2440,6 +2478,19 @@ export interface TelegramSessionActionAssemblyDeps {
2440
2478
  };
2441
2479
  getProfileName: () => string | undefined;
2442
2480
  ownsPersistence: () => boolean;
2481
+ /**
2482
+ * Registered-follower port. A follower cannot persist leader-owned state, so
2483
+ * its Workspace Thread intent is published and claimed by the leader over
2484
+ * authenticated generation-fenced bus RPC.
2485
+ */
2486
+ follower?: {
2487
+ instanceId: string;
2488
+ isRegisteredFor: (target: { chatId: number; threadId?: number }) => boolean;
2489
+ requestSessionReplacement: (
2490
+ operation: "publish" | "settle",
2491
+ intent: TelegramSessionReplacementIntent,
2492
+ ) => Promise<boolean>;
2493
+ };
2443
2494
  sendResult: (
2444
2495
  target: { chatId: number; threadId?: number },
2445
2496
  html: string,
@@ -2478,7 +2529,13 @@ export function createTelegramSessionActionAssembly(
2478
2529
  sendUserMessage: deps.sendUserMessage,
2479
2530
  notifyResult(target, result) { return sendTerminalResult(target, result); },
2480
2531
  async prepareReplacement(ctx, updateId, target) {
2481
- await deps.store.load();
2532
+ const follower = !deps.ownsPersistence() && typeof target.threadId === "number" &&
2533
+ deps.follower?.isRegisteredFor(target)
2534
+ ? deps.follower
2535
+ : undefined;
2536
+ // Follower memory is not authority; reread the leader-published snapshot.
2537
+ if (follower && deps.store.refresh) await deps.store.refresh();
2538
+ else await deps.store.load();
2482
2539
  const sessionId = ctx.sessionManager.getSessionId();
2483
2540
  const binding = typeof target.threadId === "number"
2484
2541
  ? deps.store.getWorkspaceBindingByTarget(target)
@@ -2488,7 +2545,7 @@ export function createTelegramSessionActionAssembly(
2488
2545
  throw new Error("Telegram session replacement binding is unavailable.");
2489
2546
  }
2490
2547
  const createdAtMs = now();
2491
- if (!await deps.store.commitSessionReplacementIntent({
2548
+ const intent: TelegramSessionReplacementIntent = {
2492
2549
  continuity: binding ? "workspace-thread" : "classic-chat",
2493
2550
  cwd: binding?.cwd ?? ctx.cwd,
2494
2551
  profileName: deps.getProfileName() ?? "default",
@@ -2501,7 +2558,11 @@ export function createTelegramSessionActionAssembly(
2501
2558
  ? { threadName: binding.manualThreadName ?? binding.threadName } : {}),
2502
2559
  createdAtMs,
2503
2560
  expiresAtMs: createdAtMs + deps.handoffTtlMs,
2504
- }, deps.ownsPersistence)) {
2561
+ ...(follower ? { sourceInstanceId: follower.instanceId } : {}),
2562
+ };
2563
+ if (!await (follower
2564
+ ? follower.requestSessionReplacement("publish", intent)
2565
+ : deps.store.commitSessionReplacementIntent(intent, deps.ownsPersistence))) {
2505
2566
  throw new Error("Telegram session replacement intent was not persisted.");
2506
2567
  }
2507
2568
  },
@@ -2514,11 +2575,20 @@ export function createTelegramSessionActionAssembly(
2514
2575
  return {
2515
2576
  async getIntent() { await deps.store.refresh?.(); return deps.store.getSessionReplacementIntent(); },
2516
2577
  hasSuccessorContinuity(intent) {
2517
- return intent.continuity === "classic-chat" ||
2518
- deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd === intent.cwd;
2578
+ if (intent.continuity === "classic-chat") return true;
2579
+ if (deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd !==
2580
+ intent.cwd) return false;
2581
+ // A follower successor claims only after its own re-registration is live.
2582
+ return intent.sourceInstanceId === undefined || deps.ownsPersistence() ||
2583
+ deps.follower?.isRegisteredFor(intent.target) === true;
2519
2584
  },
2520
2585
  editSuccess(intent) { return deps.sendResult(intent.target, "<b>🆕 New session started.</b>"); },
2521
- clearIntent(intent) { return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence); },
2586
+ async clearIntent(intent) {
2587
+ if (intent.sourceInstanceId === undefined || deps.ownsPersistence()) {
2588
+ return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence);
2589
+ }
2590
+ return await deps.follower?.requestSessionReplacement("settle", intent) ?? false;
2591
+ },
2522
2592
  profileName: deps.getProfileName() ?? "default",
2523
2593
  cwd: ctx.cwd,
2524
2594
  sessionId,
package/lib/extension.ts CHANGED
@@ -65,6 +65,7 @@ const telegramBusProtocolIdentity =
65
65
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
66
66
  Bus.TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
67
67
  Bus.TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
68
+ Bus.TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
68
69
  ]);
69
70
 
70
71
  // --- Extension Runtime ---
@@ -244,6 +245,20 @@ export default function (pi: Pi.ExtensionAPI) {
244
245
  store: threadStore,
245
246
  getProfileName: configStore.getActiveProfileName,
246
247
  ownsPersistence: lockRuntime.owns,
248
+ follower: {
249
+ instanceId: telegramInstanceId,
250
+ isRegisteredFor(target) {
251
+ const registered = telegramBusFollowerRegistrationState.getTarget();
252
+ return telegramBusFollowerRegistrationState.isRegistered() &&
253
+ registered?.chatId === target.chatId &&
254
+ registered.threadId === target.threadId;
255
+ },
256
+ async requestSessionReplacement(operation, intent) {
257
+ const request = telegramBusFollowerRegistration.requestSessionReplacement;
258
+ if (!request) return false;
259
+ return await request(operation, intent);
260
+ },
261
+ },
247
262
  async sendResult(target, html) {
248
263
  const delivery = await Delivery.sendTelegramView(
249
264
  { text: html, parseMode: "html", replyMarkup: { inline_keyboard: [] } },
@@ -1618,6 +1633,27 @@ export default function (pi: Pi.ExtensionAPI) {
1618
1633
  resolveAutomaticThreadCleanupEnabled: configControls.resolveAutomaticThreadCleanupEnabled,
1619
1634
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1620
1635
  });
1636
+ const connectionIntent = Lifecycle.createTelegramConnectionIntentRuntime();
1637
+ const connectionLifecycle = Lifecycle.createTelegramConnectionLifecycle({
1638
+ intent: connectionIntent,
1639
+ getGeneration: telegramSessionContextStore.getGeneration,
1640
+ isCurrent: telegramSessionContextStore.isCurrent,
1641
+ getProfileName: configStore.getActiveProfileName,
1642
+ isConnected() {
1643
+ return lockRuntime.owns() || telegramBusFollowerRegistrationState.isRegistered();
1644
+ },
1645
+ async activateProfile(profileName, isCurrent) {
1646
+ await configStore.load();
1647
+ if (!isCurrent()) return false;
1648
+ return configStore.activateProfile(profileName) && configStore.hasBotToken();
1649
+ },
1650
+ start(ctx) {
1651
+ return lockedPollingRuntime.start(ctx);
1652
+ },
1653
+ recordError(error) {
1654
+ recordRuntimeEvent("connection", error, { phase: "resume-connect" });
1655
+ },
1656
+ });
1621
1657
  const telegramBridgeSessionLifecycleDeps =
1622
1658
  Lifecycle.createTelegramBridgeSessionLifecycleDeps({
1623
1659
  contextStore: telegramSessionContextStore,
@@ -1666,6 +1702,7 @@ export default function (pi: Pi.ExtensionAPI) {
1666
1702
  },
1667
1703
  delivery: deliveryLifecycleRuntime,
1668
1704
  polling: lockedPollingRuntime,
1705
+ connection: connectionLifecycle,
1669
1706
  inboundWorker: {
1670
1707
  onSessionShutdown: updateAdmissionRuntimeBinding.onSessionShutdown,
1671
1708
  },
@@ -1812,6 +1849,9 @@ export default function (pi: Pi.ExtensionAPI) {
1812
1849
  modelContextAvailabilityRuntime.reconcile();
1813
1850
  },
1814
1851
  getStatusLines,
1852
+ isContextCurrent: telegramSessionContextStore.isCurrent,
1853
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
1854
+ connectionIntent,
1815
1855
  buttonActionStore,
1816
1856
  sendMarkdownReply,
1817
1857
  async sendChannelMarkdownMessage(channel, markdown, options) {
package/lib/lifecycle.ts CHANGED
@@ -1,9 +1,12 @@
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
 
8
+ import { randomUUID } from "node:crypto";
9
+ import { formatTelegramConnectionFailure } from "./status.ts";
7
10
  import * as BusFollower from "./bus-follower.ts";
8
11
  import * as Queue from "./queue.ts";
9
12
  import * as TextGroups from "./text-groups.ts";
@@ -157,6 +160,130 @@ export interface TelegramSessionLifecycleHooks {
157
160
  ) => Promise<void>;
158
161
  }
159
162
 
163
+ export interface TelegramConnectionIntent {
164
+ id: string;
165
+ cwd: string;
166
+ profileName?: string;
167
+ }
168
+ interface TelegramConnectionHandoff extends TelegramConnectionIntent {
169
+ pid: number;
170
+ targetSessionFile: string;
171
+ expiresAtMs: number;
172
+ }
173
+ export interface TelegramConnectionHandoffStore {
174
+ pending?: TelegramConnectionHandoff;
175
+ }
176
+ const HANDOFF_KEY = Symbol.for("pi-telegram.connection-resume.v1");
177
+ function processHandoffStore(): TelegramConnectionHandoffStore {
178
+ const globals = globalThis as unknown as Record<symbol, TelegramConnectionHandoffStore>;
179
+ return globals[HANDOFF_KEY] ??= {};
180
+ }
181
+
182
+ export interface TelegramConnectionLifecycle {
183
+ prepare(event: SessionStartEvent, ctx: ExtensionContext): (() => void) | undefined;
184
+ onSessionShutdown(event: SessionShutdownEvent, ctx: ExtensionContext): void;
185
+ }
186
+
187
+ export function createTelegramConnectionLifecycle(deps: {
188
+ intent: ReturnType<typeof createTelegramConnectionIntentRuntime>;
189
+ getGeneration(): number;
190
+ isCurrent(ctx: ExtensionContext): boolean;
191
+ getProfileName(): string | undefined;
192
+ isConnected(): boolean;
193
+ activateProfile(profileName: string | undefined, isCurrent: () => boolean): Promise<boolean>;
194
+ start(ctx: ExtensionContext): Promise<{ ok: boolean; message?: string }>;
195
+ recordError(error: unknown): void;
196
+ }): TelegramConnectionLifecycle {
197
+ return {
198
+ onSessionShutdown(event, ctx) {
199
+ deps.intent.suspend({ reason: event.reason, cwd: ctx.cwd,
200
+ targetSessionFile: event.targetSessionFile, connected: deps.isConnected(),
201
+ profileName: deps.getProfileName() });
202
+ },
203
+ prepare(event, ctx) {
204
+ const intent = deps.intent.resume({ reason: event.reason, cwd: ctx.cwd,
205
+ sessionFile: event.reason === "resume" ? ctx.sessionManager.getSessionFile() : undefined });
206
+ if (!intent) return undefined;
207
+ const generation = deps.getGeneration();
208
+ const isCurrent = () => deps.intent.isActive(intent.id) &&
209
+ generation === deps.getGeneration() && deps.isCurrent(ctx);
210
+ return () => {
211
+ // Startup remains extension-owned background work; no captured command ctx crosses resume.
212
+ void (async () => {
213
+ if (!isCurrent()) return;
214
+ if (!await deps.activateProfile(intent.profileName, isCurrent)) {
215
+ if (isCurrent()) ctx.ui.notify("Telegram profile unavailable. Run /telegram-setup.", "warning");
216
+ return;
217
+ }
218
+ if (!isCurrent()) return;
219
+ const result = await deps.start(ctx);
220
+ if (!isCurrent()) return;
221
+ if (!result.ok) {
222
+ deps.recordError(new Error(result.message ?? "Telegram resume connection failed."));
223
+ ctx.ui.notify(formatTelegramConnectionFailure(result.message), "warning");
224
+ }
225
+ })().catch((error) => {
226
+ deps.recordError(error);
227
+ if (isCurrent()) ctx.ui.notify(formatTelegramConnectionFailure(error), "warning");
228
+ }).finally(() => deps.intent.finish(intent.id));
229
+ };
230
+ },
231
+ };
232
+ }
233
+
234
+ export function createTelegramConnectionIntentRuntime(options: {
235
+ store?: TelegramConnectionHandoffStore;
236
+ now?: () => number;
237
+ pid?: number;
238
+ } = {}) {
239
+ const store = options.store ?? processHandoffStore();
240
+ const now = options.now ?? Date.now;
241
+ const pid = options.pid ?? process.pid;
242
+ let active: TelegramConnectionIntent | undefined;
243
+ return {
244
+ begin(cwd: string, profileName?: string): string {
245
+ store.pending = undefined;
246
+ active = { id: randomUUID(), cwd, profileName };
247
+ return active.id;
248
+ },
249
+ isActive(id: string): boolean {
250
+ return active?.id === id;
251
+ },
252
+ finish(id: string): void {
253
+ if (active?.id === id) active = undefined;
254
+ },
255
+ cancel(): void {
256
+ active = undefined;
257
+ store.pending = undefined;
258
+ },
259
+ suspend(input: {
260
+ reason: string;
261
+ cwd: string;
262
+ targetSessionFile?: string;
263
+ connected: boolean;
264
+ profileName?: string;
265
+ }): void {
266
+ const intent = active ?? (input.connected
267
+ ? { id: randomUUID(), cwd: input.cwd, profileName: input.profileName }
268
+ : undefined);
269
+ active = undefined;
270
+ store.pending = input.reason === "resume" && input.targetSessionFile &&
271
+ intent?.cwd === input.cwd
272
+ ? { ...intent, pid, targetSessionFile: input.targetSessionFile, expiresAtMs: now() + 30_000 }
273
+ : undefined;
274
+ },
275
+ resume(input: { reason: string; cwd: string; sessionFile?: string }): TelegramConnectionIntent | undefined {
276
+ const handoff = store.pending;
277
+ store.pending = undefined;
278
+ if (!handoff || handoff.pid !== pid || now() >= handoff.expiresAtMs ||
279
+ input.reason !== "resume" || input.cwd !== handoff.cwd ||
280
+ input.sessionFile !== handoff.targetSessionFile) return undefined;
281
+ active = { id: randomUUID(), cwd: handoff.cwd, profileName: handoff.profileName };
282
+ return { ...active };
283
+ },
284
+ };
285
+ }
286
+
160
287
  export interface TelegramSessionContextStore<TContext> {
161
288
  get: () => TContext | undefined;
162
289
  getGeneration: () => number;
@@ -250,6 +377,7 @@ export function createTelegramSessionGenerationFence(
250
377
  }
251
378
 
252
379
  export interface TelegramBridgeSessionServiceRuntime {
380
+ connection?: TelegramConnectionLifecycle;
253
381
  resumeGroupedInput(ctx: ExtensionContext): void;
254
382
  suspendGroupedInput(): void;
255
383
  delivery: {
@@ -319,6 +447,7 @@ export interface TelegramBridgeSessionLifecyclePorts<
319
447
  };
320
448
  delivery: TelegramBridgeSessionServiceRuntime["delivery"];
321
449
  polling: TelegramBridgeSessionServiceRuntime["polling"];
450
+ connection?: TelegramBridgeSessionServiceRuntime["connection"];
322
451
  inboundWorker: TelegramBridgeSessionServiceRuntime["inboundWorker"];
323
452
  capabilityMonitor: TelegramBridgeSessionServiceRuntime["capabilityMonitor"];
324
453
  queueWatchdog: TelegramBridgeSessionServiceRuntime["queueWatchdog"];
@@ -348,6 +477,7 @@ export function createTelegramBridgeSessionLifecycleDeps<
348
477
  }),
349
478
  delivery: ports.services.delivery,
350
479
  polling: ports.services.polling,
480
+ connection: ports.services.connection,
351
481
  inboundWorker: ports.services.inboundWorker,
352
482
  capabilityMonitor: ports.services.capabilityMonitor,
353
483
  queueWatchdog: ports.services.queueWatchdog,
@@ -371,19 +501,23 @@ export function createTelegramBridgeSessionLifecycleAssembly<
371
501
  suspendPolling: deps.follower.suspendPolling,
372
502
  recordRuntimeEvent: deps.follower.recordRuntimeEvent,
373
503
  });
504
+ let preserveTarget = true;
374
505
  const queueLifecycle = Queue.createTelegramSessionLifecycleRuntime({
375
506
  ...deps.queue,
376
507
  isSessionActive,
377
- stopPolling: suspendForReplacement,
508
+ stopPolling: () => suspendForReplacement(preserveTarget),
378
509
  clearPendingMediaGroups: deps.services.suspendGroupedInput,
379
510
  });
380
511
  const servicesLifecycle: TelegramSessionLifecycleHooks = {
381
512
  async onSessionStart(event, ctx) {
513
+ const resumeConnection = deps.services.connection?.prepare(event, ctx);
382
514
  await queueLifecycle.onSessionStart(event, ctx);
383
515
  if (!isSessionActive(ctx)) return;
384
516
  deps.services.resumeGroupedInput(ctx);
385
517
  await deps.services.delivery.onSessionStart();
386
- await deps.services.polling.onSessionStart(event, ctx);
518
+ if (!isSessionActive(ctx)) return;
519
+ if (resumeConnection) resumeConnection();
520
+ else await deps.services.polling.onSessionStart(event, ctx);
387
521
  deps.services.capabilityMonitor.start(ctx);
388
522
  deps.services.queueWatchdog.start(ctx);
389
523
  },
@@ -391,6 +525,8 @@ export function createTelegramBridgeSessionLifecycleAssembly<
391
525
  const generation = deps.contextStore.getGeneration();
392
526
  const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
393
527
  if (!isCurrent()) return;
528
+ deps.services.connection?.onSessionShutdown(event, ctx);
529
+ preserveTarget = event.reason !== "resume";
394
530
  let preserveThread: (() => Promise<void>) | undefined;
395
531
  if (event.reason === "quit") {
396
532
  try {
package/lib/locks.ts CHANGED
@@ -1515,7 +1515,12 @@ export function createTelegramLockedPollingRuntime<
1515
1515
  ctx,
1516
1516
  state.lock,
1517
1517
  );
1518
- if (!isCurrent() || !restored) return;
1518
+ if (!isCurrent()) return;
1519
+ if (!restored) {
1520
+ deps.recordRuntimeEvent?.("lock", "Telegram follower auto-connect did not restore this session.",
1521
+ { phase: "follower-auto-connect-unavailable" });
1522
+ return;
1523
+ }
1519
1524
  deps.onTransportAvailabilityChanged?.();
1520
1525
  deps.updateStatus(ctx);
1521
1526
  deps.recordRuntimeEvent?.(
package/lib/model.ts CHANGED
@@ -454,10 +454,8 @@ export function buildTelegramModelSwitchContinuationText<
454
454
  thinkingLevel?: ScopedTelegramModel<TModel>["thinkingLevel"],
455
455
  ): string {
456
456
  const modelLabel = `${model.provider}/${model.id}`;
457
- const thinkingSuffix = thinkingLevel
458
- ? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.`
459
- : "";
460
- return `${telegramPrefix} Continue the interrupted previous request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`;
457
+ const thinkingSuffix = thinkingLevel ? `; thinking: ${thinkingLevel}` : "";
458
+ return `${telegramPrefix} Continue from the last unfinished step. Model: ${modelLabel}${thinkingSuffix}.`;
461
459
  }
462
460
 
463
461
  export type TelegramModelSwitchContinuationSource = Pick<