@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
@@ -3,6 +3,7 @@
3
3
  * Zones: telegram controls, pi agent commands, queue controls
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
+ import { randomUUID } from "node:crypto";
6
7
  import { pairTelegramUserIfNeeded, TELEGRAM_DEFAULT_PROFILE_NAME, } from "./config.js";
7
8
  import { escapeHtml } from "./rendering.js";
8
9
  import { createTelegramControlItemBuilder, createTelegramControlQueueController, createTelegramQueueAdmissionReceipt, } from "./queue.js";
@@ -241,14 +242,6 @@ function parseTelegramProfileArg(args) {
241
242
  return undefined;
242
243
  return word === TELEGRAM_DEFAULT_PROFILE_NAME ? undefined : word;
243
244
  }
244
- export function parseTelegramRequestedThreadName(args) {
245
- const token = args
246
- .trim()
247
- .split(/\s+/)
248
- .find((word) => /^as=/i.test(word));
249
- const value = token?.slice(3).trim();
250
- return value || undefined;
251
- }
252
245
  function formatTelegramTakeoverTitle(ctx) {
253
246
  return ctx.ui.theme.fg("accent", "pi-telegram");
254
247
  }
@@ -262,7 +255,7 @@ function formatTelegramTakeoverPrompt(ctx, owner) {
262
255
  }
