@llblab/pi-telegram 0.50.0 → 0.51.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 (73) hide show
  1. package/AGENTS.md +9 -7
  2. package/BACKLOG.md +13 -32
  3. package/CHANGELOG.md +18 -0
  4. package/README.md +5 -5
  5. package/dist/lib/bindings.d.ts +3 -5
  6. package/dist/lib/bindings.js +4 -11
  7. package/dist/lib/bus-follower.d.ts +1 -1
  8. package/dist/lib/bus-follower.js +93 -9
  9. package/dist/lib/bus-leader.d.ts +9 -3
  10. package/dist/lib/bus-leader.js +231 -71
  11. package/dist/lib/bus.d.ts +4 -0
  12. package/dist/lib/bus.js +34 -7
  13. package/dist/lib/commands.d.ts +1 -4
  14. package/dist/lib/commands.js +13 -33
  15. package/dist/lib/extension.js +64 -6
  16. package/dist/lib/journal.d.ts +3 -0
  17. package/dist/lib/journal.js +13 -0
  18. package/dist/lib/lifecycle.d.ts +2 -0
  19. package/dist/lib/lifecycle.js +25 -4
  20. package/dist/lib/locks.d.ts +1 -0
  21. package/dist/lib/locks.js +32 -12
  22. package/dist/lib/menu-queue.d.ts +1 -0
  23. package/dist/lib/menu-queue.js +72 -1
  24. package/dist/lib/paths.d.ts +10 -0
  25. package/dist/lib/paths.js +22 -1
  26. package/dist/lib/polling.js +2 -2
  27. package/dist/lib/queue.d.ts +8 -1
  28. package/dist/lib/queue.js +28 -2
  29. package/dist/lib/replies.d.ts +3 -0
  30. package/dist/lib/replies.js +17 -0
  31. package/dist/lib/sync.d.ts +9 -0
  32. package/dist/lib/sync.js +49 -2
  33. package/dist/lib/telegram-api.d.ts +9 -1
  34. package/dist/lib/telegram-api.js +23 -3
  35. package/dist/lib/thread-cleanup-manager.js +7 -2
  36. package/dist/lib/threads.d.ts +2 -0
  37. package/dist/lib/threads.js +68 -37
  38. package/dist/lib/updates.d.ts +6 -4
  39. package/dist/lib/updates.js +134 -61
  40. package/dist/lib/workspace-admission.d.ts +9 -0
  41. package/dist/lib/workspace-admission.js +49 -3
  42. package/dist/lib/workspace-retirement.d.ts +77 -6
  43. package/dist/lib/workspace-retirement.js +384 -8
  44. package/dist/lib/workspace-slots.d.ts +3 -0
  45. package/dist/lib/workspace-slots.js +6 -0
  46. package/dist/package.json +1 -1
  47. package/docs/architecture.md +23 -14
  48. package/docs/multi-instance-bus.md +32 -11
  49. package/docs/public-api.md +1 -1
  50. package/docs/updates.md +2 -0
  51. package/lib/bindings.ts +5 -17
  52. package/lib/bus-follower.ts +69 -12
  53. package/lib/bus-leader.ts +236 -94
  54. package/lib/bus.ts +39 -9
  55. package/lib/commands.ts +18 -41
  56. package/lib/extension.ts +70 -10
  57. package/lib/journal.ts +14 -0
  58. package/lib/lifecycle.ts +23 -4
  59. package/lib/locks.ts +32 -13
  60. package/lib/menu-queue.ts +126 -1
  61. package/lib/paths.ts +29 -1
  62. package/lib/polling.ts +2 -2
  63. package/lib/queue.ts +48 -3
  64. package/lib/replies.ts +19 -0
  65. package/lib/sync.ts +53 -2
  66. package/lib/telegram-api.ts +30 -2
  67. package/lib/thread-cleanup-manager.ts +11 -3
  68. package/lib/threads.ts +73 -36
  69. package/lib/updates.ts +145 -78
  70. package/lib/workspace-admission.ts +67 -4
  71. package/lib/workspace-retirement.ts +465 -7
  72. package/lib/workspace-slots.ts +7 -0
  73. package/package.json +1 -1
package/lib/commands.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * Owns Telegram slash-command normalization, bot command metadata, pi-side command registration, and command-initiated session replacement orchestration behind runtime ports
5
5
  */
6
6
 
