@llblab/pi-kit 0.26.0 → 0.27.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 (88) hide show
  1. package/BACKLOG.md +2 -2
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +4 -4
  4. package/node_modules/@llblab/pi-state-flow/AGENTS.md +1 -1
  5. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +12 -5
  6. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +9 -0
  7. package/node_modules/@llblab/pi-state-flow/README.md +116 -34
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +24 -1
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +80 -1
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +17 -0
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +40 -0
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +149 -298
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +29 -0
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +58 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/operation.d.ts +37 -0
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/operation.js +59 -0
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/ownership.d.ts +31 -0
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/ownership.js +117 -0
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +7 -7
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +1 -1
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +2 -0
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +28 -0
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +6 -1
  24. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  25. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +14 -6
  26. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +2 -2
  27. package/node_modules/@llblab/pi-state-flow/docs/README.md +19 -9
  28. package/node_modules/@llblab/pi-state-flow/docs/agent-contract-relocation.md +4 -4
  29. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +644 -91
  30. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +116 -37
  31. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +118 -21
  32. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +68 -8
  33. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +88 -14
  34. package/node_modules/@llblab/pi-state-flow/docs/performance.md +83 -66
  35. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +391 -62
  36. package/node_modules/@llblab/pi-state-flow/docs/usage.md +317 -62
  37. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +85 -1
  38. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +47 -0
  39. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +161 -295
  40. package/node_modules/@llblab/pi-state-flow/lib/git.ts +63 -0
  41. package/node_modules/@llblab/pi-state-flow/lib/operation.ts +75 -0
  42. package/node_modules/@llblab/pi-state-flow/lib/ownership.ts +120 -0
  43. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +7 -8
  44. package/node_modules/@llblab/pi-state-flow/lib/query.ts +1 -1
  45. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +1 -1
  46. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +1 -2
  47. package/node_modules/@llblab/pi-state-flow/lib/status.ts +29 -0
  48. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +5 -1
  49. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  50. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +14 -6
  51. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +2 -2
  52. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -0
  53. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +10 -0
  54. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  55. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +4 -1
  56. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +16 -12
  57. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +1 -1
  58. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +7 -1
  59. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +12 -3
  60. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +137 -83
  61. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +26 -0
  62. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +57 -2
  63. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +109 -4
  64. package/node_modules/@llblab/pi-telegram/dist/lib/model.js +2 -4
  65. package/node_modules/@llblab/pi-telegram/dist/lib/status.d.ts +3 -1
  66. package/node_modules/@llblab/pi-telegram/dist/lib/status.js +31 -1
  67. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +4 -4
  68. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +1 -0
  69. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +17 -4
  70. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +2 -2
  71. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +48 -12
  72. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  73. package/node_modules/@llblab/pi-telegram/docs/architecture.md +6 -5
  74. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -0
  75. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -2
  76. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +4 -0
  77. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +17 -10
  78. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +8 -2
  79. package/node_modules/@llblab/pi-telegram/lib/commands.ts +135 -97
  80. package/node_modules/@llblab/pi-telegram/lib/extension.ts +25 -0
  81. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +140 -4
  82. package/node_modules/@llblab/pi-telegram/lib/model.ts +2 -4
  83. package/node_modules/@llblab/pi-telegram/lib/status.ts +30 -1
  84. package/node_modules/@llblab/pi-telegram/lib/sync.ts +4 -4
  85. package/node_modules/@llblab/pi-telegram/lib/threads.ts +21 -3
  86. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +46 -13
  87. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  88. package/package.json +3 -3
@@ -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
  });
@@ -1633,6 +1633,27 @@ export default function (pi: Pi.ExtensionAPI) {
1633
1633
  resolveAutomaticThreadCleanupEnabled: configControls.resolveAutomaticThreadCleanupEnabled,
1634
1634
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1635
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
+ });
1636
1657
  const telegramBridgeSessionLifecycleDeps =
