@llblab/pi-kit 0.25.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 (169) hide show
  1. package/BACKLOG.md +5 -1
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +10 -8
  4. package/node_modules/@llblab/pi-actors/AGENTS.md +2 -0
  5. package/node_modules/@llblab/pi-actors/CHANGELOG.md +4 -1
  6. package/node_modules/@llblab/pi-actors/LICENSE +21 -0
  7. package/node_modules/@llblab/pi-actors/README.md +1 -1
  8. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +1 -1
  9. package/node_modules/@llblab/pi-actors/package.json +4 -3
  10. package/node_modules/@llblab/pi-claude-usage/AGENTS.md +6 -3
  11. package/node_modules/@llblab/pi-claude-usage/BACKLOG.md +2 -1
  12. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +8 -0
  13. package/node_modules/@llblab/pi-claude-usage/README.md +48 -3
  14. package/node_modules/@llblab/pi-claude-usage/index.ts +8 -1159
  15. package/node_modules/@llblab/pi-claude-usage/lib/extension.ts +30 -0
  16. package/node_modules/@llblab/pi-claude-usage/lib/fast.ts +24 -0
  17. package/node_modules/@llblab/pi-claude-usage/lib/query.ts +146 -0
  18. package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +297 -0
  19. package/node_modules/@llblab/pi-claude-usage/lib/status.ts +366 -0
  20. package/node_modules/@llblab/pi-claude-usage/lib/telegram.ts +44 -0
  21. package/node_modules/@llblab/pi-claude-usage/lib/usage-store.ts +221 -0
  22. package/node_modules/@llblab/pi-claude-usage/lib/usage.ts +128 -0
  23. package/node_modules/@llblab/pi-claude-usage/package.json +9 -5
  24. package/node_modules/@llblab/pi-clean-room/AGENTS.md +1 -0
  25. package/node_modules/@llblab/pi-clean-room/CHANGELOG.md +5 -0
  26. package/node_modules/@llblab/pi-clean-room/LICENSE +21 -0
  27. package/node_modules/@llblab/pi-clean-room/README.md +1 -1
  28. package/node_modules/@llblab/pi-clean-room/package.json +3 -2
  29. package/node_modules/@llblab/pi-codex-usage/AGENTS.md +9 -6
  30. package/node_modules/@llblab/pi-codex-usage/BACKLOG.md +2 -1
  31. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +17 -0
  32. package/node_modules/@llblab/pi-codex-usage/README.md +75 -17
  33. package/node_modules/@llblab/pi-codex-usage/index.ts +8 -1602
  34. package/node_modules/@llblab/pi-codex-usage/lib/extension.ts +25 -0
  35. package/node_modules/@llblab/pi-codex-usage/lib/fast.ts +23 -0
  36. package/node_modules/@llblab/pi-codex-usage/lib/query.ts +368 -0
  37. package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +347 -0
  38. package/node_modules/@llblab/pi-codex-usage/lib/status.ts +435 -0
  39. package/node_modules/@llblab/pi-codex-usage/lib/telegram.ts +45 -0
  40. package/node_modules/@llblab/pi-codex-usage/lib/usage-store.ts +229 -0
  41. package/node_modules/@llblab/pi-codex-usage/lib/usage.ts +425 -0
  42. package/node_modules/@llblab/pi-codex-usage/package.json +11 -6
  43. package/node_modules/@llblab/pi-command-fast/AGENTS.md +7 -0
  44. package/node_modules/@llblab/pi-command-fast/BACKLOG.md +9 -0
  45. package/node_modules/@llblab/pi-command-fast/CHANGELOG.md +7 -0
  46. package/node_modules/@llblab/pi-command-fast/LICENSE +21 -0
  47. package/node_modules/@llblab/pi-command-fast/README.md +42 -0
  48. package/node_modules/@llblab/pi-command-fast/dist/command.d.ts +8 -0
  49. package/node_modules/@llblab/pi-command-fast/dist/command.js +52 -0
  50. package/node_modules/@llblab/pi-command-fast/dist/index.d.ts +3 -0
  51. package/node_modules/@llblab/pi-command-fast/dist/index.js +3 -0
  52. package/node_modules/@llblab/pi-command-fast/dist/models-json.d.ts +10 -0
  53. package/node_modules/@llblab/pi-command-fast/dist/models-json.js +81 -0
  54. package/node_modules/@llblab/pi-command-fast/package.json +49 -0
  55. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -0
  56. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -1
  57. package/node_modules/@llblab/pi-grow-loop/LICENSE +21 -0
  58. package/node_modules/@llblab/pi-grow-loop/README.md +1 -1
  59. package/node_modules/@llblab/pi-grow-loop/package.json +3 -2
  60. package/node_modules/@llblab/pi-state-flow/AGENTS.md +7 -6
  61. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +13 -5
  62. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +16 -1
  63. package/node_modules/@llblab/pi-state-flow/LICENSE +21 -0
  64. package/node_modules/@llblab/pi-state-flow/README.md +117 -35
  65. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +24 -1
  66. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +80 -1
  67. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +18 -1
  68. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +41 -1
  69. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +346 -303
  70. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +31 -2
  71. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +74 -5
  72. package/node_modules/@llblab/pi-state-flow/dist/lib/operation.d.ts +37 -0
  73. package/node_modules/@llblab/pi-state-flow/dist/lib/operation.js +59 -0
  74. package/node_modules/@llblab/pi-state-flow/dist/lib/ownership.d.ts +31 -0
  75. package/node_modules/@llblab/pi-state-flow/dist/lib/ownership.js +117 -0
  76. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +7 -7
  77. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +1 -1
  78. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +10 -1
  79. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +60 -1
  80. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +6 -0
  81. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +8 -5
  82. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +2 -0
  83. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +28 -0
  84. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +6 -1
  85. package/node_modules/@llblab/pi-state-flow/dist/package.json +10 -9
  86. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +14 -6
  87. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +2 -2
  88. package/node_modules/@llblab/pi-state-flow/docs/README.md +19 -9
  89. package/node_modules/@llblab/pi-state-flow/docs/agent-contract-relocation.md +4 -4
  90. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +646 -89
  91. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +116 -37
  92. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +118 -21
  93. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +70 -8
  94. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +88 -14
  95. package/node_modules/@llblab/pi-state-flow/docs/performance.md +83 -66
  96. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +391 -62
  97. package/node_modules/@llblab/pi-state-flow/docs/usage.md +317 -61
  98. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +85 -1
  99. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +49 -2
  100. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +342 -301
  101. package/node_modules/@llblab/pi-state-flow/lib/git.ts +77 -5
  102. package/node_modules/@llblab/pi-state-flow/lib/operation.ts +75 -0
  103. package/node_modules/@llblab/pi-state-flow/lib/ownership.ts +120 -0
  104. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +7 -8
  105. package/node_modules/@llblab/pi-state-flow/lib/query.ts +1 -1
  106. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +1 -1
  107. package/node_modules/@llblab/pi-state-flow/lib/session.ts +52 -1
  108. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +8 -7
  109. package/node_modules/@llblab/pi-state-flow/lib/status.ts +29 -0
  110. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +5 -1
  111. package/node_modules/@llblab/pi-state-flow/package.json +10 -9
  112. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +14 -6
  113. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +2 -2
  114. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -0
  115. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +10 -0
  116. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  117. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +4 -1
  118. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +16 -12
  119. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +1 -1
  120. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +7 -1
  121. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +12 -3
  122. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +137 -83
  123. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +26 -0
  124. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +57 -2
  125. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +109 -4
  126. package/node_modules/@llblab/pi-telegram/dist/lib/model.js +2 -4
  127. package/node_modules/@llblab/pi-telegram/dist/lib/status.d.ts +3 -1
  128. package/node_modules/@llblab/pi-telegram/dist/lib/status.js +31 -1
  129. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +4 -4
  130. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +1 -0
  131. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +17 -4
  132. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +2 -2
  133. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +48 -12
  134. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  135. package/node_modules/@llblab/pi-telegram/docs/architecture.md +6 -5
  136. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -0
  137. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -2
  138. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +4 -0
  139. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +17 -10
  140. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +8 -2
  141. package/node_modules/@llblab/pi-telegram/lib/commands.ts +135 -97
  142. package/node_modules/@llblab/pi-telegram/lib/extension.ts +25 -0
  143. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +140 -4
  144. package/node_modules/@llblab/pi-telegram/lib/model.ts +2 -4
  145. package/node_modules/@llblab/pi-telegram/lib/status.ts +30 -1
  146. package/node_modules/@llblab/pi-telegram/lib/sync.ts +4 -4
  147. package/node_modules/@llblab/pi-telegram/lib/threads.ts +21 -3
  148. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +46 -13
  149. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  150. package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
  151. package/node_modules/jsonc-parser/LICENSE.md +21 -0
  152. package/node_modules/jsonc-parser/README.md +364 -0
  153. package/node_modules/jsonc-parser/SECURITY.md +41 -0
  154. package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
  155. package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
  156. package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
  157. package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
  158. package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
  159. package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
  160. package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
  161. package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
  162. package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
  163. package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
  164. package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
  165. package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
  166. package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
  167. package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
  168. package/node_modules/jsonc-parser/package.json +37 -0
  169. package/package.json +8 -8