7
+ import { randomUUID } from "node:crypto";
8
+
7
9
  import {
8
10
  pairTelegramUserIfNeeded,
9
11
  type TelegramConfigStore,
@@ -366,7 +368,6 @@ export function createTelegramBotCommandRegistrar(
366
368
  export interface TelegramBridgeCommandStartPollingOptions {
367
369
  force?: boolean;
368
370
  forceFreshLeaderThread?: boolean;
369
- requestedThreadName?: string;
370
371
  }
371
372
 
372
373
  export interface TelegramBridgeCommandStartPollingResult {
@@ -407,7 +408,6 @@ export interface TelegramBridgeCommandRegistrationDeps {
407
408
  ctx: ExtensionCommandContext,
408
409
  profileName: string,
409
410
  ) => Promise<boolean>;
410
- validateThreadName?: (threadName: string) => string | undefined;
411
411
  }
412
412
 
413
413
  export type TelegramThreadDisplayNameRenamePort = (
@@ -466,17 +466,6 @@ function parseTelegramProfileArg(args: string): string | undefined {
466
466
  return word === TELEGRAM_DEFAULT_PROFILE_NAME ? undefined : word;
467
467
  }
468
468
 
469
- export function parseTelegramRequestedThreadName(
470
- args: string,
471
- ): string | undefined {
472
- const token = args
473
- .trim()
474
- .split(/\s+/)
475
- .find((word) => /^as=/i.test(word));
476
- const value = token?.slice(3).trim();
477
- return value || undefined;
478
- }
479
-
480
469
  function formatTelegramTakeoverTitle(ctx: ExtensionCommandContext): string {
481
470
  return ctx.ui.theme.fg("accent", "pi-telegram");
482
471
  }
@@ -498,7 +487,7 @@ export function registerTelegramBridgeCommands(
498
487
  deps: TelegramBridgeCommandRegistrationDeps,
499
488
  ): void {
500
489
  pi.registerCommand("telegram-setup", {
501
- description: "Configure Telegram bot token. Use /telegram-setup <name> for named profiles.",
490
+ description: "<profile> — Configure Telegram bot token",
502
491
  handler: async (args, ctx) => {
503
492
  await deps.promptForConfig(ctx, parseTelegramProfileArg(args));
504
493
  },
@@ -513,28 +502,17 @@ export function registerTelegramBridgeCommands(
513
502
  },
514
503
  });
515
504
  pi.registerCommand("telegram-connect", {
516
- description:
517
- "Start the Telegram bridge. Use /telegram-connect <profile> and optional as=Name for a fresh Workspace Thread.",
505
+ description: "<profile> — Start Telegram bridge",
518
506
  handler: async (args, ctx) => {
519
- const profileName = parseTelegramProfileArg(args);
520
- const requestedNameTokens = args
521
- .trim()
522
- .split(/\s+/)
523
- .filter((word) => /^as=/i.test(word));
524
- const requestedThreadName = parseTelegramRequestedThreadName(args);
525
- const requestedNameError =
526
- requestedNameTokens.length > 1
527
- ? "Specify at most one as=Name Workspace Thread name."
528
- : requestedNameTokens.length === 1 && !requestedThreadName
529
- ? "Usage: /telegram-connect [profile] as=Flightprice"
530
- : requestedThreadName
531
- ? deps.validateThreadName?.(requestedThreadName)
532
- : undefined;
533
- if (requestedNameError) {
534
- ctx.ui.notify(requestedNameError, "warning");
507
+ if (args.trim().split(/\s+/).some((word) => /^as=/i.test(word))) {
508
+ ctx.ui.notify(
509
+ "Thread names are configured from Telegram, not from Pi commands.",
510
+ "warning",
511
+ );
535
512
  deps.updateStatus(ctx);
536
513
  return;
537
514
  }
515
+ const profileName = parseTelegramProfileArg(args);
538
516
  if (profileName && deps.activateProfileConfig) {
539
517
  const ok = await deps.activateProfileConfig(ctx, profileName);
540
518
  if (!ok) {
@@ -597,7 +575,6 @@ export function registerTelegramBridgeCommands(
597
575
  };
598
576
  let result = await startWithRecovery({
599
577
  forceFreshLeaderThread: true,
600
- ...(requestedThreadName ? { requestedThreadName } : {}),
601
578
  });
602
579
  if (result && !result.ok && result.canTakeover) {
603
580
  const confirmed = await ctx.ui.confirm(
@@ -612,7 +589,6 @@ export function registerTelegramBridgeCommands(
612
589
  result = await startWithRecovery({
613
590
  force: true,
614
591
  forceFreshLeaderThread: true,
615
- ...(requestedThreadName ? { requestedThreadName } : {}),
616
592
  });
617
593
  }
618
594
  if (result?.message) {
@@ -625,8 +601,7 @@ export function registerTelegramBridgeCommands(
625
601
  },
626
602
  });
627
603
  pi.registerCommand("telegram-disconnect", {
628
- description:
629
- "Stop Telegram; in Threaded Mode, delete this instance's current thread",
604
+ description: "Stop Telegram and delete current thread in Threaded Mode",
630
605
  handler: async (_args, ctx) => {
631
606
  const threadName = deps.getDisconnectThreadName?.();
632
607
  if (threadName) {
@@ -2337,7 +2312,7 @@ async function handleTelegramCommandRuntime<
2337
2312
 
2338
2313
  export const TELEGRAM_INTERNAL_COMMAND_NAME = "telegram-internal";
2339
2314
  export const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION =
2340
- "(internal) dispatch one settled Telegram lifecycle action";
2315
+ "Internal Telegram command cannot be run manually";
2341
2316
  export const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE =
2342
2317
  "This internal Telegram command cannot be run manually.";
2343
2318
 
@@ -2542,6 +2517,7 @@ export function createTelegramSessionActionAssembly(
2542
2517
 
2543
2518
  type TelegramPendingInternalAction = {
2544
2519
  kind: "replace-session";
2520
+ token: string;
2545
2521
  updateId: number;
2546
2522
  target: { chatId: number; threadId?: number; messageId: number };
2547
2523
  };
@@ -2582,9 +2558,9 @@ export function createTelegramSessionActionRuntime(
2582
2558
  registered = true;
2583
2559
  deps.registerCommand(TELEGRAM_INTERNAL_COMMAND_NAME, {
2584
2560
  description: TELEGRAM_INTERNAL_COMMAND_DESCRIPTION,
2585
- handler: async (_args, ctx) => {
2561
+ handler: async (args, ctx) => {
2586
2562
  const action = pendingAction;
2587
- if (!action) {
2563
+ if (!action || args.trim() !== action.token) {
2588
2564
  ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
2589
2565
  return;
2590
2566
  }
@@ -2616,10 +2592,11 @@ export function createTelegramSessionActionRuntime(
2616
2592
  const target = pendingTarget;
2617
2593
  pendingTarget = undefined;
2618
2594
  if (!target) return;
2619
- pendingAction = { kind: "replace-session", updateId, target };
2595
+ const token = randomUUID();
2596
+ pendingAction = { kind: "replace-session", token, updateId, target };
2620
2597
  void Promise.resolve()
2621
2598
  .then(() =>
2622
- deps.sendUserMessage(`/${TELEGRAM_INTERNAL_COMMAND_NAME}`, {
2599
+ deps.sendUserMessage(`/${TELEGRAM_INTERNAL_COMMAND_NAME} ${token}`, {
2623
2600
  expandPromptTemplates: true,
2624
2601
  }),
2625
2602
  )
package/lib/extension.ts CHANGED
@@ -104,7 +104,7 @@ export default function (pi: Pi.ExtensionAPI) {
104
104
  WorkspaceAdmission.createTelegramWorkspaceAdmissionRuntimeBinding({
105
105
  getProfileName: configStore.getActiveProfileName,
106
106
  getBotToken: configStore.getBotToken,
107
- getPath: Paths.resolveTelegramWorkspaceAdmissionPath,
107
+ getPath: Paths.resolveTelegramWorkspaceAdmissionPathForProfile,
108
108
  owner: {
109
109
  processId: telegramProcessId,
110
110
  processBirthId: telegramQueueProcessBirthId,
@@ -187,7 +187,7 @@ export default function (pi: Pi.ExtensionAPI) {
187
187
  },
188
188
  getWorkspaceAdmission: workspaceAdmissionRuntime.resolve,
189
189
  },
190
- getLeaderJournalPath: Paths.resolveTelegramUpdateJournalPath,
190
+ getLeaderJournalPath: Paths.resolveTelegramUpdateJournalPathForProfile,
191
191
  getFollowerJournalPath(bindingKey, profileName) {
192
192
  return Paths.resolveTelegramFollowerJournalPath(
193
193
  bindingKey,
@@ -387,6 +387,51 @@ export default function (pi: Pi.ExtensionAPI) {
387
387
  : "clear";
388
388
  },
389
389
  });
390
+ const reclaimTelegramWorkspaceDeadOwnerQueue =
391
+ WorkspaceRetirement.createTelegramWorkspaceDeadOwnerQueueReclaimer({
392
+ getExternalProtection: captureWorkspaceExternalProtection,
393
+ getActiveTurnTarget: activeTurnRuntime.getTarget,
394
+ getQueuedItems: telegramQueueStore.getQueuedItems,
395
+ resolveLeaderJournal: resolveTelegramUpdateJournalBinding,
396
+ createFollowerJournalResolver:
397
+ telegramJournalBindingRuntime.createRecipientResolver,
398
+ discoverFollowerJournals() {
399
+ return Journal.discoverTelegramFollowerJournalPaths({
400
+ directory: Paths.resolveTelegramTempDir(),
401
+ profileName: configStore.getActiveProfileName(),
402
+ });
403
+ },
404
+ createJournalPathResolver: telegramJournalBindingRuntime.createPathResolver,
405
+ withJournalReference(binding, operation) {
406
+ if (!binding.recoveryKey) throw new Error(
407
+ "Telegram workspace journal reference identity is unavailable.",
408
+ );
409
+ return telegramJournalReferenceRegistry.withReference({
410
+ referenceClass: "workspace-retirement",
411
+ recoveryKey: binding.recoveryKey,
412
+ }, operation);
413
+ },
414
+ getRecoveryOwner() {
415
+ return {
416
+ instanceId: telegramInstanceId,
417
+ processId: telegramProcessId,
418
+ processBirthId: telegramQueueProcessBirthId,
419
+ sessionGeneration: telegramSessionContextStore.getGeneration(),
420
+ };
421
+ },
422
+ getQueueOwnerLiveness: Bus.getTelegramProcessLiveness,
423
+ onMutationError(error) {
424
+ recordRuntimeEvent("Telegram dead-owner queue reclamation was refused.", {
425
+ error: error instanceof Error ? error.message : String(error),
426
+ });
427
+ },
428
+ isBindingCurrent(binding) {
429
+ return WorkspaceRetirement.isCurrentTelegramWorkspaceBinding(
430
+ threadStore,
431
+ binding,
432
+ );
433
+ },
434
+ });
390
435
  const inactiveThreadCleanupReviewRuntime =
391
436
  ThreadCleanupManager.createTelegramInactiveThreadCleanupReviewRuntime({
392
437
  getProfileName() { return configStore.getActiveProfileName() ?? "default"; },
@@ -871,6 +916,7 @@ export default function (pi: Pi.ExtensionAPI) {
871
916
  storeModelMenuState: modelMenuRuntime.storeState,
872
917
  updateStatusMessage: menuActions.updateStatusMessage,
873
918
  updateStatus,
919
+ dismissGuestPlaceholder: guestPlaceholderRuntime.dismiss,
874
920
  });
875
921
  const threadDisplaySettingsRuntime =
876
922
  ThreadDisplay.createTelegramThreadDisplaySettingsRuntime({
@@ -1409,6 +1455,18 @@ export default function (pi: Pi.ExtensionAPI) {
1409
1455
  getWorkspaceAdmission: workspaceAdmissionRuntime.resolve,
1410
1456
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1411
1457
  captureWorkspaceExternalProtection,
1458
+ workspaceRotation: {
1459
+ getAdmission: workspaceAdmissionRuntime.resolve,
1460
+ runExclusive: telegramWorkspaceOperationRuntime.runExclusive,
1461
+ deleteThread: directTelegramApiRuntime.deleteWorkspaceThread,
1462
+ reclaimDeadOwnerQueuedWork(binding, isCurrent) {
1463
+ return telegramWorkspaceOperationRuntime.run({
1464
+ operationId: WorkspaceAdmission.createTelegramWorkspaceAdmissionOperationId(),
1465
+ operationKind: "workspace.reclaim-dead-owner-queue",
1466
+ scopes: [{ kind: "target", target: binding.target }],
1467
+ }, reclaimTelegramWorkspaceDeadOwnerQueue.bind(undefined, binding, isCurrent));
1468
+ },
1469
+ },
1412
1470
  callApi(method, body, options) {
1413
1471
  return directTelegramApiRuntime.call(method, body, options);
1414
1472
  },
@@ -1544,6 +1602,15 @@ export default function (pi: Pi.ExtensionAPI) {
1544
1602
  recordRuntimeEvent,
1545
1603
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1546
1604
  });
1605
+ const prepareThreadPreservationOnQuit = Sync.createTelegramPreservedLeaderQuitHandler({
1606
+ instanceId: telegramInstanceId,
1607
+ topicTargetStore: threadStore,
1608
+ getCurrentLeaderEpoch,
1609
+ getProfileName: configStore.getActiveProfileName,
1610
+ isPollingSuspended: lockedPollingRuntime.isSuspended,
1611
+ resolveAutomaticThreadCleanupEnabled: configControls.resolveAutomaticThreadCleanupEnabled,
1612
+ runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1613
+ });
1547
1614
  const telegramBridgeSessionLifecycleDeps =
1548
1615
  Lifecycle.createTelegramBridgeSessionLifecycleDeps({
1549
1616
  contextStore: telegramSessionContextStore,
@@ -1598,6 +1665,7 @@ export default function (pi: Pi.ExtensionAPI) {
1598
1665
  capabilityMonitor: telegramThreadCapabilityMonitor,
1599
1666
  queueWatchdog: queueDispatchWatchdogRuntime,
1600
1667
  guestPlaceholder: { stopAll: guestPlaceholderRuntime.stopAll },
1668
+ prepareThreadPreservationOnQuit,
1601
1669
  },
1602
1670
  });
1603
1671
  const sessionLifecycleRuntime =
@@ -1731,14 +1799,6 @@ export default function (pi: Pi.ExtensionAPI) {
1731
1799
  if (!record?.target.threadId) return undefined;
1732
1800
  return record.threadName ?? "current Telegram thread";
1733
1801
  },
1734
- setRequestedThreadNameForPollingStart:
1735
- telegramThreadCapabilityState.setRequestedThreadName,
1736
- validateThreadName(threadName) {
1737
- return Threads.getTelegramTopicThreadNameValidationError(
1738
- threadName,
1739
- undefined,
1740
- );
1741
- },
1742
1802
  onTransportChanged() {
1743
1803
  deliveryLifecycleRuntime.onSessionStart();
1744
1804
  activityVerbosityRuntime.reset();
package/lib/journal.ts CHANGED
@@ -2428,6 +2428,7 @@ export interface TelegramUpdateJournalRuntimeBinding {
2428
2428
  runtimeKey: string;
2429
2429
  recoveryKey: string;
2430
2430
  journal: TelegramUpdateJournalStore;
2431
+ readForProtection?: () => { entries: readonly TelegramUpdateJournalEntry[] };
2431
2432
  }
2432
2433
 
2433
2434
  export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
@@ -2470,6 +2471,19 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
2470
2471
  profileName,
2471
2472
  botIdentity,
2472
2473
  }),
2474
+ readForProtection() {
2475
+ // Protection must never turn corruption/recovery into empty-work evidence.
2476
+ const evidence = inspectTelegramUpdateJournalFamily({
2477
+ directory: dirname(path), path, profile: profileName, botIdentity,
2478
+ limits: {
2479
+ maxFiles: 1024,
2480
+ maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
2481
+ maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES,
2482
+ maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
2483
+ },
2484
+ });
2485
+ return { entries: evidence.kind === "present" ? evidence.file.entries : [] };
2486
+ },
2473
2487
  journal: createTelegramUpdateJournalStore({
2474
2488
  path,
2475
2489
  profileName,
package/lib/lifecycle.ts CHANGED
@@ -244,7 +244,7 @@ export function createTelegramSessionGenerationFence(
244
244
  const generation = store.getGeneration();
245
245
  if (!store.isCurrent(ctx, generation)) return;
246
246
  await hooks.onSessionShutdown(event, ctx);
247
- store.clear(ctx);
247
+ if (store.isCurrent(ctx, generation)) store.clear(ctx);
248
248
  },
249
249
  };
250
250
  }
@@ -266,6 +266,7 @@ export interface TelegramBridgeSessionServiceRuntime {
266
266
  capabilityMonitor: { start(ctx: ExtensionContext): void; stop(): void };
267
267
  queueWatchdog: { start(ctx: ExtensionContext): void; stop(): void };
268
268
  guestPlaceholder?: { stopAll(): void };
269
+ prepareThreadPreservationOnQuit?: (isSessionCurrent: () => boolean) => (() => Promise<void>) | undefined;
269
270
  }
270
271
 
271
272
  export interface TelegramBridgeSessionLifecycleAssemblyDeps<
@@ -322,6 +323,7 @@ export interface TelegramBridgeSessionLifecyclePorts<
322
323
  capabilityMonitor: TelegramBridgeSessionServiceRuntime["capabilityMonitor"];
323
324
  queueWatchdog: TelegramBridgeSessionServiceRuntime["queueWatchdog"];
324
325
  guestPlaceholder?: TelegramBridgeSessionServiceRuntime["guestPlaceholder"];
326
+ prepareThreadPreservationOnQuit?: TelegramBridgeSessionServiceRuntime["prepareThreadPreservationOnQuit"];
325
327
  };
326
328
  }
327
329
 
@@ -350,6 +352,7 @@ export function createTelegramBridgeSessionLifecycleDeps<
350
352
  capabilityMonitor: ports.services.capabilityMonitor,
351
353
  queueWatchdog: ports.services.queueWatchdog,
352
354
  guestPlaceholder: ports.services.guestPlaceholder,
355
+ prepareThreadPreservationOnQuit: ports.services.prepareThreadPreservationOnQuit,
353
356
  },
354
357
  };
355
358
  }
@@ -385,15 +388,31 @@ export function createTelegramBridgeSessionLifecycleAssembly<
385
388
  deps.services.queueWatchdog.start(ctx);
386
389
  },
387
390
  async onSessionShutdown(event, ctx) {
388
- if (!isSessionActive(ctx)) return;
391
+ const generation = deps.contextStore.getGeneration();
392
+ const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
393
+ if (!isCurrent()) return;
394
+ let preserveThread: (() => Promise<void>) | undefined;
395
+ if (event.reason === "quit") {
396
+ try {
397
+ preserveThread = deps.services.prepareThreadPreservationOnQuit?.(isCurrent);
398
+ } catch (error) {
399
+ deps.follower.recordRuntimeEvent("session", error, { phase: "preserve-thread-on-quit" });
400
+ }
401
+ }
389
402
  deps.services.guestPlaceholder?.stopAll();
390
403
  await deps.services.delivery.onSessionShutdown();
391
- if (!isSessionActive(ctx)) return;
404
+ if (!isCurrent()) return;
392
405
  deps.services.queueWatchdog.stop();
393
406
  deps.services.capabilityMonitor.stop();
394
407
  await queueLifecycle.onSessionShutdown(event, ctx);
395
- if (!isSessionActive(ctx)) return;
408
+ if (!isCurrent()) return;
396
409
  await deps.services.inboundWorker.onSessionShutdown();
410
+ if (!isCurrent()) return;
411
+ try {
412
+ await preserveThread?.();
413
+ } catch (error) {
414
+ deps.follower.recordRuntimeEvent("session", error, { phase: "preserve-thread-on-quit" });
415
+ }
397
416
  },
398
417
  };
399
418
  const followerLifecycle = appendTelegramLifecycleHooks(
package/lib/locks.ts CHANGED
@@ -739,7 +739,8 @@ export function isProcessAlive(pid: number): boolean {
739
739
  process.kill(pid, 0);
740
740
  return true;
741
741
  } catch (error) {
742
- return (error as { code?: string }).code === "EPERM";
742
+ // Only an absent PID proves death; permission and unexpected failures do not.
743
+ return (error as { code?: string }).code !== "ESRCH";
743
744
  }
744
745
  }
745
746
 
@@ -1126,6 +1127,7 @@ export interface TelegramLockedPollingRuntime<
1126
1127
  ) => Promise<TelegramLockedPollingStartResult>;
1127
1128
  stop: () => Promise<string>;
1128
1129
  suspend: () => Promise<void>;
1130
+ isSuspended: () => boolean;
1129
1131
  onPersistentConflict: (ctx: TContext, count: number) => Promise<void>;
1130
1132
  onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
1131
1133
  registerFollowerWithOwner?: (
@@ -1190,6 +1192,9 @@ export function createTelegramLockedPollingRuntime<
1190
1192
  let takeoverCandidate: TelegramLockEntry | undefined;
1191
1193
  let sessionAutoStartRun: Promise<void> | undefined;
1192
1194
  let pollingGeneration = 0;
1195
+ let suspendedGeneration: number | undefined;
1196
+ let suspensionsInFlight = 0;
1197
+ let startupsInFlight = 0;
1193
1198
  const ownershipCheckMs =
1194
1199
  deps.ownershipCheckMs ?? TELEGRAM_OWNERSHIP_CHECK_MS;
1195
1200
  const ownershipRefreshMs =
@@ -1201,20 +1206,29 @@ export function createTelegramLockedPollingRuntime<
1201
1206
  ownershipRefreshInterval = undefined;
1202
1207
  };
1203
1208
  const suspendPolling = async () => {
1204
- pollingGeneration += 1;
1205
- activeContext = undefined;
1206
- deps.transportMonitor?.stop();
1207
- deps.stopFollowerRegistration?.();
1208
- stopOwnershipWatcher();
1209
- if (sessionAutoStartRun) {
1210
- await sessionAutoStartRun;
1209
+ const generation = ++pollingGeneration;
1210
+ suspensionsInFlight += 1;
1211
+ try {
1212
+ activeContext = undefined;
1213
+ deps.transportMonitor?.stop();
1211
1214
  deps.stopFollowerRegistration?.();
1215
+ stopOwnershipWatcher();
1216
+ if (sessionAutoStartRun) {
1217
+ await sessionAutoStartRun;
1218
+ deps.stopFollowerRegistration?.();
1219
+ }
1220
+ if (ownershipStop) {
1221
+ await ownershipStop;
1222
+ return;
1223
+ }
1224
+ await deps.stopPolling();
1225
+ // Unsettled starts, overlapping stops or stale completion cannot certify quiescence.
1226
+ if (generation === pollingGeneration && suspensionsInFlight === 1 && startupsInFlight === 0) {
1227
+ suspendedGeneration = generation;
1228
+ }
1229
+ } finally {
1230
+ suspensionsInFlight -= 1;
1212
1231
  }
1213
- if (ownershipStop) {
1214
- await ownershipStop;
1215
- return;
1216
- }
1217
- await deps.stopPolling();
1218
1232
  };
1219
1233
  const stopAfterOwnershipLoss = () => {
1220
1234
  if (ownershipStop) return;
@@ -1261,6 +1275,7 @@ export function createTelegramLockedPollingRuntime<
1261
1275
  if (!isCurrent()) return false;
1262
1276
  activeContext = ctx;
1263
1277
  startOwnershipWatcher(ctx);
1278
+ startupsInFlight += 1;
1264
1279
  try {
1265
1280
  if (!deps.lock.refresh(snapshotLockContext(ctx))) {
1266
1281
  stopOwnershipWatcher();
@@ -1283,6 +1298,8 @@ export function createTelegramLockedPollingRuntime<
1283
1298
  deps.lock.release();
1284
1299
  deps.onTransportAvailabilityChanged?.();
1285
1300
  throw error;
1301
+ } finally {
1302
+ startupsInFlight -= 1;
1286
1303
  }
1287
1304
  if (!isCurrent()) return false;
1288
1305
  if (deps.lock.owns(ctx)) {
@@ -1421,6 +1438,8 @@ export function createTelegramLockedPollingRuntime<
1421
1438
  return "Telegram bridge disconnected.";
1422
1439
  },
1423
1440
  suspend: suspendPolling,
1441
+ isSuspended: () => suspendedGeneration === pollingGeneration &&
1442
+ suspensionsInFlight === 0 && startupsInFlight === 0 && !sessionAutoStartRun && !ownershipStop,
1424
1443
  onPersistentConflict: async (ctx, count) => {
1425
1444
  if (activeContext === undefined || ownershipStop) return;
1426
1445
  if (!(deps.isContextCurrent?.(ctx) ?? activeContext === ctx)) return;
package/lib/menu-queue.ts CHANGED
@@ -31,6 +31,8 @@ type TelegramQueueMenuReplyMarkup = TelegramInlineKeyboardMarkup;
31
31
  interface TelegramQueueMenuItem {
32
32
  chatId: number;
33
33
  replyToMessageId: number;
34
+ queueOrder: number;
35
+ isGuest: boolean;
34
36
  queuePosition: number;
35
37
  isPriority: boolean;
36
38
  priorityEmoji?: string;
@@ -60,6 +62,8 @@ function toTelegramQueueMenuItems<Context>(
60
62
  return {
61
63
  chatId: item.chatId,
62
64
  replyToMessageId: item.replyToMessageId,
65
+ queueOrder: item.queueOrder,
66
+ isGuest: item.kind === "prompt" && item.guestQueryId !== undefined,
63
67
  queuePosition: index + 1,
64
68
  isPriority: item.queueLane === "priority",
65
69
  priorityEmoji: item.kind === "prompt" ? item.priorityEmoji : undefined,
@@ -106,7 +110,9 @@ function buildTelegramQueueMenuReplyMarkup(
106
110
  return [
107
111
  {
108
112
  text: label,
109
- callback_data: `queue:pick:${item.chatId}:${item.replyToMessageId}`,
113
+ callback_data: item.isGuest
114
+ ? `queue:guest-pick:${item.queueOrder}`
115
+ : `queue:pick:${item.chatId}:${item.replyToMessageId}`,
110
116
  },
111
117
  ];
112
118
  });
@@ -178,6 +184,22 @@ function getTelegramQueueMenuItemText(item: TelegramQueueMenuItem): string {
178
184
  return `${heading}\n${preview}`;
179
185
  }
180
186
 
187
+ function buildTelegramGuestQueueItemSubmenuReplyMarkup(
188
+ queueOrder: number,
189
+ ): TelegramQueueMenuReplyMarkup {
190
+ return {
191
+ inline_keyboard: [
192
+ [{ text: "⬆️ Back", callback_data: "queue:list" }],
193
+ [
194
+ {
195
+ text: "🔴 Skip",
196
+ callback_data: `queue:guest-skip:${queueOrder}`,
197
+ },
198
+ ],
199
+ ],
200
+ };
201
+ }
202
+
181
203
  function buildTelegramQueueItemSubmenuReplyMarkup(
182
204
  chatId: number,
183
205
  replyToMessageId: number,
@@ -217,6 +239,8 @@ interface TelegramQueueMenuCallbackDeps<Context = unknown> {
217
239
  chatId: number,
218
240
  replyToMessageId: number,
219
241
  ) => TelegramQueueMenuItem | undefined;
242
+ findGuestItem: (queueOrder: number) => TelegramQueueMenuItem | undefined;
243
+ skipGuest: (queueOrder: number, ctx: Context) => Promise<boolean>;
220
244
  togglePriority: (chatId: number, replyToMessageId: number) => boolean;
221
245
  setPriority: (
222
246
  chatId: number,
@@ -275,6 +299,38 @@ async function handleTelegramQueueMenuCallback<Context>(
275
299
  );
276
300
  return true;
277
301
  }
302
+ const guestPickMatch = data.match(/^queue:guest-pick:(\d+)$/);
303
+ if (guestPickMatch) {
304
+ await handleTelegramGuestQueueMenuPick(
305
+ callbackQueryId,
306
+ replyChatId,
307
+ replyMessageId,
308
+ Number(guestPickMatch[1]),
309
+ deps,
310
+ );
311
+ return true;
312
+ }
313
+ const guestSkipMatch = data.match(/^queue:guest-skip:(\d+)$/);
314
+ if (guestSkipMatch) {
315
+ const skipped = await deps.skipGuest(Number(guestSkipMatch[1]), ctx);
316
+ if (!skipped) {
317
+ await refreshStaleTelegramQueueMenuItem(
318
+ callbackQueryId,
319
+ replyChatId,
320
+ replyMessageId,
321
+ deps,
322
+ );
323
+ return true;
324
+ }
325
+ await updateTelegramQueueMenuList(
326
+ callbackQueryId,
327
+ replyChatId,
328
+ replyMessageId,
329
+ deps,
330
+ "Guest prompt skipped.",
331
+ );
332
+ return true;
333
+ }
278
334
  const pickMatch = data.match(/^queue:pick:(\d+):(\d+)$/);
279
335
  if (pickMatch) {
280
336
  await handleTelegramQueueMenuPick(
@@ -379,6 +435,31 @@ async function refreshStaleTelegramQueueMenuItem<Context>(
379
435
  );
380
436
  }
381
437
 
438
+ async function handleTelegramGuestQueueMenuPick<Context>(
439
+ callbackQueryId: string,
440
+ replyChatId: number,
441
+ replyMessageId: number,
442
+ queueOrder: number,
443
+ deps: TelegramQueueMenuCallbackDeps<Context>,
444
+ ): Promise<void> {
445
+ const item = deps.findGuestItem(queueOrder);
446
+ if (!item) {
447
+ return refreshStaleTelegramQueueMenuItem(
448
+ callbackQueryId,
449
+ replyChatId,
450
+ replyMessageId,
451
+ deps,
452
+ );
453
+ }
454
+ await deps.updateQueueMessage(
455
+ replyChatId,
456
+ replyMessageId,
457
+ getTelegramQueueMenuItemText(item),
458
+ buildTelegramGuestQueueItemSubmenuReplyMarkup(queueOrder),
459
+ );
460
+ await deps.answerCallbackQuery(callbackQueryId);
461
+ }
462
+
382
463
  async function handleTelegramQueueMenuPick<Context>(
383
464
  callbackQueryId: string,
384
465
  replyChatId: number,
@@ -606,6 +687,7 @@ export function createTelegramQueueMenuRuntime<
606
687
  ctx: Context,
607
688
  ) => Promise<void>;
608
689
  updateStatus: (ctx: Context) => void;
690
+ dismissGuestPlaceholder?: (inlineMessageId: string) => Promise<void>;
609
691
  }): TelegramQueueMenuRuntime<Context> {
610
692
  const sendQueueMenuMessage = createQueueMenuSendMessageAdapter(
611
693
  deps.sendInteractiveMessage,
@@ -628,6 +710,7 @@ export function createTelegramQueueMenuRuntime<
628
710
  updateStatusMessage: deps.updateStatusMessage,
629
711
  answerCallbackQuery: deps.answerCallbackQuery,
630
712
  updateStatus: deps.updateStatus,
713
+ dismissGuestPlaceholder: deps.dismissGuestPlaceholder,
631
714
  }),
632
715
  };
633
716
  }
@@ -695,6 +778,7 @@ function createQueueMenuCallbackHandler<
695
778
  text?: string,
696
779
  ) => Promise<void>;
697
780
  updateStatus: (ctx: Context) => void;
781
+ dismissGuestPlaceholder?: (inlineMessageId: string) => Promise<void>;
698
782
  }) {
699
783
  return async (
700
784
  query: TelegramQueueMenuCallbackQuery,
@@ -737,6 +821,11 @@ function createQueueMenuCallbackHandler<
737
821
  const findItem = (cId: number, rId: number) => {
738
822
  return findTelegramQueueMenuItem(toMenuItems(), cId, rId);
739
823
  };
824
+ const findGuestItem = (queueOrder: number) => {
825
+ return toMenuItems().find((item) => {
826
+ return item.isGuest && item.queueOrder === queueOrder;
827
+ });
828
+ };
740
829
  return handleTelegramQueueMenuCallback(
741
830
  query.id,
742
831
  data,
@@ -746,6 +835,14 @@ function createQueueMenuCallbackHandler<
746
835
  {
747
836
  getQueuedItems: toMenuItems,
748
837
  findItem,
838
+ findGuestItem,
839
+ skipGuest: (queueOrder, callbackContext) => {
840
+ return skipQueuedTelegramGuestPrompt(queueOrder, callbackContext, {
841
+ getQueueSnapshot,
842
+ queueMutationRuntime: deps.queueMutationRuntime,
843
+ dismissGuestPlaceholder: deps.dismissGuestPlaceholder,
844
+ });
845
+ },
749
846
  togglePriority: (cId, rId) => {
750
847
  return toggleQueuedTelegramPromptPriority(cId, rId, ctx, {
751
848
  getQueueSnapshot,
@@ -772,6 +869,34 @@ function createQueueMenuCallbackHandler<
772
869
  };
773
870
  }
774
871
 
872
+ async function skipQueuedTelegramGuestPrompt<Context>(
873
+ queueOrder: number,
874
+ ctx: Context,
875
+ deps: {
876
+ getQueueSnapshot: () => Queue.TelegramQueueItem<Context>[];
877
+ queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
878
+ dismissGuestPlaceholder?: (inlineMessageId: string) => Promise<void>;
879
+ },
880
+ ): Promise<boolean> {
881
+ const item = deps.getQueueSnapshot().find((candidate) => {
882
+ return candidate.kind === "prompt" &&
883
+ candidate.guestQueryId !== undefined &&
884
+ candidate.queueOrder === queueOrder;
885
+ });
886
+ if (!item || !deps.queueMutationRuntime.removeGuestPromptByQueueOrder) {
887
+ return false;
888
+ }
889
+ const removed = deps.queueMutationRuntime.removeGuestPromptByQueueOrder(
890
+ queueOrder,
891
+ ctx,
892
+ );
893
+ if (!removed) return false;
894
+ if (item.guestInlineMessageId && deps.dismissGuestPlaceholder) {
895
+ await deps.dismissGuestPlaceholder(item.guestInlineMessageId);
896
+ }
897
+ return true;
898
+ }
899
+
775
900
  function getQueueMenuReactionDisposition<Context>(
776
901
  item: Queue.TelegramQueueItem<Context>,
777
902
  priority: boolean,