@llblab/pi-telegram 0.50.1 → 0.51.1

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 +10 -8
  2. package/BACKLOG.md +13 -32
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +4 -3
  5. package/dist/lib/bindings.d.ts +4 -2
  6. package/dist/lib/bindings.js +5 -5
  7. package/dist/lib/bus-follower.d.ts +4 -2
  8. package/dist/lib/bus-follower.js +99 -10
  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 +5 -0
  14. package/dist/lib/commands.js +9 -0
  15. package/dist/lib/extension.js +68 -3
  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/paths.d.ts +10 -0
  23. package/dist/lib/paths.js +22 -1
  24. package/dist/lib/polling.js +2 -2
  25. package/dist/lib/queue.d.ts +9 -1
  26. package/dist/lib/queue.js +64 -13
  27. package/dist/lib/replies.d.ts +4 -0
  28. package/dist/lib/replies.js +14 -0
  29. package/dist/lib/routing.d.ts +1 -0
  30. package/dist/lib/routing.js +5 -0
  31. package/dist/lib/sync.d.ts +9 -0
  32. package/dist/lib/sync.js +41 -1
  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 +22 -15
  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 +12 -7
  52. package/lib/bus-follower.ts +82 -14
  53. package/lib/bus-leader.ts +236 -94
  54. package/lib/bus.ts +39 -9
  55. package/lib/commands.ts +19 -0
  56. package/lib/extension.ts +78 -2
  57. package/lib/journal.ts +14 -0
  58. package/lib/lifecycle.ts +23 -4
  59. package/lib/locks.ts +32 -13
  60. package/lib/paths.ts +29 -1
  61. package/lib/polling.ts +2 -2
  62. package/lib/queue.ts +77 -19
  63. package/lib/replies.ts +18 -0
  64. package/lib/routing.ts +7 -0
  65. package/lib/sync.ts +45 -1
  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
