@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
@@ -371,7 +371,7 @@ export declare function createTelegramBusAgentMessageClient(deps: TelegramBusFol
371
371
  routeMessage: (message: TelegramBusAgentMessage) => Promise<void>;
372
372
  };
373
373
  export declare function createTelegramBusFollowerApiCaller(deps: TelegramBusFollowerApiCallerDeps): (method: string, args: unknown[]) => Promise<unknown>;
374
- export declare function createTelegramBusFollowerSessionReplacementSuspender(deps: TelegramBusFollowerSessionReplacementSuspenderDeps): () => Promise<void>;
374
+ export declare function createTelegramBusFollowerSessionReplacementSuspender(deps: TelegramBusFollowerSessionReplacementSuspenderDeps): (preserveTarget?: boolean) => Promise<void>;
375
375
  export declare function createTelegramBusFollowerSessionRefreshHook<TContext>(deps: TelegramBusFollowerSessionRefreshHookDeps<TContext>): (_event: unknown, ctx: TContext) => Promise<void>;
376
376
  export declare function createTelegramBusFollowerControlState(): TelegramBusFollowerControlState;
377
377
  export declare function createTelegramBusFollowerRegistrationState(options?: {
@@ -519,7 +519,13 @@ function isTelegramStaleContextError(error) {
519
519
  export function createTelegramBusFollowerSessionReplacementSuspender(deps) {
520
520
  const getNowMs = deps.getNowMs ?? Date.now;
521
521
  const getPid = deps.getPid ?? (() => process.pid);
522
- return async () => {
522
+ return async (preserveTarget = true) => {
523
+ if (!preserveTarget) {
524
+ setTelegramFollowerSessionHandoff(undefined);
525
+ Threads.setTelegramLeaderSessionHandoff(undefined);
526
+ await deps.suspendPolling();
527
+ return;
528
+ }
523
529
  const target = deps.registrationState.getTarget();
524
530
  if (deps.registrationState.isRegistered() && target) {
525
531
  setTelegramFollowerSessionHandoff({
@@ -6,7 +6,7 @@
6
6
  import { type TelegramConfigStore } from "./config.ts";
7
7
  import type * as Pi from "./pi.ts";
8
8
  import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
9
- import type { TelegramBridgeStatusLineOptions } from "./status.ts";
9
+ import { type TelegramBridgeStatusLineOptions } from "./status.ts";
10
10
  import type { TelegramSessionReplacementIntent } from "./threads.ts";
11
11
  import { type PendingTelegramControlItem, type TelegramQueueAdmissionReceipt } from "./queue.ts";
12
12
  export interface ParsedTelegramCommand {
@@ -112,12 +112,21 @@ export interface TelegramBridgeCommandRegistrationDeps {
112
112
  startPolling: (ctx: ExtensionCommandContext, options?: TelegramBridgeCommandStartPollingOptions) => void | Promise<void | TelegramBridgeCommandStartPollingResult> | TelegramBridgeCommandStartPollingResult;
113
113
  stopPolling: () => Promise<void | string>;
114
114
  recoverPollingStart?: (error: unknown) => Promise<TelegramPollingStartRecoveryResult>;
115
+ recordConnectionEvent?: (error: unknown, phase: string) => void;
115
116
  getDisconnectThreadName?: () => string | undefined;
116
117
  queueAgentConnectionContext?: (connected: boolean) => void;
117
118
  updateStatus: (ctx: ExtensionCommandContext) => void;
119
+ isContextCurrent?: (ctx: ExtensionCommandContext) => boolean;
120
+ getSessionGeneration?: () => number;
121
+ connectionIntent?: {
122
+ begin(cwd: string, profileName?: string): string;
123
+ finish(id: string): void;
124
+ isActive(id: string): boolean;
125
+ cancel(): void;
126
+ };
118
127
  getProfileNames?: () => string[];
119
- activateDefaultProfileConfig?: (ctx: ExtensionCommandContext) => Promise<void>;
120
- activateProfileConfig?: (ctx: ExtensionCommandContext, profileName: string) => Promise<boolean>;
128
+ activateDefaultProfileConfig?: (ctx: ExtensionCommandContext, isCurrent: () => boolean) => Promise<void>;
129
+ activateProfileConfig?: (ctx: ExtensionCommandContext, profileName: string, isCurrent: () => boolean) => Promise<boolean>;
121
130
  }
122
131
  export type TelegramThreadDisplayNameRenamePort = (target: {
123
132
  chatId: number;
@@ -6,6 +6,7 @@
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { pairTelegramUserIfNeeded, TELEGRAM_DEFAULT_PROFILE_NAME, } from "./config.js";
8
8
  import { escapeHtml } from "./rendering.js";
9
+ import { formatTelegramConnectionFailure } from "./status.js";
9
10
  import { createTelegramControlItemBuilder, createTelegramControlQueueController, createTelegramQueueAdmissionReceipt, } from "./queue.js";
10
11
  const TELEGRAM_EXTENSION_COMMAND_REGISTRY_KEY = "__piTelegramCommandRegistry__";
11
12
  const TELEGRAM_BOT_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
@@ -270,121 +271,174 @@ export function registerTelegramBridgeCommands(pi, deps) {
270
271
  pi.registerCommand("telegram-connect", {
271
272
  description: "<profile> — Start Telegram bridge",
272
273
  handler: async (args, ctx) => {
274
+ const sessionGeneration = deps.getSessionGeneration?.();
275
+ let intentId;
276
+ // Pi context getters throw after replacement; check plain intent/generation first.
277
+ const isCurrent = () => (!intentId || deps.connectionIntent?.isActive(intentId) !== false) &&
278
+ (sessionGeneration === undefined || deps.getSessionGeneration?.() === sessionGeneration) &&
279
+ deps.isContextCurrent?.(ctx) !== false;
280
+ if (!isCurrent())
281
+ return;
273
282
  if (args.trim().split(/\s+/).some((word) => /^as=/i.test(word))) {
274
283
  ctx.ui.notify("Thread names are configured from Telegram, not from Pi commands.", "warning");
275
284
  deps.updateStatus(ctx);
276
285
  return;
277
286
  }
278
287
  const profileName = parseTelegramProfileArg(args);
279
- if (profileName && deps.activateProfileConfig) {
280
- const ok = await deps.activateProfileConfig(ctx, profileName);
281
- if (!ok) {
282
- ctx.ui.notify(`Profile "${profileName}" not found.`, "error");
283
- deps.updateStatus(ctx);
284
- return;
285
- }
286
- ctx.ui.notify(`Activated profile "${profileName}".`, "info");
287
- }
288
- else {
289
- await (deps.activateDefaultProfileConfig?.(ctx) ?? deps.reloadConfig());
290
- }
291
- if (!deps.hasBotToken()) {
292
- const botTokenDiagnostic = deps.getBotTokenDiagnostic?.();
293
- if (botTokenDiagnostic)
294
- ctx.ui.notify(botTokenDiagnostic, "error");
295
- const profileNames = deps.getProfileNames?.() ?? [];
296
- if (!profileName && profileNames.length > 0) {
297
- ctx.ui.notify(`No default Telegram profile configured. Available profiles: ${profileNames.join(", ")}. Use /telegram-connect <profileName> or /telegram-setup to create a default profile.`, "info");
298
- deps.updateStatus(ctx);
299
- return;
288
+ intentId = deps.connectionIntent?.begin(ctx.cwd, profileName);
289
+ try {
290
+ if (profileName && deps.activateProfileConfig) {
291
+ const ok = await deps.activateProfileConfig(ctx, profileName, isCurrent);
292
+ if (!isCurrent())
293
+ return;
294
+ if (!ok) {
295
+ ctx.ui.notify(`Profile "${profileName}" not found.`, "error");
296
+ deps.updateStatus(ctx);
297
+ return;
298
+ }
299
+ ctx.ui.notify(`Activated profile "${profileName}".`, "info");
300
300
  }
301
- await deps.promptForConfig(ctx, profileName);
302
- return;
303
- }
304
- let recoveryUsed = false;
305
- const startWithRecovery = async (options) => {
306
- try {
307
- return await deps.startPolling(ctx, options);
301
+ else {
302
+ await (deps.activateDefaultProfileConfig?.(ctx, isCurrent) ?? deps.reloadConfig());
303
+ if (!isCurrent())
304
+ return;
308
305
  }
309
- catch (error) {
310
- if (!deps.recoverPollingStart || recoveryUsed)
311
- throw error;
312
- const recovery = await deps.recoverPollingStart(error);
313
- if (recovery.kind === "unhandled")
314
- throw error;
315
- if (recovery.kind === "blocked") {
316
- return { ok: false, message: recovery.message };
306
+ if (!deps.hasBotToken()) {
307
+ const botTokenDiagnostic = deps.getBotTokenDiagnostic?.();
308
+ if (botTokenDiagnostic)
309
+ ctx.ui.notify(botTokenDiagnostic, "error");
310
+ const profileNames = deps.getProfileNames?.() ?? [];
311
+ if (!profileName && profileNames.length > 0) {
312
+ ctx.ui.notify(`No default Telegram profile configured. Available profiles: ${profileNames.join(", ")}. Use /telegram-connect <profileName> or /telegram-setup to create a default profile.`, "info");
313
+ deps.updateStatus(ctx);
314
+ return;
317
315
  }
318
- recoveryUsed = true;
316
+ await deps.promptForConfig(ctx, profileName);
317
+ return;
318
+ }
319
+ let recoveryUsed = false;
320
+ const startWithRecovery = async (options) => {
319
321
  try {
320
- const retry = await deps.startPolling(ctx, options);
321
- if (!retry) {
322
- return { ok: true, message: recovery.message };
322
+ return await deps.startPolling(ctx, options);
323
+ }
324
+ catch (error) {
325
+ if (!isCurrent())
326
+ return;
327
+ if (!deps.recoverPollingStart || recoveryUsed)
328
+ throw error;
329
+ deps.recordConnectionEvent?.(error, "polling-start");
330
+ const recovery = await deps.recoverPollingStart(error);
331
+ if (!isCurrent())
332
+ return;
333
+ if (recovery.kind === "unhandled")
334
+ throw error;
335
+ if (recovery.kind === "blocked") {
336
+ return { ok: false, message: recovery.message,
337
+ notice: "Telegram recovery blocked. Check /telegram-status --debug." };
338
+ }
339
+ recoveryUsed = true;
340
+ deps.recordConnectionEvent?.(recovery.message, "recovery");
341
+ try {
342
+ const retry = await deps.startPolling(ctx, options);
343
+ if (!isCurrent())
344
+ return;
345
+ if (!retry)
346
+ return { ok: true, message: "Telegram bridge connected; temporary state recovered." };
347
+ return {
348
+ ...retry,
349
+ message: retry.ok
350
+ ? "Telegram bridge connected; temporary state recovered."
351
+ : retry.message,
352
+ };
353
+ }
354
+ catch (error) {
355
+ if (!isCurrent())
356
+ return;
357
+ deps.recordConnectionEvent?.(error, "recovery-retry");
358
+ return {
359
+ ok: false,
360
+ notice: "Telegram recovery failed. Restart this Pi instance.",
361
+ };
323
362
  }
324
- return {
325
- ...retry,
326
- message: retry.ok
327
- ? `${recovery.message} ${retry.message ?? "Telegram bridge connected."}`
328
- : retry.message,
329
- };
330
363
  }
331
- catch {
332
- return {
333
- ok: false,
334
- message: "Telegram temporary state was recovered, but the bridge could not restart. Restart this Pi instance and run /telegram-connect again.",
335
- };
364
+ };
365
+ let result = await startWithRecovery({ forceFreshLeaderThread: true });
366
+ if (!isCurrent())
367
+ return;
368
+ if (result && !result.ok && result.canTakeover) {
369
+ const confirmed = await ctx.ui.confirm(formatTelegramTakeoverTitle(ctx), formatTelegramTakeoverPrompt(ctx, result.owner));
370
+ if (!isCurrent())
371
+ return;
372
+ if (!confirmed) {
373
+ ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
374
+ deps.updateStatus(ctx);
375
+ return;
336
376
  }
377
+ result = await startWithRecovery({ force: true, forceFreshLeaderThread: true });
378
+ if (!isCurrent())
379
+ return;
337
380
  }
338
- };
339
- let result = await startWithRecovery({
340
- forceFreshLeaderThread: true,
341
- });
342
- if (result && !result.ok && result.canTakeover) {
343
- const confirmed = await ctx.ui.confirm(formatTelegramTakeoverTitle(ctx), formatTelegramTakeoverPrompt(ctx, result.owner));
344
- if (!confirmed) {
345
- ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
346
- deps.updateStatus(ctx);
347
- return;
381
+ if (result && !result.ok) {
382
+ if (result.message)
383
+ deps.recordConnectionEvent?.(result.message, "connect-refused");
384
+ ctx.ui.notify(result.notice ?? formatTelegramConnectionFailure(result.message), "warning");
348
385
  }
349
- result = await startWithRecovery({
350
- force: true,
351
- forceFreshLeaderThread: true,
352
- });
386
+ else if (result?.message) {
387
+ ctx.ui.notify(result.message, "info");
388
+ }
389
+ if (!result || result.ok)
390
+ deps.queueAgentConnectionContext?.(true);
391
+ deps.updateStatus(ctx);
353
392
  }
354
- if (result?.message) {
355
- ctx.ui.notify(result.message, result.ok ? "info" : "warning");
393
+ catch (error) {
394
+ if (!isCurrent())
395
+ return;
396
+ deps.recordConnectionEvent?.(error, "connect");
397
+ ctx.ui.notify(formatTelegramConnectionFailure(error), "warning");
398
+ deps.updateStatus(ctx);
356
399
  }
357
- if (!result || result.ok) {
358
- deps.queueAgentConnectionContext?.(true);
400
+ finally {
401
+ if (intentId)
402
+ deps.connectionIntent?.finish(intentId);
359
403
  }
360
- deps.updateStatus(ctx);
361
404
  },
362
405
  });
363
406
  pi.registerCommand("telegram-disconnect", {
364
407
  description: "Stop Telegram and delete current thread in Threaded Mode",
365
408
  handler: async (_args, ctx) => {
366
- const threadName = deps.getDisconnectThreadName?.();
367
- if (threadName) {
368
- const confirmed = await ctx.ui.confirm(ctx.ui.theme.fg("accent", "pi-telegram"), `Delete Telegram thread ${ctx.ui.theme.fg("warning", threadName)} and disconnect this Pi session?`);
369
- if (!confirmed) {
370
- ctx.ui.notify("Telegram disconnect cancelled.", "info");
371
- deps.updateStatus(ctx);
372
- return;
373
- }
374
- }
409
+ const generation = deps.getSessionGeneration?.();
410
+ const isCurrent = () => (generation === undefined || deps.getSessionGeneration?.() === generation) &&
411
+ deps.isContextCurrent?.(ctx) !== false;
412
+ if (!isCurrent())
413
+ return;
414
+ deps.connectionIntent?.cancel();
375
415
  try {
416
+ const threadName = deps.getDisconnectThreadName?.();
417
+ if (threadName) {
418
+ const confirmed = await ctx.ui.confirm(ctx.ui.theme.fg("accent", "pi-telegram"), `Delete Telegram thread ${ctx.ui.theme.fg("warning", threadName)} and disconnect this Pi session?`);
419
+ if (!isCurrent())
420
+ return;
421
+ if (!confirmed) {
422
+ ctx.ui.notify("Telegram disconnect cancelled.", "info");
423
+ return;
424
+ }
425
+ }
376
426
  const message = await deps.stopPolling();
427
+ if (!isCurrent())
428
+ return;
377
429
  if (message)
378
430
  ctx.ui.notify(message, "info");
379
431
  deps.queueAgentConnectionContext?.(false);
380
432
  }
381
433
  catch (error) {
382
- const detail = error instanceof Error ? error.message : String(error);
383
- ctx.ui.notify(`Telegram disconnect did not complete: ${detail} Keep this Pi session open, restore leader connectivity, inspect /telegram-status --debug, and retry /telegram-disconnect.`, "warning");
384
- throw error;
434
+ deps.recordConnectionEvent?.(error, "disconnect");
435
+ if (!isCurrent())
436
+ return;
437
+ ctx.ui.notify("Telegram disconnect incomplete; keep Pi open. Check /telegram-status --debug.", "warning");
385
438
  }
386
439
  finally {
387
- deps.updateStatus(ctx);
440
+ if (isCurrent())
441
+ deps.updateStatus(ctx);
388
442
  }
389
443
  },
390
444
  });
@@ -1361,6 +1361,28 @@ export default function (pi) {
1361
1361
  resolveAutomaticThreadCleanupEnabled: configControls.resolveAutomaticThreadCleanupEnabled,
1362
1362
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1363
1363
  });
1364
+ const connectionIntent = Lifecycle.createTelegramConnectionIntentRuntime();
1365
+ const connectionLifecycle = Lifecycle.createTelegramConnectionLifecycle({
1366
+ intent: connectionIntent,
1367
+ getGeneration: telegramSessionContextStore.getGeneration,
1368
+ isCurrent: telegramSessionContextStore.isCurrent,
1369
+ getProfileName: configStore.getActiveProfileName,
1370
+ isConnected() {
1371
+ return lockRuntime.owns() || telegramBusFollowerRegistrationState.isRegistered();
1372
+ },
1373
+ async activateProfile(profileName, isCurrent) {
1374
+ await configStore.load();
1375
+ if (!isCurrent())
1376
+ return false;
1377
+ return configStore.activateProfile(profileName) && configStore.hasBotToken();
1378
+ },
1379
+ start(ctx) {
1380
+ return lockedPollingRuntime.start(ctx);
1381
+ },
1382
+ recordError(error) {
1383
+ recordRuntimeEvent("connection", error, { phase: "resume-connect" });
1384
+ },
1385
+ });
1364
1386
  const telegramBridgeSessionLifecycleDeps = Lifecycle.createTelegramBridgeSessionLifecycleDeps({
1365
1387
  contextStore: telegramSessionContextStore,
1366
1388
  queue: {
@@ -1408,6 +1430,7 @@ export default function (pi) {
1408
1430
  },
1409
1431
  delivery: deliveryLifecycleRuntime,
1410
1432
  polling: lockedPollingRuntime,
1433
+ connection: connectionLifecycle,
1411
1434
  inboundWorker: {
1412
1435
  onSessionShutdown: updateAdmissionRuntimeBinding.onSessionShutdown,
1413
1436
  },
@@ -1536,6 +1559,9 @@ export default function (pi) {
1536
1559
  modelContextAvailabilityRuntime.reconcile();
1537
1560
  },
1538
1561
  getStatusLines,
1562
+ isContextCurrent: telegramSessionContextStore.isCurrent,
1563
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
1564
+ connectionIntent,
1539
1565
  buttonActionStore,
1540
1566
  sendMarkdownReply,
1541
1567
  async sendChannelMarkdownMessage(channel, markdown, options) {
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Telegram lifecycle hook registration helpers
2
+ * Telegram session lifecycle coordination and hook registration
3
3
  * Zones: pi agent lifecycle, telegram session
4
- * Binds prepared Telegram lifecycle runtimes to pi extension lifecycle events
4
+ * Owns context generations, bounded connect intent across resume, and session sequencing.
5
+ * Transport authority, durable bindings, and queue custody remain with their owners.
5
6
  */
6
7
  import * as BusFollower from "./bus-follower.ts";
7
8
  import * as Queue from "./queue.ts";
@@ -50,6 +51,58 @@ export interface TelegramSessionLifecycleHooks {
50
51
  onSessionStart: (event: SessionStartEvent, ctx: ExtensionContext) => Promise<void>;
51
52
  onSessionShutdown: (event: SessionShutdownEvent, ctx: ExtensionContext) => Promise<void>;
52
53
  }
54
+ export interface TelegramConnectionIntent {
55
+ id: string;
56
+ cwd: string;
57
+ profileName?: string;
58
+ }
59
+ interface TelegramConnectionHandoff extends TelegramConnectionIntent {
60
+ pid: number;
61
+ targetSessionFile: string;
62
+ expiresAtMs: number;
63
+ }
64
+ export interface TelegramConnectionHandoffStore {
65
+ pending?: TelegramConnectionHandoff;
66
+ }
67
+ export interface TelegramConnectionLifecycle {
68
+ prepare(event: SessionStartEvent, ctx: ExtensionContext): (() => void) | undefined;
69
+ onSessionShutdown(event: SessionShutdownEvent, ctx: ExtensionContext): void;
70
+ }
71
+ export declare function createTelegramConnectionLifecycle(deps: {
72
+ intent: ReturnType<typeof createTelegramConnectionIntentRuntime>;
73
+ getGeneration(): number;
74
+ isCurrent(ctx: ExtensionContext): boolean;
75
+ getProfileName(): string | undefined;
76
+ isConnected(): boolean;
77
+ activateProfile(profileName: string | undefined, isCurrent: () => boolean): Promise<boolean>;
78
+ start(ctx: ExtensionContext): Promise<{
79
+ ok: boolean;
80
+ message?: string;
81
+ }>;
82
+ recordError(error: unknown): void;
83
+ }): TelegramConnectionLifecycle;
84
+ export declare function createTelegramConnectionIntentRuntime(options?: {
85
+ store?: TelegramConnectionHandoffStore;
86
+ now?: () => number;
87
+ pid?: number;
88
+ }): {
89
+ begin(cwd: string, profileName?: string): string;
90
+ isActive(id: string): boolean;
91
+ finish(id: string): void;
92
+ cancel(): void;
93
+ suspend(input: {
94
+ reason: string;
95
+ cwd: string;
96
+ targetSessionFile?: string;
97
+ connected: boolean;
98
+ profileName?: string;
99
+ }): void;
100
+ resume(input: {
101
+ reason: string;
102
+ cwd: string;
103
+ sessionFile?: string;
104
+ }): TelegramConnectionIntent | undefined;
105
+ };
53
106
  export interface TelegramSessionContextStore<TContext> {
54
107
  get: () => TContext | undefined;
55
108
  getGeneration: () => number;
@@ -62,6 +115,7 @@ export declare function createTelegramSessionContextStore<TContext>(options?: {
62
115
  }): TelegramSessionContextStore<TContext>;
63
116
  export declare function createTelegramSessionGenerationFence(store: TelegramSessionContextStore<ExtensionContext>, hooks: TelegramSessionLifecycleHooks): TelegramSessionLifecycleHooks;
64
117
  export interface TelegramBridgeSessionServiceRuntime {
118
+ connection?: TelegramConnectionLifecycle;
65
119
  resumeGroupedInput(ctx: ExtensionContext): void;
66
120
  suspendGroupedInput(): void;
67
121
  delivery: {
@@ -108,6 +162,7 @@ export interface TelegramBridgeSessionLifecyclePorts<TQueueItem, TModel = unknow
108
162
  };
109
163
  delivery: TelegramBridgeSessionServiceRuntime["delivery"];
110
164
  polling: TelegramBridgeSessionServiceRuntime["polling"];
165
+ connection?: TelegramBridgeSessionServiceRuntime["connection"];
111
166
  inboundWorker: TelegramBridgeSessionServiceRuntime["inboundWorker"];
112
167
  capabilityMonitor: TelegramBridgeSessionServiceRuntime["capabilityMonitor"];
113
168
  queueWatchdog: TelegramBridgeSessionServiceRuntime["queueWatchdog"];
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Telegram lifecycle hook registration helpers
2
+ * Telegram session lifecycle coordination and hook registration
3
3
  * Zones: pi agent lifecycle, telegram session
4
- * Binds prepared Telegram lifecycle runtimes to pi extension lifecycle events
4
+ * Owns context generations, bounded connect intent across resume, and session sequencing.
5
+ * Transport authority, durable bindings, and queue custody remain with their owners.
5
6
  */
7
+ import { randomUUID } from "node:crypto";
8
+ import { formatTelegramConnectionFailure } from "./status.js";
6
9
  import * as BusFollower from "./bus-follower.js";
7
10
  import * as Queue from "./queue.js";
8
11
  import * as TextGroups from "./text-groups.js";
@@ -23,6 +26,98 @@ export function createAgentStartDedupHook(inner, schedulePublication) {
23
26
  return inner(event, ctx);
24
27
  };
25
28
  }
29
+ const HANDOFF_KEY = Symbol.for("pi-telegram.connection-resume.v1");
30
+ function processHandoffStore() {
31
+ const globals = globalThis;
32
+ return globals[HANDOFF_KEY] ??= {};
33
+ }
34
+ export function createTelegramConnectionLifecycle(deps) {
35
+ return {
36
+ onSessionShutdown(event, ctx) {
37
+ deps.intent.suspend({ reason: event.reason, cwd: ctx.cwd,
38
+ targetSessionFile: event.targetSessionFile, connected: deps.isConnected(),
39
+ profileName: deps.getProfileName() });
40
+ },
41
+ prepare(event, ctx) {
42
+ const intent = deps.intent.resume({ reason: event.reason, cwd: ctx.cwd,
43
+ sessionFile: event.reason === "resume" ? ctx.sessionManager.getSessionFile() : undefined });
44
+ if (!intent)
45
+ return undefined;
46
+ const generation = deps.getGeneration();
47
+ const isCurrent = () => deps.intent.isActive(intent.id) &&
48
+ generation === deps.getGeneration() && deps.isCurrent(ctx);
49
+ return () => {
50
+ // Startup remains extension-owned background work; no captured command ctx crosses resume.
51
+ void (async () => {
52
+ if (!isCurrent())
53
+ return;
54
+ if (!await deps.activateProfile(intent.profileName, isCurrent)) {
55
+ if (isCurrent())
56
+ ctx.ui.notify("Telegram profile unavailable. Run /telegram-setup.", "warning");
57
+ return;
58
+ }
59
+ if (!isCurrent())
60
+ return;
61
+ const result = await deps.start(ctx);
62
+ if (!isCurrent())
63
+ return;
64
+ if (!result.ok) {
65
+ deps.recordError(new Error(result.message ?? "Telegram resume connection failed."));
66
+ ctx.ui.notify(formatTelegramConnectionFailure(result.message), "warning");
67
+ }
68
+ })().catch((error) => {
69
+ deps.recordError(error);
70
+ if (isCurrent())
71
+ ctx.ui.notify(formatTelegramConnectionFailure(error), "warning");
72
+ }).finally(() => deps.intent.finish(intent.id));
73
+ };
74
+ },
75
+ };
76
+ }
77
+ export function createTelegramConnectionIntentRuntime(options = {}) {
78
+ const store = options.store ?? processHandoffStore();
79
+ const now = options.now ?? Date.now;
80
+ const pid = options.pid ?? process.pid;
81
+ let active;
82
+ return {
83
+ begin(cwd, profileName) {
84
+ store.pending = undefined;
85
+ active = { id: randomUUID(), cwd, profileName };
86
+ return active.id;
87
+ },
88
+ isActive(id) {
89
+ return active?.id === id;
90
+ },
91
+ finish(id) {
92
+ if (active?.id === id)
93
+ active = undefined;
94
+ },
95
+ cancel() {
96
+ active = undefined;
97
+ store.pending = undefined;
98
+ },
99
+ suspend(input) {
100
+ const intent = active ?? (input.connected
101
+ ? { id: randomUUID(), cwd: input.cwd, profileName: input.profileName }
102
+ : undefined);
103
+ active = undefined;
104
+ store.pending = input.reason === "resume" && input.targetSessionFile &&
105
+ intent?.cwd === input.cwd
106
+ ? { ...intent, pid, targetSessionFile: input.targetSessionFile, expiresAtMs: now() + 30_000 }
107
+ : undefined;
108
+ },
109
+ resume(input) {
110
+ const handoff = store.pending;
111
+ store.pending = undefined;
112
+ if (!handoff || handoff.pid !== pid || now() >= handoff.expiresAtMs ||
113
+ input.reason !== "resume" || input.cwd !== handoff.cwd ||
114
+ input.sessionFile !== handoff.targetSessionFile)
115
+ return undefined;
116
+ active = { id: randomUUID(), cwd: handoff.cwd, profileName: handoff.profileName };
117
+ return { ...active };
118
+ },
119
+ };
120
+ }
26
121
  export function createTelegramSessionContextStore(options = {}) {
27
122
  let currentContext;
28
123
  let currentIdentity;
@@ -112,6 +207,7 @@ export function createTelegramBridgeSessionLifecycleDeps(ports) {
112
207
  }),
113
208
  delivery: ports.services.delivery,
114
209
  polling: ports.services.polling,
210
+ connection: ports.services.connection,
115
211
  inboundWorker: ports.services.inboundWorker,
116
212
  capabilityMonitor: ports.services.capabilityMonitor,
117
213
  queueWatchdog: ports.services.queueWatchdog,
@@ -128,20 +224,27 @@ export function createTelegramBridgeSessionLifecycleAssembly(deps) {
128
224
  suspendPolling: deps.follower.suspendPolling,
129
225
  recordRuntimeEvent: deps.follower.recordRuntimeEvent,
130
226
  });
227
+ let preserveTarget = true;
131
228
  const queueLifecycle = Queue.createTelegramSessionLifecycleRuntime({
132
229
  ...deps.queue,
133
230
  isSessionActive,
134
- stopPolling: suspendForReplacement,
231
+ stopPolling: () => suspendForReplacement(preserveTarget),
135
232
  clearPendingMediaGroups: deps.services.suspendGroupedInput,
136
233
  });
137
234
  const servicesLifecycle = {
138
235
  async onSessionStart(event, ctx) {
236
+ const resumeConnection = deps.services.connection?.prepare(event, ctx);
139
237
  await queueLifecycle.onSessionStart(event, ctx);
140
238
  if (!isSessionActive(ctx))
141
239
  return;
142
240
  deps.services.resumeGroupedInput(ctx);
143
241
  await deps.services.delivery.onSessionStart();
144
- await deps.services.polling.onSessionStart(event, ctx);
242
+ if (!isSessionActive(ctx))
243
+ return;
244
+ if (resumeConnection)
245
+ resumeConnection();
246
+ else
247
+ await deps.services.polling.onSessionStart(event, ctx);
145
248
  deps.services.capabilityMonitor.start(ctx);
146
249
  deps.services.queueWatchdog.start(ctx);
147
250
  },
@@ -150,6 +253,8 @@ export function createTelegramBridgeSessionLifecycleAssembly(deps) {
150
253
  const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
151
254
  if (!isCurrent())
152
255
  return;
256
+ deps.services.connection?.onSessionShutdown(event, ctx);
257
+ preserveTarget = event.reason !== "resume";
153
258
  let preserveThread;
154
259
  if (event.reason === "quit") {
155
260
  try {
@@ -294,10 +294,8 @@ function truncateTelegramModelSwitchStatusSummary(text, maxWords = 4, maxLength
294
294
  }
295
295
  export function buildTelegramModelSwitchContinuationText(telegramPrefix, model, thinkingLevel) {
296
296
  const modelLabel = `${model.provider}/${model.id}`;
297
- const thinkingSuffix = thinkingLevel
298
- ? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.`
299
- : "";
300
- 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}`;
297
+ const thinkingSuffix = thinkingLevel ? `; thinking: ${thinkingLevel}` : "";
298
+ return `${telegramPrefix} Continue from the last unfinished step. Model: ${modelLabel}${thinkingSuffix}.`;
301
299
  }
302
300
  export function buildTelegramModelSwitchContinuationTurn(options) {
303
301
  const modelLabel = `${options.selection.model.provider}/${options.selection.model.id}`;
@@ -1,8 +1,10 @@
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
+ /** UI copy is allowlisted; raw exception text belongs only in redacted diagnostics. */
7
+ export declare function formatTelegramConnectionFailure(error: unknown): string;
6
8
  export type TelegramStatusQueueLane = "control" | "priority" | "default";
7
9
  export interface TelegramUsageStats {
8
10
  totalInput: number;