1637
1658
  Lifecycle.createTelegramBridgeSessionLifecycleDeps({
1638
1659
  contextStore: telegramSessionContextStore,
@@ -1681,6 +1702,7 @@ export default function (pi: Pi.ExtensionAPI) {
1681
1702
  },
1682
1703
  delivery: deliveryLifecycleRuntime,
1683
1704
  polling: lockedPollingRuntime,
1705
+ connection: connectionLifecycle,
1684
1706
  inboundWorker: {
1685
1707
  onSessionShutdown: updateAdmissionRuntimeBinding.onSessionShutdown,
1686
1708
  },
@@ -1827,6 +1849,9 @@ export default function (pi: Pi.ExtensionAPI) {
1827
1849
  modelContextAvailabilityRuntime.reconcile();
1828
1850
  },
1829
1851
  getStatusLines,
1852
+ isContextCurrent: telegramSessionContextStore.isCurrent,
1853
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
1854
+ connectionIntent,
1830
1855
  buttonActionStore,
1831
1856
  sendMarkdownReply,
1832
1857
  async sendChannelMarkdownMessage(channel, markdown, options) {
@@ -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 {
@@ -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<
@@ -1,9 +1,38 @@
1
1
  /**
2
2
  * Telegram status rendering helpers
3
3
  * Zones: telegram ui, pi agent diagnostics, tui
4
- * Builds usage, cost, and context summaries for the interactive Telegram status view
4
+ * Owns status summaries, redacted runtime diagnostics, and compact connection-failure copy
5
5
  */
6
6
 
7
+ /** UI copy is allowlisted; raw exception text belongs only in redacted diagnostics. */
8
+ export function formatTelegramConnectionFailure(error: unknown): string {
9
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
10
+ const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
11
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
12
+ if (status === 401) return "Telegram token rejected. Run /telegram-setup.";
13
+ if (status === 403) return "Telegram access denied. Check /telegram-status --debug.";
14
+ if ((typeof code === "string" && ["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "ECONNRESET"].includes(code)) ||
15
+ /network unavailable|fetch failed/i.test(message)) {
16
+ return "Telegram network unavailable. Retry /telegram-connect.";
17
+ }
18
+ if (/Workspace slots?.*(?:unavailable|exhausted)|no free.*slot/i.test(message)) {
19
+ return "No Telegram slot available. Check /telegram-status --debug.";
20
+ }
21
+ if (code === "incompatible-protocol" || /protocol.*(?:incompatible|mismatch)|incompatible.*protocol/i.test(message)) {
22
+ return "Telegram instances are incompatible. Update them together.";
23
+ }
24
+ if (/unsupported.*(?:journal|version)|(?:journal|version).*unsupported/i.test(message)) {
25
+ return "Telegram state version unsupported. Use a compatible runtime.";
26
+ }
27
+ if (/follower registration failed|active in another Pi instance/i.test(message)) {
28
+ return "Telegram leader is active but unavailable. Check /telegram-status --debug.";
29
+ }
30
+ if (/unfinished Thread creation does not match/i.test(message)) {
31
+ return "Telegram Thread creation is unresolved. Check /telegram-status --debug.";
32
+ }
33
+ return "Telegram connection failed. Check /telegram-status --debug.";
34
+ }
35
+
7
36
  const TELEGRAM_STATUS_DEFAULT_PROFILE_NAME = "default";
8
37
 
9
38
  export type TelegramStatusQueueLane = "control" | "priority" | "default";
@@ -773,6 +773,7 @@ export async function ensureTelegramLeaderThreadBinding(
773
773
  : undefined;
774
774
  const legacyWorkspaceBinding =
775
775
  workspaceIdentity?.instanceSlot === "a" &&
776
+ deps.sessionId === undefined &&
776
777
  !persistedWorkspaceBinding &&
777
778
  typeof legacyLeaderRecord?.target.threadId === "number"
778
779
  ? {
@@ -885,10 +886,9 @@ export async function ensureTelegramLeaderThreadBinding(
885
886
  (record.status === "active" || record.status === "starting")
886
887
  );
887
888
  });
888
- // Short-circuit: when the instance already has an active thread and we are not
889
- // force-freshing, reuse it without re-provisioning. A thread belongs to the
890
- // live instance binding, not to one transient Pi session lifecycle.
891
- if (!deps.forceFreshUnnamed && priorTargets.length > 0) {
889
+ // Legacy callers without session identity may reuse the active instance target.
890
+ // Session-aware callers must resolve their exact Workspace binding instead.
891
+ if (deps.sessionId === undefined && !deps.forceFreshUnnamed && priorTargets.length > 0) {
892
892
  const record = priorTargets[0];
893
893
  deps.recordEvent(
894
894
  "telegram",