263
256
  export function registerTelegramBridgeCommands(pi, deps) {
264
257
  pi.registerCommand("telegram-setup", {
265
- description: "Configure Telegram bot token. Use /telegram-setup <name> for named profiles.",
258
+ description: "<profile> — Configure Telegram bot token",
266
259
  handler: async (args, ctx) => {
267
260
  await deps.promptForConfig(ctx, parseTelegramProfileArg(args));
268
261
  },
@@ -275,26 +268,14 @@ export function registerTelegramBridgeCommands(pi, deps) {
275
268
  },
276
269
  });
277
270
  pi.registerCommand("telegram-connect", {
278
- description: "Start the Telegram bridge. Use /telegram-connect <profile> and optional as=Name for a fresh Workspace Thread.",
271
+ description: "<profile> — Start Telegram bridge",
279
272
  handler: async (args, ctx) => {
280
- const profileName = parseTelegramProfileArg(args);
281
- const requestedNameTokens = args
282
- .trim()
283
- .split(/\s+/)
284
- .filter((word) => /^as=/i.test(word));
285
- const requestedThreadName = parseTelegramRequestedThreadName(args);
286
- const requestedNameError = requestedNameTokens.length > 1
287
- ? "Specify at most one as=Name Workspace Thread name."
288
- : requestedNameTokens.length === 1 && !requestedThreadName
289
- ? "Usage: /telegram-connect [profile] as=Flightprice"
290
- : requestedThreadName
291
- ? deps.validateThreadName?.(requestedThreadName)
292
- : undefined;
293
- if (requestedNameError) {
294
- ctx.ui.notify(requestedNameError, "warning");
273
+ if (args.trim().split(/\s+/).some((word) => /^as=/i.test(word))) {
274
+ ctx.ui.notify("Thread names are configured from Telegram, not from Pi commands.", "warning");
295
275
  deps.updateStatus(ctx);
296
276
  return;
297
277
  }
278
+ const profileName = parseTelegramProfileArg(args);
298
279
  if (profileName && deps.activateProfileConfig) {
299
280
  const ok = await deps.activateProfileConfig(ctx, profileName);
300
281
  if (!ok) {
@@ -357,7 +338,6 @@ export function registerTelegramBridgeCommands(pi, deps) {
357
338
  };
358
339
  let result = await startWithRecovery({
359
340
  forceFreshLeaderThread: true,
360
- ...(requestedThreadName ? { requestedThreadName } : {}),
361
341
  });
362
342
  if (result && !result.ok && result.canTakeover) {
363
343
  const confirmed = await ctx.ui.confirm(formatTelegramTakeoverTitle(ctx), formatTelegramTakeoverPrompt(ctx, result.owner));
@@ -369,7 +349,6 @@ export function registerTelegramBridgeCommands(pi, deps) {
369
349
  result = await startWithRecovery({
370
350
  force: true,
371
351
  forceFreshLeaderThread: true,
372
- ...(requestedThreadName ? { requestedThreadName } : {}),
373
352
  });
374
353
  }
375
354
  if (result?.message) {
@@ -382,7 +361,7 @@ export function registerTelegramBridgeCommands(pi, deps) {
382
361
  },
383
362
  });
384
363
  pi.registerCommand("telegram-disconnect", {
385
- description: "Stop Telegram; in Threaded Mode, delete this instance's current thread",
364
+ description: "Stop Telegram and delete current thread in Threaded Mode",
386
365
  handler: async (_args, ctx) => {
387
366
  const threadName = deps.getDisconnectThreadName?.();
388
367
  if (threadName) {
@@ -1233,7 +1212,7 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
1233
1212
  }, commandArgs);
1234
1213
  }
1235
1214
  export const TELEGRAM_INTERNAL_COMMAND_NAME = "telegram-internal";
1236
- export const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION = "(internal) dispatch one settled Telegram lifecycle action";
1215
+ export const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION = "Internal Telegram command cannot be run manually";
1237
1216
  export const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE = "This internal Telegram command cannot be run manually.";
1238
1217
  export function delayTelegramSessionAction(delayMs) {
1239
1218
  return new Promise((resolve) => setTimeout(resolve, delayMs));
@@ -1382,9 +1361,9 @@ export function createTelegramSessionActionRuntime(deps) {
1382
1361
  registered = true;
1383
1362
  deps.registerCommand(TELEGRAM_INTERNAL_COMMAND_NAME, {
1384
1363
  description: TELEGRAM_INTERNAL_COMMAND_DESCRIPTION,
1385
- handler: async (_args, ctx) => {
1364
+ handler: async (args, ctx) => {
1386
1365
  const action = pendingAction;
1387
- if (!action) {
1366
+ if (!action || args.trim() !== action.token) {
1388
1367
  ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
1389
1368
  return;
1390
1369
  }
@@ -1421,9 +1400,10 @@ export function createTelegramSessionActionRuntime(deps) {
1421
1400
  pendingTarget = undefined;
1422
1401
  if (!target)
1423
1402
  return;
1424
- pendingAction = { kind: "replace-session", updateId, target };
1403
+ const token = randomUUID();
1404
+ pendingAction = { kind: "replace-session", token, updateId, target };
1425
1405
  void Promise.resolve()
1426
- .then(() => deps.sendUserMessage(`/${TELEGRAM_INTERNAL_COMMAND_NAME}`, {
1406
+ .then(() => deps.sendUserMessage(`/${TELEGRAM_INTERNAL_COMMAND_NAME} ${token}`, {
1427
1407
  expandPromptTemplates: true,
1428
1408
  }))
1429
1409
  .catch((error) => {
@@ -79,7 +79,7 @@ export default function (pi) {
79
79
  const workspaceAdmissionRuntime = WorkspaceAdmission.createTelegramWorkspaceAdmissionRuntimeBinding({
80
80
  getProfileName: configStore.getActiveProfileName,
81
81
  getBotToken: configStore.getBotToken,
82
- getPath: Paths.resolveTelegramWorkspaceAdmissionPath,
82
+ getPath: Paths.resolveTelegramWorkspaceAdmissionPathForProfile,
83
83
  owner: {
84
84
  processId: telegramProcessId,
85
85
  processBirthId: telegramQueueProcessBirthId,
@@ -146,7 +146,7 @@ export default function (pi) {
146
146
  },
147
147
  getWorkspaceAdmission: workspaceAdmissionRuntime.resolve,
148
148
  },
149
- getLeaderJournalPath: Paths.resolveTelegramUpdateJournalPath,
149
+ getLeaderJournalPath: Paths.resolveTelegramUpdateJournalPathForProfile,
150
150
  getFollowerJournalPath(bindingKey, profileName) {
151
151
  return Paths.resolveTelegramFollowerJournalPath(bindingKey, undefined, profileName);
152
152
  },
@@ -295,6 +295,45 @@ export default function (pi) {
295
295
  : "clear";
296
296
  },
297
297
  });
298
+ const reclaimTelegramWorkspaceDeadOwnerQueue = WorkspaceRetirement.createTelegramWorkspaceDeadOwnerQueueReclaimer({
299
+ getExternalProtection: captureWorkspaceExternalProtection,
300
+ getActiveTurnTarget: activeTurnRuntime.getTarget,
301
+ getQueuedItems: telegramQueueStore.getQueuedItems,
302
+ resolveLeaderJournal: resolveTelegramUpdateJournalBinding,
303
+ createFollowerJournalResolver: telegramJournalBindingRuntime.createRecipientResolver,
304
+ discoverFollowerJournals() {
305
+ return Journal.discoverTelegramFollowerJournalPaths({
306
+ directory: Paths.resolveTelegramTempDir(),
307
+ profileName: configStore.getActiveProfileName(),
308
+ });
309
+ },
310
+ createJournalPathResolver: telegramJournalBindingRuntime.createPathResolver,
311
+ withJournalReference(binding, operation) {
312
+ if (!binding.recoveryKey)
313
+ throw new Error("Telegram workspace journal reference identity is unavailable.");
314
+ return telegramJournalReferenceRegistry.withReference({
315
+ referenceClass: "workspace-retirement",
316
+ recoveryKey: binding.recoveryKey,
317
+ }, operation);
318
+ },
319
+ getRecoveryOwner() {
320
+ return {
321
+ instanceId: telegramInstanceId,
322
+ processId: telegramProcessId,
323
+ processBirthId: telegramQueueProcessBirthId,
324
+ sessionGeneration: telegramSessionContextStore.getGeneration(),
325
+ };
326
+ },
327
+ getQueueOwnerLiveness: Bus.getTelegramProcessLiveness,
328
+ onMutationError(error) {
329
+ recordRuntimeEvent("Telegram dead-owner queue reclamation was refused.", {
330
+ error: error instanceof Error ? error.message : String(error),
331
+ });
332
+ },
333
+ isBindingCurrent(binding) {
334
+ return WorkspaceRetirement.isCurrentTelegramWorkspaceBinding(threadStore, binding);
335
+ },
336
+ });
298
337
  const inactiveThreadCleanupReviewRuntime = ThreadCleanupManager.createTelegramInactiveThreadCleanupReviewRuntime({
299
338
  getProfileName() { return configStore.getActiveProfileName() ?? "default"; },
300
339
  listBindings: threadStore.listWorkspaceBindings,
@@ -689,6 +728,7 @@ export default function (pi) {
689
728
  storeModelMenuState: modelMenuRuntime.storeState,
690
729
  updateStatusMessage: menuActions.updateStatusMessage,
691
730
  updateStatus,
731
+ dismissGuestPlaceholder: guestPlaceholderRuntime.dismiss,
692
732
  });
693
733
  const threadDisplaySettingsRuntime = ThreadDisplay.createTelegramThreadDisplaySettingsRuntime({
694
734
  getTarget() {
@@ -1168,6 +1208,18 @@ export default function (pi) {
1168
1208
  getWorkspaceAdmission: workspaceAdmissionRuntime.resolve,
1169
1209
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1170
1210
  captureWorkspaceExternalProtection,
1211
+ workspaceRotation: {
1212
+ getAdmission: workspaceAdmissionRuntime.resolve,
1213
+ runExclusive: telegramWorkspaceOperationRuntime.runExclusive,
1214
+ deleteThread: directTelegramApiRuntime.deleteWorkspaceThread,
1215
+ reclaimDeadOwnerQueuedWork(binding, isCurrent) {
1216
+ return telegramWorkspaceOperationRuntime.run({
1217
+ operationId: WorkspaceAdmission.createTelegramWorkspaceAdmissionOperationId(),
1218
+ operationKind: "workspace.reclaim-dead-owner-queue",
1219
+ scopes: [{ kind: "target", target: binding.target }],
1220
+ }, reclaimTelegramWorkspaceDeadOwnerQueue.bind(undefined, binding, isCurrent));
1221
+ },
1222
+ },
1171
1223
  callApi(method, body, options) {
1172
1224
  return directTelegramApiRuntime.call(method, body, options);
1173
1225
  },
@@ -1281,6 +1333,15 @@ export default function (pi) {
1281
1333
  recordRuntimeEvent,
1282
1334
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1283
1335
  });
1336
+ const prepareThreadPreservationOnQuit = Sync.createTelegramPreservedLeaderQuitHandler({
1337
+ instanceId: telegramInstanceId,
1338
+ topicTargetStore: threadStore,
1339
+ getCurrentLeaderEpoch,
1340
+ getProfileName: configStore.getActiveProfileName,
1341
+ isPollingSuspended: lockedPollingRuntime.isSuspended,
1342
+ resolveAutomaticThreadCleanupEnabled: configControls.resolveAutomaticThreadCleanupEnabled,
1343
+ runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1344
+ });
1284
1345
  const telegramBridgeSessionLifecycleDeps = Lifecycle.createTelegramBridgeSessionLifecycleDeps({
1285
1346
  contextStore: telegramSessionContextStore,
1286
1347
  queue: {
@@ -1334,6 +1395,7 @@ export default function (pi) {
1334
1395
  capabilityMonitor: telegramThreadCapabilityMonitor,
1335
1396
  queueWatchdog: queueDispatchWatchdogRuntime,
1336
1397
  guestPlaceholder: { stopAll: guestPlaceholderRuntime.stopAll },
1398
+ prepareThreadPreservationOnQuit,
1337
1399
  },
1338
1400
  });
1339
1401
  const sessionLifecycleRuntime = Lifecycle.createTelegramBridgeSessionLifecycleAssembly(telegramBridgeSessionLifecycleDeps);
@@ -1449,10 +1511,6 @@ export default function (pi) {
1449
1511
  return undefined;
1450
1512
  return record.threadName ?? "current Telegram thread";
1451
1513
  },
1452
- setRequestedThreadNameForPollingStart: telegramThreadCapabilityState.setRequestedThreadName,
1453
- validateThreadName(threadName) {
1454
- return Threads.getTelegramTopicThreadNameValidationError(threadName, undefined);
1455
- },
1456
1514
  onTransportChanged() {
1457
1515
  deliveryLifecycleRuntime.onSessionStart();
1458
1516
  activityVerbosityRuntime.reset();
@@ -610,6 +610,9 @@ export interface TelegramUpdateJournalRuntimeBinding {
610
610
  runtimeKey: string;
611
611
  recoveryKey: string;
612
612
  journal: TelegramUpdateJournalStore;
613
+ readForProtection?: () => {
614
+ entries: readonly TelegramUpdateJournalEntry[];
615
+ };
613
616
  }
614
617
  export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
615
618
  getProfileName: () => string | undefined;
@@ -1443,6 +1443,19 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(deps) {
1443
1443
  profileName,
1444
1444
  botIdentity,
1445
1445
  }),
1446
+ readForProtection() {
1447
+ // Protection must never turn corruption/recovery into empty-work evidence.
1448
+ const evidence = inspectTelegramUpdateJournalFamily({
1449
+ directory: dirname(path), path, profile: profileName, botIdentity,
1450
+ limits: {
1451
+ maxFiles: 1024,
1452
+ maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
1453
+ maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES,
1454
+ maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
1455
+ },
1456
+ });
1457
+ return { entries: evidence.kind === "present" ? evidence.file.entries : [] };
1458
+ },
1446
1459
  journal: createTelegramUpdateJournalStore({
1447
1460
  path,
1448
1461
  profileName,
@@ -85,6 +85,7 @@ export interface TelegramBridgeSessionServiceRuntime {
85
85
  guestPlaceholder?: {
86
86
  stopAll(): void;
87
87
  };
88
+ prepareThreadPreservationOnQuit?: (isSessionCurrent: () => boolean) => (() => Promise<void>) | undefined;
88
89
  }
89
90
  export interface TelegramBridgeSessionLifecycleAssemblyDeps<TQueueItem, TModel = unknown> {
90
91
  contextStore: TelegramSessionContextStore<ExtensionContext>;
@@ -111,6 +112,7 @@ export interface TelegramBridgeSessionLifecyclePorts<TQueueItem, TModel = unknow
111
112
  capabilityMonitor: TelegramBridgeSessionServiceRuntime["capabilityMonitor"];
112
113
  queueWatchdog: TelegramBridgeSessionServiceRuntime["queueWatchdog"];
113
114
  guestPlaceholder?: TelegramBridgeSessionServiceRuntime["guestPlaceholder"];
115
+ prepareThreadPreservationOnQuit?: TelegramBridgeSessionServiceRuntime["prepareThreadPreservationOnQuit"];
114
116
  };
115
117
  }
116
118
  export declare function createTelegramBridgeSessionLifecycleDeps<TQueueItem, TModel = unknown>(ports: TelegramBridgeSessionLifecyclePorts<TQueueItem, TModel>): TelegramBridgeSessionLifecycleAssemblyDeps<TQueueItem, TModel>;
@@ -91,7 +91,8 @@ export function createTelegramSessionGenerationFence(store, hooks) {
91
91
  if (!store.isCurrent(ctx, generation))
92
92
  return;
93
93
  await hooks.onSessionShutdown(event, ctx);
94
- store.clear(ctx);
94
+ if (store.isCurrent(ctx, generation))
95
+ store.clear(ctx);
95
96
  },
96
97
  };
97
98
  }
@@ -115,6 +116,7 @@ export function createTelegramBridgeSessionLifecycleDeps(ports) {
115
116
  capabilityMonitor: ports.services.capabilityMonitor,
116
117
  queueWatchdog: ports.services.queueWatchdog,
117
118
  guestPlaceholder: ports.services.guestPlaceholder,
119
+ prepareThreadPreservationOnQuit: ports.services.prepareThreadPreservationOnQuit,
118
120
  },
119
121
  };
120
122
  }
@@ -144,18 +146,37 @@ export function createTelegramBridgeSessionLifecycleAssembly(deps) {
144
146
  deps.services.queueWatchdog.start(ctx);
145
147
  },
146
148
  async onSessionShutdown(event, ctx) {
147
- if (!isSessionActive(ctx))
149
+ const generation = deps.contextStore.getGeneration();
150
+ const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
151
+ if (!isCurrent())
148
152
  return;
153
+ let preserveThread;
154
+ if (event.reason === "quit") {
155
+ try {
156
+ preserveThread = deps.services.prepareThreadPreservationOnQuit?.(isCurrent);
157
+ }
158
+ catch (error) {
159
+ deps.follower.recordRuntimeEvent("session", error, { phase: "preserve-thread-on-quit" });
160
+ }
161
+ }
149
162
  deps.services.guestPlaceholder?.stopAll();
150
163
  await deps.services.delivery.onSessionShutdown();
151
- if (!isSessionActive(ctx))
164
+ if (!isCurrent())
152
165
  return;
153
166
  deps.services.queueWatchdog.stop();
154
167
  deps.services.capabilityMonitor.stop();
155
168
  await queueLifecycle.onSessionShutdown(event, ctx);
156
- if (!isSessionActive(ctx))
169
+ if (!isCurrent())
157
170
  return;
158
171
  await deps.services.inboundWorker.onSessionShutdown();
172
+ if (!isCurrent())
173
+ return;
174
+ try {
175
+ await preserveThread?.();
176
+ }
177
+ catch (error) {
178
+ deps.follower.recordRuntimeEvent("session", error, { phase: "preserve-thread-on-quit" });
179
+ }
159
180
  },
160
181
  };
161
182
  const followerLifecycle = appendTelegramLifecycleHooks(servicesLifecycle, {
@@ -133,6 +133,7 @@ export interface TelegramLockedPollingRuntime<TContext extends TelegramLockConte
133
133
  start: (ctx: TContext, options?: TelegramLockedPollingStartOptions) => Promise<TelegramLockedPollingStartResult>;
134
134
  stop: () => Promise<string>;
135
135
  suspend: () => Promise<void>;
136
+ isSuspended: () => boolean;
136
137
  onPersistentConflict: (ctx: TContext, count: number) => Promise<void>;
137
138
  onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
138
139
  registerFollowerWithOwner?: (ctx: TContext, owner: TelegramLockEntry) => boolean | undefined | Promise<boolean | undefined>;
package/dist/lib/locks.js CHANGED
@@ -551,7 +551,8 @@ export function isProcessAlive(pid) {
551
551
  return true;
552
552
  }
553
553
  catch (error) {
554
- return error.code === "EPERM";
554
+ // Only an absent PID proves death; permission and unexpected failures do not.
555
+ return error.code !== "ESRCH";
555
556
  }
556
557
  }
557
558
  export function formatTelegramLockEntry(lock) {
@@ -835,6 +836,9 @@ export function createTelegramLockedPollingRuntime(deps) {
835
836
  let takeoverCandidate;
836
837
  let sessionAutoStartRun;
837
838
  let pollingGeneration = 0;
839
+ let suspendedGeneration;
840
+ let suspensionsInFlight = 0;
841
+ let startupsInFlight = 0;
838
842
  const ownershipCheckMs = deps.ownershipCheckMs ?? TELEGRAM_OWNERSHIP_CHECK_MS;
839
843
  const ownershipRefreshMs = deps.ownershipRefreshMs ?? TELEGRAM_OWNERSHIP_REFRESH_MS;
840
844
  const stopOwnershipWatcher = () => {
@@ -846,20 +850,30 @@ export function createTelegramLockedPollingRuntime(deps) {
846
850
  ownershipRefreshInterval = undefined;
847
851
  };
848
852
  const suspendPolling = async () => {
849
- pollingGeneration += 1;
850
- activeContext = undefined;
851
- deps.transportMonitor?.stop();
852
- deps.stopFollowerRegistration?.();
853
- stopOwnershipWatcher();
854
- if (sessionAutoStartRun) {
855
- await sessionAutoStartRun;
853
+ const generation = ++pollingGeneration;
854
+ suspensionsInFlight += 1;
855
+ try {
856
+ activeContext = undefined;
857
+ deps.transportMonitor?.stop();
856
858
  deps.stopFollowerRegistration?.();
859
+ stopOwnershipWatcher();
860
+ if (sessionAutoStartRun) {
861
+ await sessionAutoStartRun;
862
+ deps.stopFollowerRegistration?.();
863
+ }
864
+ if (ownershipStop) {
865
+ await ownershipStop;
866
+ return;
867
+ }
868
+ await deps.stopPolling();
869
+ // Unsettled starts, overlapping stops or stale completion cannot certify quiescence.
870
+ if (generation === pollingGeneration && suspensionsInFlight === 1 && startupsInFlight === 0) {
871
+ suspendedGeneration = generation;
872
+ }
857
873
  }
858
- if (ownershipStop) {
859
- await ownershipStop;
860
- return;
874
+ finally {
875
+ suspensionsInFlight -= 1;
861
876
  }
862
- await deps.stopPolling();
863
877
  };
864
878
  const stopAfterOwnershipLoss = () => {
865
879
  if (ownershipStop)
@@ -906,6 +920,7 @@ export function createTelegramLockedPollingRuntime(deps) {
906
920
  return false;
907
921
  activeContext = ctx;
908
922
  startOwnershipWatcher(ctx);
923
+ startupsInFlight += 1;
909
924
  try {
910
925
  if (!deps.lock.refresh(snapshotLockContext(ctx))) {
911
926
  stopOwnershipWatcher();
@@ -934,6 +949,9 @@ export function createTelegramLockedPollingRuntime(deps) {
934
949
  deps.onTransportAvailabilityChanged?.();
935
950
  throw error;
936
951
  }
952
+ finally {
953
+ startupsInFlight -= 1;
954
+ }
937
955
  if (!isCurrent())
938
956
  return false;
939
957
  if (deps.lock.owns(ctx)) {
@@ -1075,6 +1093,8 @@ export function createTelegramLockedPollingRuntime(deps) {
1075
1093
  return "Telegram bridge disconnected.";
1076
1094
  },
1077
1095
  suspend: suspendPolling,
1096
+ isSuspended: () => suspendedGeneration === pollingGeneration &&
1097
+ suspensionsInFlight === 0 && startupsInFlight === 0 && !sessionAutoStartRun && !ownershipStop,
1078
1098
  onPersistentConflict: async (ctx, count) => {
1079
1099
  if (activeContext === undefined || ownershipStop)
1080
1100
  return;
@@ -33,5 +33,6 @@ export declare function createTelegramQueueMenuRuntime<Context, TModel extends M
33
33
  storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
34
34
  updateStatusMessage: (state: TelegramModelMenuState<TModel>, ctx: Context) => Promise<void>;
35
35
  updateStatus: (ctx: Context) => void;
36
+ dismissGuestPlaceholder?: (inlineMessageId: string) => Promise<void>;
36
37
  }): TelegramQueueMenuRuntime<Context>;
37
38
  export {};
@@ -34,6 +34,8 @@ function toTelegramQueueMenuItems(items) {
34
34
  return {
35
35
  chatId: item.chatId,
36
36
  replyToMessageId: item.replyToMessageId,
37
+ queueOrder: item.queueOrder,
38
+ isGuest: item.kind === "prompt" && item.guestQueryId !== undefined,
37
39
  queuePosition: index + 1,
38
40
  isPriority: item.queueLane === "priority",
39
41
  priorityEmoji: item.kind === "prompt" ? item.priorityEmoji : undefined,
@@ -72,7 +74,9 @@ function buildTelegramQueueMenuReplyMarkup(items, emptyRefreshIndex = 0) {
72
74
  return [
73
75
  {
74
76
  text: label,
75
- callback_data: `queue:pick:${item.chatId}:${item.replyToMessageId}`,
77
+ callback_data: item.isGuest
78
+ ? `queue:guest-pick:${item.queueOrder}`
79
+ : `queue:pick:${item.chatId}:${item.replyToMessageId}`,
76
80
  },
77
81
  ];
78
82
  });
@@ -128,6 +132,19 @@ function getTelegramQueueMenuItemText(item) {
128
132
  const preview = `<pre>${escapeTelegramQueueMenuHtmlPreview(item.promptText)}</pre>`;
129
133
  return `${heading}\n${preview}`;
130
134
  }
135
+ function buildTelegramGuestQueueItemSubmenuReplyMarkup(queueOrder) {
136
+ return {
137
+ inline_keyboard: [
138
+ [{ text: "⬆️ Back", callback_data: "queue:list" }],
139
+ [
140
+ {
141
+ text: "🔴 Skip",
142
+ callback_data: `queue:guest-skip:${queueOrder}`,
143
+ },
144
+ ],
145
+ ],
146
+ };
147
+ }
131
148
  function buildTelegramQueueItemSubmenuReplyMarkup(chatId, replyToMessageId, isPriority, isSkipped) {
132
149
  return {
133
150
  inline_keyboard: [
@@ -171,6 +188,21 @@ async function handleTelegramQueueMenuCallback(callbackQueryId, data, replyChatI
171
188
  await updateTelegramQueueMenuList(callbackQueryId, replyChatId, replyMessageId, deps, undefined, refreshMatch[1] === undefined ? 0 : Number(refreshMatch[1]));
172
189
  return true;
173
190
  }
191
+ const guestPickMatch = data.match(/^queue:guest-pick:(\d+)$/);
192
+ if (guestPickMatch) {
193
+ await handleTelegramGuestQueueMenuPick(callbackQueryId, replyChatId, replyMessageId, Number(guestPickMatch[1]), deps);
194
+ return true;
195
+ }
196
+ const guestSkipMatch = data.match(/^queue:guest-skip:(\d+)$/);
197
+ if (guestSkipMatch) {
198
+ const skipped = await deps.skipGuest(Number(guestSkipMatch[1]), ctx);
199
+ if (!skipped) {
200
+ await refreshStaleTelegramQueueMenuItem(callbackQueryId, replyChatId, replyMessageId, deps);
201
+ return true;
202
+ }
203
+ await updateTelegramQueueMenuList(callbackQueryId, replyChatId, replyMessageId, deps, "Guest prompt skipped.");
204
+ return true;
205
+ }
174
206
  const pickMatch = data.match(/^queue:pick:(\d+):(\d+)$/);
175
207
  if (pickMatch) {
176
208
  await handleTelegramQueueMenuPick(callbackQueryId, replyChatId, replyMessageId, Number(pickMatch[1]), Number(pickMatch[2]), deps);
@@ -208,6 +240,14 @@ async function updateTelegramQueueMenuList(callbackQueryId, replyChatId, replyMe
208
240
  async function refreshStaleTelegramQueueMenuItem(callbackQueryId, replyChatId, replyMessageId, deps) {
209
241
  await updateTelegramQueueMenuList(callbackQueryId, replyChatId, replyMessageId, deps, "Item no longer in queue.");
210
242
  }
243
+ async function handleTelegramGuestQueueMenuPick(callbackQueryId, replyChatId, replyMessageId, queueOrder, deps) {
244
+ const item = deps.findGuestItem(queueOrder);
245
+ if (!item) {
246
+ return refreshStaleTelegramQueueMenuItem(callbackQueryId, replyChatId, replyMessageId, deps);
247
+ }
248
+ await deps.updateQueueMessage(replyChatId, replyMessageId, getTelegramQueueMenuItemText(item), buildTelegramGuestQueueItemSubmenuReplyMarkup(queueOrder));
249
+ await deps.answerCallbackQuery(callbackQueryId);
250
+ }
211
251
  async function handleTelegramQueueMenuPick(callbackQueryId, replyChatId, replyMessageId, chatId, msgId, deps) {
212
252
  const item = deps.findItem(chatId, msgId);
213
253
  if (!item) {
@@ -272,6 +312,7 @@ export function createTelegramQueueMenuRuntime(deps) {
272
312
  updateStatusMessage: deps.updateStatusMessage,
273
313
  answerCallbackQuery: deps.answerCallbackQuery,
274
314
  updateStatus: deps.updateStatus,
315
+ dismissGuestPlaceholder: deps.dismissGuestPlaceholder,
275
316
  }),
276
317
  };
277
318
  }
@@ -318,9 +359,22 @@ function createQueueMenuCallbackHandler(deps) {
318
359
  const findItem = (cId, rId) => {
319
360
  return findTelegramQueueMenuItem(toMenuItems(), cId, rId);
320
361
  };
362
+ const findGuestItem = (queueOrder) => {
363
+ return toMenuItems().find((item) => {
364
+ return item.isGuest && item.queueOrder === queueOrder;
365
+ });
366
+ };
321
367
  return handleTelegramQueueMenuCallback(query.id, data, chatId, messageId, ctx, {
322
368
  getQueuedItems: toMenuItems,
323
369
  findItem,
370
+ findGuestItem,
371
+ skipGuest: (queueOrder, callbackContext) => {
372
+ return skipQueuedTelegramGuestPrompt(queueOrder, callbackContext, {
373
+ getQueueSnapshot,
374
+ queueMutationRuntime: deps.queueMutationRuntime,
375
+ dismissGuestPlaceholder: deps.dismissGuestPlaceholder,
376
+ });
377
+ },
324
378
  togglePriority: (cId, rId) => {
325
379
  return toggleQueuedTelegramPromptPriority(cId, rId, ctx, {
326
380
  getQueueSnapshot,
@@ -345,6 +399,23 @@ function createQueueMenuCallbackHandler(deps) {
345
399
  });
346
400
  };
347
401
  }
402
+ async function skipQueuedTelegramGuestPrompt(queueOrder, ctx, deps) {
403
+ const item = deps.getQueueSnapshot().find((candidate) => {
404
+ return candidate.kind === "prompt" &&
405
+ candidate.guestQueryId !== undefined &&
406
+ candidate.queueOrder === queueOrder;
407
+ });
408
+ if (!item || !deps.queueMutationRuntime.removeGuestPromptByQueueOrder) {
409
+ return false;
410
+ }
411
+ const removed = deps.queueMutationRuntime.removeGuestPromptByQueueOrder(queueOrder, ctx);
412
+ if (!removed)
413
+ return false;
414
+ if (item.guestInlineMessageId && deps.dismissGuestPlaceholder) {
415
+ await deps.dismissGuestPlaceholder(item.guestInlineMessageId);
416
+ }
417
+ return true;
418
+ }
348
419
  function getQueueMenuReactionDisposition(item, priority, skipped) {
349
420
  if (priority && skipped) {
350
421
  return {
@@ -14,6 +14,12 @@ export interface TelegramAgentDirResolutionInput {
14
14
  * 3. Fallback: `~/.pi/agent`.
15
15
  */
16
16
  export declare function resolveAgentDir(input?: TelegramAgentDirResolutionInput): string;
17
+ /**
18
+ * Pure reference preflight against an independently approved resource path.
19
+ * Reject aliases; never repair relative historical references or redirect storage.
20
+ * Equality proves spelling only, not file identity, consumer closure or migration readiness.
21
+ */
22
+ export declare function requireTelegramStoragePathReference(path: string, expectedPath: string): string;
17
23
  /** Telegram bridge configuration file (<agentDir>/telegram.json). */
18
24
  export declare function resolveTelegramConfigPath(): string;
19
25
  /** Telegram bridge temporary directory (<agentDir>/tmp/telegram). */
@@ -28,12 +34,16 @@ export declare function getTelegramDiagnosticsDisplayPaths(profileName?: string)
28
34
  };
29
35
  /** Durable Workspace admission ledger (<agentDir>/tmp/telegram/workspace-admission[.<profile>].json). */
30
36
  export declare function resolveTelegramWorkspaceAdmissionPath(agentDir?: string, profileName?: string): string;
37
+ /** Profile-only callback shape; binds storage to the configured agent directory. */
38
+ export declare function resolveTelegramWorkspaceAdmissionPathForProfile(profileName?: string): string;
31
39
  /** Durable inactive Thread cleanup work-set journal. */
32
40
  export declare function resolveTelegramThreadCleanupWorkPath(agentDir?: string, profileName?: string): string;
33
41
  /** Durable agent-authored channel post journal. */
34
42
  export declare function resolveTelegramChannelPostJournalPath(agentDir?: string, profileName?: string): string;
35
43
  /** Durable inbound update journal (<agentDir>/tmp/telegram/inbox[.<profile>].json). */
36
44
  export declare function resolveTelegramUpdateJournalPath(agentDir?: string, profileName?: string): string;
45
+ /** Profile-only callback shape; binds storage to the configured agent directory. */
46
+ export declare function resolveTelegramUpdateJournalPathForProfile(profileName?: string): string;
37
47
  /** Durable follower delivery journal, isolated by stable recipient binding. */
38
48
  export declare function resolveTelegramFollowerJournalPath(recipientBindingKey: string, agentDir?: string, profileName?: string): string;
39
49
  /** Runtime event log (<agentDir>/tmp/telegram/logs.jsonl). */