@@ -218,6 +218,10 @@ Examples:
218
218
  - Main menu → Settings: first row is `⬆️ Main menu`.
219
219
  - Settings → Voice reply mode: first row is `⬆️ Back`.
220
220
 
221
+ ## Pi Connection Notices
222
+
223
+ Pi TUI connection notices use plain text: a short known cause and one recovery action. Unknown failures use a generic connection-failed notice with `/telegram-status --debug`; never interpolate raw exceptions, credentials, stack traces or Pi lifecycle guidance. Technical evidence belongs in the redacted runtime recorder. Failed disconnect must retain the instruction to keep Pi open; do not also rethrow the same error as a second Pi banner. These TUI notices are distinct from Telegram bot message cards below.
224
+
221
225
  ## Message Cards
222
226
 
223
227
  Message cards and standalone informational notices sent by the bot should start with a strong heading.
@@ -602,6 +602,9 @@ interface TelegramCommandsAndToolsBindingDeps {
602
602
  | undefined,
603
603
  ) => void;
604
604
  updateStatus: TelegramBridgeStatusUpdater;
605
+ isContextCurrent: (ctx: Pi.ExtensionContext) => boolean;
606
+ getSessionGeneration: () => number;
607
+ connectionIntent: NonNullable<Commands.TelegramBridgeCommandRegistrationDeps["connectionIntent"]>;
605
608
  recordRuntimeEvent: TelegramRuntimeEventRecorder;