@@ -711,6 +711,7 @@ export interface TelegramCommandActionDeps<TMessage, TContext> {
711
711
  export interface TelegramStopCommandDeps {
712
712
  hasAbortHandler: () => boolean;
713
713
  clearPendingModelSwitch: () => void;
714
+ cancelNextTransitionAnnouncements?: () => void;
714
715
  clearQueuedTelegramItems: () => number;
715
716
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
716
717
  abortCurrentTurn: () => void;
@@ -1146,6 +1147,8 @@ export interface TelegramCommandRuntimeDeps<
1146
1147
  isContextActive?: (ctx: TContext) => boolean;
1147
1148
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
1148
1149
  requestNextDispatchAnnouncement?: () => void;
1150
+ markActiveTurnNextAbortAnnouncement?: () => boolean;
1151
+ cancelNextTransitionAnnouncements?: () => void;
1149
1152
  requestDeferredDispatchNextQueuedTelegramTurn?: (
1150
1153
  dispatch: (ctx: TContext) => void,
1151
1154
  ) => void;
@@ -1352,6 +1355,7 @@ export async function handleTelegramStopCommand(
1352
1355
  deps: TelegramStopCommandDeps,
1353
1356
  ): Promise<void> {
1354
1357
  deps.clearPendingModelSwitch();
1358
+ deps.cancelNextTransitionAnnouncements?.();
1355
1359
  const clearedCount = deps.clearQueuedTelegramItems();
1356
1360
  deps.setFoldQueuedPromptsIntoHistory(false);
1357
1361
  if (!deps.hasAbortHandler()) {
@@ -1385,6 +1389,7 @@ export async function handleTelegramAbortCommand(deps: {
1385
1389
  hasAbortHandler: () => boolean;
1386
1390
  hasActiveTelegramTurn: () => boolean;
1387
1391
  clearPendingModelSwitch: () => void;
1392
+ cancelNextTransitionAnnouncements?: () => void;
1388
1393
  abortCurrentTurn: () => void;
1389
1394
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
1390
1395
  updateStatus: () => void;
@@ -1394,6 +1399,7 @@ export async function handleTelegramAbortCommand(deps: {
1394
1399
  ) => Promise<void>;
1395
1400
  }): Promise<void> {
1396
1401
  deps.clearPendingModelSwitch();
1402
+ deps.cancelNextTransitionAnnouncements?.();
1397
1403
  if (!deps.hasAbortHandler()) {
1398
1404
  await deps.sendTextReply(
1399
1405
  formatTelegramInformationHeading("💤", "No active turn."),
@@ -1418,6 +1424,7 @@ export async function handleTelegramNextCommand(deps: {
1418
1424
  abortCurrentTurn: () => void;
1419
1425
  dispatchNextQueuedTurn: () => void;
1420
1426
  requestNextDispatchAnnouncement?: () => void;
1427
+ markActiveTurnNextAbortAnnouncement?: () => boolean;
1421
1428
  clearFoldForDispatch: () => void;
1422
1429
  updateStatus: () => void;
1423
1430
  sendTextReply: (
@@ -1439,6 +1446,7 @@ export async function handleTelegramNextCommand(deps: {
1439
1446
  if (!deps.isIdle() && deps.hasAbortHandler()) {
1440
1447
  deps.clearFoldForDispatch();
1441
1448
  deps.requestNextDispatchAnnouncement?.();
1449
+ deps.markActiveTurnNextAbortAnnouncement?.();
1442
1450
  deps.abortCurrentTurn();
1443
1451
  deps.updateStatus();
1444
1452
  return;
@@ -1880,6 +1888,11 @@ export function createTelegramCommandHandlerTargetRuntime<
1880
1888
  updateStatus: deps.updateStatus,
1881
1889
  isContextActive: deps.isContextActive,
1882
1890
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
1891
+ requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
1892
+ markActiveTurnNextAbortAnnouncement:
1893
+ deps.markActiveTurnNextAbortAnnouncement,
1894
+ cancelNextTransitionAnnouncements:
1895
+ deps.cancelNextTransitionAnnouncements,
1883
1896
  startTypingLoop: deps.startTypingLoop,
1884
1897
  stopTypingLoop: deps.stopTypingLoop,
1885
1898
  enqueueContinueTurn: deps.enqueueContinueTurn,
@@ -2037,6 +2050,8 @@ async function handleTelegramCommandRuntime<
2037
2050
  await handleTelegramStopCommand({
2038
2051
  hasAbortHandler: deps.hasAbortHandler,
2039
2052
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
2053
+ cancelNextTransitionAnnouncements:
2054
+ deps.cancelNextTransitionAnnouncements,
2040
2055
  clearQueuedTelegramItems: () =>
2041
2056
  deps.clearQueuedTelegramItems(commandCtx),
2042
2057
  setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
@@ -2113,6 +2128,8 @@ async function handleTelegramCommandRuntime<
2113
2128
  hasAbortHandler: deps.hasAbortHandler,
2114
2129
  hasActiveTelegramTurn: deps.hasActiveTelegramTurn,
2115
2130
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
2131
+ cancelNextTransitionAnnouncements:
2132
+ deps.cancelNextTransitionAnnouncements,
2116
2133
  abortCurrentTurn: deps.abortCurrentTurn,
2117
2134
  setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
2118
2135
  updateStatus: updateStatusFor(commandCtx),
@@ -2129,6 +2146,8 @@ async function handleTelegramCommandRuntime<
2129
2146
  dispatchNextQueuedTurn: () =>
2130
2147
  deps.dispatchNextQueuedTelegramTurn(commandCtx),
2131
2148
  requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
2149
+ markActiveTurnNextAbortAnnouncement:
2150
+ deps.markActiveTurnNextAbortAnnouncement,
2132
2151
  clearFoldForDispatch: () =>
2133
2152
  deps.setFoldQueuedPromptsIntoHistory(false),
2134
2153
  updateStatus: updateStatusFor(commandCtx),
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"; },
@@ -775,6 +820,7 @@ export default function (pi: Pi.ExtensionAPI) {
775
820
  mutation: queueMutationRuntime,
776
821
  dispatchNext: dispatchNextQueuedTelegramTurn,
777
822
  requestNextDispatchAnnouncement,
823
+ cancelNextDispatchAnnouncement,
778
824
  watchdog: queueDispatchWatchdogRuntime,
779
825
  } = Bindings.createTelegramQueueBindingRuntime({
780
826
  store: telegramQueueStore,
@@ -790,6 +836,13 @@ export default function (pi: Pi.ExtensionAPI) {
790
836
  updateStatus,
791
837
  sendTextReply,
792
838
  sendUserMessage,
839
+ reconcileNextDispatchAnnouncementReplyOwnership(item) {
840
+ Replies.preserveTransportReplyDedupOnNextReset(
841
+ item.chatId,
842
+ item.replyToMessageId,
843
+ item.target,
844
+ );
845
+ },
793
846
  recordRuntimeEvent,
794
847
  });
795
848
  const { finalizeMarkdownPreview, preparePreviewDelivery } =
@@ -1048,6 +1101,7 @@ export default function (pi: Pi.ExtensionAPI) {
1048
1101
  isContextActive: telegramSessionContextStore.isCurrent,
1049
1102
  dispatchNextQueuedTelegramTurn,
1050
1103
  requestNextDispatchAnnouncement,
1104
+ cancelNextDispatchAnnouncement,
1051
1105
  requestDeferredDispatchNextQueuedTelegramTurn:
1052
1106
  deferredQueueDispatchRuntime.request,
1053
1107
  hasDeferredDispatchContext: deferredQueueDispatchRuntime.isBound,
@@ -1410,6 +1464,18 @@ export default function (pi: Pi.ExtensionAPI) {
1410
1464
  getWorkspaceAdmission: workspaceAdmissionRuntime.resolve,
1411
1465
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1412
1466
  captureWorkspaceExternalProtection,
1467
+ workspaceRotation: {
1468
+ getAdmission: workspaceAdmissionRuntime.resolve,
1469
+ runExclusive: telegramWorkspaceOperationRuntime.runExclusive,
1470
+ deleteThread: directTelegramApiRuntime.deleteWorkspaceThread,
1471
+ reclaimDeadOwnerQueuedWork(binding, isCurrent) {
1472
+ return telegramWorkspaceOperationRuntime.run({
1473
+ operationId: WorkspaceAdmission.createTelegramWorkspaceAdmissionOperationId(),
1474
+ operationKind: "workspace.reclaim-dead-owner-queue",
1475
+ scopes: [{ kind: "target", target: binding.target }],
1476
+ }, reclaimTelegramWorkspaceDeadOwnerQueue.bind(undefined, binding, isCurrent));
1477
+ },
1478
+ },
1413
1479
  callApi(method, body, options) {
1414
1480
  return directTelegramApiRuntime.call(method, body, options);
1415
1481
  },
@@ -1545,6 +1611,15 @@ export default function (pi: Pi.ExtensionAPI) {
1545
1611
  recordRuntimeEvent,
1546
1612
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1547
1613
  });
1614
+ const prepareThreadPreservationOnQuit = Sync.createTelegramPreservedLeaderQuitHandler({
1615
+ instanceId: telegramInstanceId,
1616
+ topicTargetStore: threadStore,
1617
+ getCurrentLeaderEpoch,
1618
+ getProfileName: configStore.getActiveProfileName,
1619
+ isPollingSuspended: lockedPollingRuntime.isSuspended,
1620
+ resolveAutomaticThreadCleanupEnabled: configControls.resolveAutomaticThreadCleanupEnabled,
1621
+ runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1622
+ });
1548
1623
  const telegramBridgeSessionLifecycleDeps =
1549
1624
  Lifecycle.createTelegramBridgeSessionLifecycleDeps({
1550
1625
  contextStore: telegramSessionContextStore,
@@ -1599,6 +1674,7 @@ export default function (pi: Pi.ExtensionAPI) {
1599
1674
  capabilityMonitor: telegramThreadCapabilityMonitor,
1600
1675
  queueWatchdog: queueDispatchWatchdogRuntime,
1601
1676
  guestPlaceholder: { stopAll: guestPlaceholderRuntime.stopAll },
1677
+ prepareThreadPreservationOnQuit,
1602
1678
  },
1603
1679
  });
1604
1680
  const sessionLifecycleRuntime =
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/paths.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
11
  import { homedir } from "node:os";
12
- import { join, resolve } from "node:path";
12
+ import { isAbsolute, join, resolve } from "node:path";
13
13
 
14
14
  export const TELEGRAM_DEFAULT_PROFILE_NAME = "default";
15
15
 
@@ -43,6 +43,20 @@ export function resolveAgentDir(
43
43
  return join(homedir(), ".pi", "agent");
44
44
  }
45
45
 
46
+ /**
47
+ * Pure reference preflight against an independently approved resource path.
48
+ * Reject aliases; never repair relative historical references or redirect storage.
49
+ * Equality proves spelling only, not file identity, consumer closure or migration readiness.
50
+ */
51
+ export function requireTelegramStoragePathReference(path: string, expectedPath: string): string {
52
+ if (typeof path !== "string" || typeof expectedPath !== "string" ||
53
+ !isAbsolute(path) || resolve(path) !== path ||
54
+ !isAbsolute(expectedPath) || resolve(expectedPath) !== expectedPath || path !== expectedPath) {
55
+ throw new Error("Telegram storage reference does not match its approved absolute path.");
56
+ }
57
+ return path;
58
+ }
59
+
46
60
  /** Telegram bridge configuration file (<agentDir>/telegram.json). */
47
61
  export function resolveTelegramConfigPath(): string {
48
62
  return join(resolveAgentDir(), "telegram.json");
@@ -100,6 +114,13 @@ export function resolveTelegramWorkspaceAdmissionPath(
100
114
  );
101
115
  }
102
116
 
117
+ /** Profile-only callback shape; binds storage to the configured agent directory. */
118
+ export function resolveTelegramWorkspaceAdmissionPathForProfile(
119
+ profileName?: string,
120
+ ): string {
121
+ return resolveTelegramWorkspaceAdmissionPath(resolveAgentDir(), profileName);
122
+ }
123
+
103
124
  /** Durable inactive Thread cleanup work-set journal. */
104
125
  export function resolveTelegramThreadCleanupWorkPath(
105
126
  agentDir = resolveAgentDir(),
@@ -129,6 +150,13 @@ export function resolveTelegramUpdateJournalPath(
129
150
  );
130
151
  }
131
152
 
153
+ /** Profile-only callback shape; binds storage to the configured agent directory. */
154
+ export function resolveTelegramUpdateJournalPathForProfile(
155
+ profileName?: string,
156
+ ): string {
157
+ return resolveTelegramUpdateJournalPath(resolveAgentDir(), profileName);
158
+ }
159
+
132
160
  /** Durable follower delivery journal, isolated by stable recipient binding. */
133
161
  export function resolveTelegramFollowerJournalPath(
134
162
  recipientBindingKey: string,
package/lib/polling.ts CHANGED
@@ -28,8 +28,8 @@ const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
28
28
  const TELEGRAM_POLLING_RETRY_MS = 3_000;
29
29
  export const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
30
30
 
31
- // Standard Telegram DM polling does not expose ordinary message-deletion events,
32
- // so queue removal stays reaction-driven while delete-like business updates remain defensive-only.
31
+ // Standard Telegram DM polling does not expose ordinary message-deletion events.
32
+ // Business deletions belong to a separate namespace and default routing ignores them.
33
33
  export const TELEGRAM_ALLOWED_UPDATES = [
34
34
  "message",
35
35
  "edited_message",