606
609
  }
607
610
 
@@ -633,6 +636,9 @@ export function registerTelegramCommandsAndTools({
633
636
  setGenerativeAppLiveSurfaceRuntime,
634
637
  recordRuntimeEvent,
635
638
  updateStatus,
639
+ isContextCurrent,
640
+ getSessionGeneration,
641
+ connectionIntent,
636
642
  }: TelegramCommandsAndToolsBindingDeps): void {
637
643
  GenerativeApps.registerTelegramBindTool(pi, {
638
644
  agentDir,
@@ -783,38 +789,39 @@ export function registerTelegramCommandsAndTools({
783
789
  reloadConfig: configStore.load,
784
790
  hasBotToken: configStore.hasBotToken,
785
791
  getBotTokenDiagnostic: configStore.getBotTokenDiagnostic,
786
- startPolling: async (ctx, options) => {
787
- try {
788
- return await lockedPollingRuntime.start(ctx, options);
789
- } catch (error) {
790
- recordRuntimeEvent("recovery", error, { phase: "polling-start" });
791
- throw error;
792
- }
793
- },
792
+ startPolling: lockedPollingRuntime.start,
793
+ recordConnectionEvent: (error, phase) => recordRuntimeEvent("connection", error, { phase }),
794
794
  stopPolling: stopPolling ?? lockedPollingRuntime.stop,
795
795
  recoverPollingStart,
796
796
  getDisconnectThreadName,
797
797
  queueAgentConnectionContext,
798
798
  updateStatus,
799
+ isContextCurrent,
800
+ getSessionGeneration,
801
+ connectionIntent,
799
802
  getProfileNames: () =>
800
803
  Config.getTelegramProfileNames(configStore.getStoredConfig()),
801
- activateDefaultProfileConfig: async () => {
804
+ activateDefaultProfileConfig: async (_ctx, isCurrent) => {
802
805
  const previousProfileName = configStore.getActiveProfileName();
803
806
  await configStore.load();
807
+ if (!isCurrent()) return;
804
808
  if (previousProfileName) {
805
809
  await (stopPolling ?? lockedPollingRuntime.stop)();
810
+ if (!isCurrent()) return;
806
811
  }
807
812
  configStore.activateProfile(undefined);
808
813
  await onTransportChanged?.();
809
814
  },
810
- activateProfileConfig: async (_ctx, profileName) => {
815
+ activateProfileConfig: async (_ctx, profileName, isCurrent) => {
811
816
  const previousProfileName = configStore.getActiveProfileName();
812
817
  await configStore.load();
818
+ if (!isCurrent()) return false;
813
819
  if (!Config.isValidTelegramProfileName(profileName)) return false;
814
820
  const storedConfig = configStore.getStoredConfig();
815
821
  if (!storedConfig.profiles?.[profileName]) return false;
816
822
  if (previousProfileName !== profileName) {
817
823
  await (stopPolling ?? lockedPollingRuntime.stop)();
824
+ if (!isCurrent()) return false;
818
825
  }
819
826
  if (!configStore.activateProfile(profileName)) return false;
820
827
  await onTransportChanged?.();
@@ -1130,10 +1130,16 @@ function isTelegramStaleContextError(error: unknown): boolean {
1130
1130
 
1131
1131
  export function createTelegramBusFollowerSessionReplacementSuspender(
1132
1132
  deps: TelegramBusFollowerSessionReplacementSuspenderDeps,
1133
- ): () => Promise<void> {
1133
+ ): (preserveTarget?: boolean) => Promise<void> {
1134
1134
  const getNowMs = deps.getNowMs ?? Date.now;
1135
1135
  const getPid = deps.getPid ?? (() => process.pid);
1136
- return async () => {
1136
+ return async (preserveTarget = true) => {
1137
+ if (!preserveTarget) {
1138
+ setTelegramFollowerSessionHandoff(undefined);
1139
+ Threads.setTelegramLeaderSessionHandoff(undefined);
1140
+ await deps.suspendPolling();
1141
+ return;
1142
+ }
1137
1143
  const target = deps.registrationState.getTarget();
1138
1144
  if (deps.registrationState.isRegistered() && target) {
1139
1145
  setTelegramFollowerSessionHandoff({
@@ -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<