@llblab/pi-kit 0.36.2 → 0.37.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 (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -3
  4. package/node_modules/@llblab/pi-telegram/BACKLOG.md +27 -1
  5. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +31 -0
  6. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -1
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +2 -1
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +16 -14
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +8 -1
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +1 -1
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +6 -0
  12. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +12 -10
  13. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +58 -39
  14. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +21 -12
  15. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +1 -1
  16. package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +4 -4
  17. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +3 -2
  18. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +1 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +4 -2
  20. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +10 -9
  21. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +1 -15
  22. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +2 -15
  23. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  24. package/node_modules/@llblab/pi-telegram/docs/architecture.md +2 -2
  25. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +16 -2
  26. package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -1
  27. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +4 -0
  28. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +18 -17
  29. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +11 -4
  30. package/node_modules/@llblab/pi-telegram/lib/bus.ts +8 -0
  31. package/node_modules/@llblab/pi-telegram/lib/commands.ts +87 -51
  32. package/node_modules/@llblab/pi-telegram/lib/extension.ts +22 -13
  33. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +1 -1
  34. package/node_modules/@llblab/pi-telegram/lib/menu.ts +4 -4
  35. package/node_modules/@llblab/pi-telegram/lib/routing.ts +5 -2
  36. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +11 -7
  37. package/node_modules/@llblab/pi-telegram/lib/updates.ts +2 -26
  38. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  39. package/node_modules/@llblab/pi-telegram/scripts/run-macos-tests.mjs +43 -0
  40. package/package.json +2 -2
@@ -36,6 +36,7 @@ import {
36
36
  TelegramApiAuthorityError,
37
37
  type TelegramApiCallOptions,
38
38
  } from "./telegram-api.ts";
39
+ import type { TelegramTarget } from "./target.ts";
39
40
  import type { TelegramSessionReplacementIntent } from "./threads.ts";
40
41
  import {
41
42
  acquireTelegramUpdateRouting,
@@ -624,6 +625,7 @@ export interface TelegramBridgeCommandStartPollingResult {
624
625
  }
625
626
 
626
627
  export interface TelegramBridgeCommandRegistrationDeps {
628
+ handleInternalAction?: TelegramSessionActionRuntime["handleInternalAction"];
627
629
  promptForConfig: (
628
630
  ctx: ExtensionCommandContext,
629
631
  profileName?: string,
@@ -789,6 +791,7 @@ export function registerTelegramBridgeCommands(
789
791
  pi: ExtensionAPI,
790
792
  deps: TelegramBridgeCommandRegistrationDeps,
791
793
  ): void {
794
+ const handleInternalAction = deps.handleInternalAction;
792
795
  pi.registerCommand("telegram-setup", {
793
796
  description: "<profile> — Configure Telegram bot token",
794
797
  handler: async (args, ctx) => {
@@ -804,9 +807,21 @@ export function registerTelegramBridgeCommands(
804
807
  ctx.ui.notify(deps.getStatusLines({ verbose }).join("\n"), "info");
805
808
  },
806
809
  });
807
- pi.registerCommand("telegram-connect", {
810
+ pi.registerCommand(TELEGRAM_CONNECT_COMMAND_NAME, {
808
811
  description: "<profile> — Start Telegram bridge",
809
812
  handler: async (args, ctx) => {
813
+ const words = args.trim().split(/\s+/);
814
+ // Service dispatch never inherits profile activation or explicit-connect recovery authority.
815
+ if (words.some(word =>
816
+ word.toLowerCase().startsWith(TELEGRAM_INTERNAL_COMMAND_FLAG),
817
+ )) {
818
+ if (words[0] !== TELEGRAM_INTERNAL_COMMAND_FLAG || !handleInternalAction) {
819
+ ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
820
+ return;
821
+ }
822
+ await handleInternalAction(words.slice(1).join(" "), ctx);
823
+ return;
824
+ }
810
825
  const sessionGeneration = deps.getSessionGeneration?.();
811
826
  let intentId: string | undefined;
812
827
  // Pi context getters throw after replacement; check plain intent/generation first.
@@ -1194,34 +1209,38 @@ export interface TelegramCommandTargetRuntimeDeps<TContext> {
1194
1209
  ) => void;
1195
1210
  getAdmissionScope?: () => string | undefined;
1196
1211
  getAdmissionJournalBinding?: () => string | undefined;
1212
+ /** Ordinary reply destination selected before publication, not resend authority. */
1213
+ getReplyTarget?: (
1214
+ message: TelegramCommandRuntimeMessage,
1215
+ ) => TelegramTarget | undefined;
1197
1216
  onControlQueued?: (
1198
1217
  message: TelegramCommandRuntimeMessage,
1199
1218
  receipt: TelegramQueueAdmissionReceipt,
1200
1219
  ) => void;
1201
1220
  showStatus: (
1202
1221
  chatId: number,
1203
- replyToMessageId: number,
1222
+ replyToMessageId: number | undefined,
1204
1223
  ctx: TContext,
1205
1224
  threadId?: number,
1206
1225
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
1207
1226
  ) => Promise<void>;
1208
1227
  openModelMenu: (
1209
1228
  chatId: number,
1210
- replyToMessageId: number,
1229
+ replyToMessageId: number | undefined,
1211
1230
  ctx: TContext,
1212
1231
  threadId?: number,
1213
1232
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
1214
1233
  ) => Promise<void>;
1215
1234
  openSettingsMenu?: (
1216
1235
  chatId: number,
1217
- replyToMessageId: number,
1236
+ replyToMessageId: number | undefined,
1218
1237
  ctx: TContext,
1219
1238
  threadId?: number,
1220
1239
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
1221
1240
  ) => Promise<void>;
1222
1241
  sendTextReply: (
1223
1242
  chatId: number,
1224
- replyToMessageId: number,
1243
+ replyToMessageId: number | undefined,
1225
1244
  text: string,
1226
1245
  options?: {
1227
1246
  parseMode?: "HTML";
@@ -1336,6 +1355,7 @@ export function createTelegramCommandTargetQueueRuntime<
1336
1355
  createControlItem,
1337
1356
  enqueueControlItem: controlQueueController.enqueue,
1338
1357
  }),
1358
+ getReplyTarget: deps.getReplyTarget,
1339
1359
  getAdmissionScope: deps.getAdmissionScope,
1340
1360
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
1341
1361
  onControlQueued: deps.onControlQueued,
@@ -1352,6 +1372,25 @@ export function createTelegramCommandTargetRuntime<
1352
1372
  >(
1353
1373
  deps: TelegramCommandTargetRuntimeDeps<TContext>,
1354
1374
  ): TelegramCommandTargetRuntime<TMessage, TContext> {
1375
+ const getReplyTarget = (
1376
+ message: TMessage,
1377
+ options?: Pick<TelegramApiCallOptions, "assertAuthority">,
1378
+ ) => {
1379
+ const source = getTelegramCommandMessageTarget(message);
1380
+ const recipient = options?.assertAuthority
1381
+ ? undefined
1382
+ : deps.getReplyTarget?.(message);
1383
+ if (!recipient) return source;
1384
+ if (recipient.chatId !== source.chatId)
1385
+ throw new Error("Telegram command reply cannot change its recipient chat.");
1386
+ if (source.threadId !== undefined && recipient.threadId === undefined)
1387
+ throw new Error("Telegram command cannot move a Thread reply to All.");
1388
+ return {
1389
+ ...recipient,
1390
+ replyToMessageId:
1391
+ recipient.threadId === source.threadId ? source.replyToMessageId : undefined,
1392
+ };
1393
+ };
1355
1394
  return {
1356
1395
  enqueueControlItem: (message, ctx, controlType, statusSummary, execute) => {
1357
1396
  const sourceUpdateId = message.pi_telegram_source_update_id;
@@ -1381,7 +1420,7 @@ export function createTelegramCommandTargetRuntime<
1381
1420
  );
1382
1421
  },
1383
1422
  showStatus: (message, ctx, options) => {
1384
- const target = getTelegramCommandMessageTarget(message);
1423
+ const target = getReplyTarget(message, options);
1385
1424
  return deps.showStatus(
1386
1425
  target.chatId,
1387
1426
  target.replyToMessageId,
@@ -1391,7 +1430,7 @@ export function createTelegramCommandTargetRuntime<
1391
1430
  );
1392
1431
  },
1393
1432
  openModelMenu: (message, ctx, options) => {
1394
- const target = getTelegramCommandMessageTarget(message);
1433
+ const target = getReplyTarget(message, options);
1395
1434
  return deps.openModelMenu(
1396
1435
  target.chatId,
1397
1436
  target.replyToMessageId,
@@ -1401,7 +1440,7 @@ export function createTelegramCommandTargetRuntime<
1401
1440
  );
1402
1441
  },
1403
1442
  openSettingsMenu: async (message, ctx, options) => {
1404
- const target = getTelegramCommandMessageTarget(message);
1443
+ const target = getReplyTarget(message, options);
1405
1444
  if (!deps.openSettingsMenu) {
1406
1445
  await deps.sendTextReply(
1407
1446
  target.chatId,
@@ -1423,7 +1462,7 @@ export function createTelegramCommandTargetRuntime<
1423
1462
  );
1424
1463
  },
1425
1464
  sendTextReply: async (message, text, options) => {
1426
- const target = getTelegramCommandMessageTarget(message);
1465
+ const target = getReplyTarget(message, options);
1427
1466
  const assertAuthority = options?.assertAuthority,
1428
1467
  send = deps.sendTextReply;
1429
1468
  const capturedOptions = { ...options, target };
@@ -2217,6 +2256,7 @@ export function createTelegramCommandHandlerTargetRuntime<
2217
2256
  }),
2218
2257
  appendControlItem: deps.appendControlItem,
2219
2258
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
2259
+ getReplyTarget: deps.getReplyTarget,
2220
2260
  getAdmissionScope: deps.getAdmissionScope,
2221
2261
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
2222
2262
  onControlQueued: deps.onControlQueued,
@@ -4157,18 +4197,16 @@ async function handleTelegramCommandRuntime<
4157
4197
  );
4158
4198
  }
4159
4199
 
4160
- export const TELEGRAM_INTERNAL_COMMAND_NAME = "telegram-internal";
4161
- export const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION =
4162
- "Internal Telegram command cannot be run manually";
4200
+ export const TELEGRAM_CONNECT_COMMAND_NAME = "telegram-connect";
4201
+ export const TELEGRAM_INTERNAL_COMMAND_FLAG = "--internal";
4163
4202
  export const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE =
4164
- "This internal Telegram command cannot be run manually.";
4203
+ "This internal Telegram action cannot be run manually.";
4165
4204
 
4166
4205
  function delayTelegramSessionAction(delayMs: number): Promise<void> {
4167
4206
  return new Promise<void>((resolve) => setTimeout(resolve, delayMs));
4168
4207
  }
4169
4208
 
4170
4209
  export interface TelegramSessionActionRuntimeDeps {
4171
- registerCommand: Pi.ExtensionAPI["registerCommand"];
4172
4210
  sendUserMessage: Pi.ExtensionAPI["sendUserMessage"];
4173
4211
  notifyResult: (
4174
4212
  target: { chatId: number; threadId?: number; messageId: number },
@@ -4251,7 +4289,6 @@ function createTelegramSessionReplacementSettlementRuntime<TContext>(deps: {
4251
4289
  }
4252
4290
 
4253
4291
  export interface TelegramSessionActionAssemblyDeps {
4254
- registerCommand: Pi.ExtensionAPI["registerCommand"];
4255
4292
  sendUserMessage: Pi.ExtensionAPI["sendUserMessage"];
4256
4293
  store: {
4257
4294
  load: () => Promise<void>;
@@ -4333,7 +4370,6 @@ export function createTelegramSessionActionAssembly(
4333
4370
  report(new Error("Telegram new-session result delivery failed."));
4334
4371
  };
4335
4372
  const action = createTelegramSessionActionRuntime({
4336
- registerCommand: deps.registerCommand,
4337
4373
  sendUserMessage: deps.sendUserMessage,
4338
4374
  notifyResult(target, result) {
4339
4375
  return sendTerminalResult(target, result);
@@ -4465,7 +4501,10 @@ type TelegramPendingInternalAction = {
4465
4501
  };
4466
4502
 
4467
4503
  export interface TelegramSessionActionRuntime {
4468
- register: () => void;
4504
+ handleInternalAction: (
4505
+ args: string,
4506
+ ctx: Pi.ExtensionCommandContext,
4507
+ ) => Promise<void>;
4469
4508
  scheduleAfterUpdate: (
4470
4509
  updateId: number,
4471
4510
  target: { chatId: number; threadId?: number; messageId: number },
@@ -4486,7 +4525,6 @@ export function createTelegramSessionActionRuntime(
4486
4525
  }
4487
4526
  | undefined;
4488
4527
  let pendingAction: TelegramPendingInternalAction | undefined;
4489
- let registered = false;
4490
4528
 
4491
4529
  const reportFailure = (error: unknown): void => {
4492
4530
  try {
@@ -4497,37 +4535,34 @@ export function createTelegramSessionActionRuntime(
4497
4535
  };
4498
4536
 
4499
4537
  return {
4500
- register() {
4501
- if (registered) return;
4502
- registered = true;
4503
- deps.registerCommand(TELEGRAM_INTERNAL_COMMAND_NAME, {
4504
- description: TELEGRAM_INTERNAL_COMMAND_DESCRIPTION,
4505
- handler: async (args, ctx) => {
4506
- const action = pendingAction;
4507
- if (!action || args.trim() !== action.token) {
4508
- ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
4509
- return;
4510
- }
4511
- pendingAction = undefined;
4512
- switch (action.kind) {
4513
- case "replace-session":
4514
- try {
4515
- await deps.prepareReplacement?.(
4516
- ctx,
4517
- action.updateId,
4518
- action.target,
4519
- );
4520
- const result = await ctx.newSession();
4521
- if (result.cancelled)
4522
- await deps.notifyResult(action.target, "cancelled");
4523
- } catch (error) {
4524
- reportFailure(error);
4525
- await deps.notifyResult(action.target, "failure");
4526
- }
4527
- return;
4538
+ async handleInternalAction(args, ctx) {
4539
+ const action = pendingAction;
4540
+ const [operation, token, ...extra] = args.trim().split(/\s+/);
4541
+ if (
4542
+ !action || operation !== "new" ||
4543
+ token !== action.token || extra.length !== 0
4544
+ ) {
4545
+ ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
4546
+ return;
4547
+ }
4548
+ pendingAction = undefined;
4549
+ switch (action.kind) {
4550
+ case "replace-session":
4551
+ try {
4552
+ await deps.prepareReplacement?.(
4553
+ ctx,
4554
+ action.updateId,
4555
+ action.target,
4556
+ );
4557
+ const result = await ctx.newSession();
4558
+ if (result.cancelled)
4559
+ await deps.notifyResult(action.target, "cancelled");
4560
+ } catch (error) {
4561
+ reportFailure(error);
4562
+ await deps.notifyResult(action.target, "failure");
4528
4563
  }
4529
- },
4530
- });
4564
+ return;
4565
+ }
4531
4566
  },
4532
4567
  scheduleAfterUpdate(updateId, target) {
4533
4568
  if (pendingUpdateId !== undefined || pendingAction !== undefined)
@@ -4546,9 +4581,10 @@ export function createTelegramSessionActionRuntime(
4546
4581
  pendingAction = { kind: "replace-session", token, updateId, target };
4547
4582
  void Promise.resolve()
4548
4583
  .then(() =>
4549
- deps.sendUserMessage(`/${TELEGRAM_INTERNAL_COMMAND_NAME} ${token}`, {
4550
- expandPromptTemplates: true,
4551
- }),
4584
+ deps.sendUserMessage(
4585
+ `/${TELEGRAM_CONNECT_COMMAND_NAME} ${TELEGRAM_INTERNAL_COMMAND_FLAG} new ${token}`,
4586
+ { expandPromptTemplates: true },
4587
+ ),
4552
4588
  )
4553
4589
  .catch((error) => {
4554
4590
  pendingAction = undefined;
@@ -92,7 +92,6 @@ export default function (pi: Pi.ExtensionAPI) {
92
92
  getCommands,
93
93
  getThinkingLevel,
94
94
  sendUserMessage,
95
- registerCommand,
96
95
  setActiveTools,
97
96
  setModel,
98
97
  setThinkingLevel,
@@ -310,7 +309,6 @@ export default function (pi: Pi.ExtensionAPI) {
310
309
  captureAuthority: captureTelegramStateAuthority,
311
310
  });
312
311
  const sessionActionAssembly = Commands.createTelegramSessionActionAssembly({
313
- registerCommand,
314
312
  sendUserMessage,
315
313
  store: threadStore,
316
314
  getProfileName: configStore.getActiveProfileName,
@@ -1364,6 +1362,27 @@ export default function (pi: Pi.ExtensionAPI) {
1364
1362
  },
1365
1363
  getAdmissionScope: getTelegramUpdateAdmissionScope,
1366
1364
  getAdmissionJournalBinding: getTelegramQueueJournalBinding,
1365
+ getCommandReplyTarget(message) {
1366
+ if (
1367
+ message.chat.type !== "private" ||
1368
+ "business_connection_id" in message ||
1369
+ !Updates.getTelegramUpdateExecutionFence(message) ||
1370
+ lockRuntime.owns()
1371
+ ) return undefined;
1372
+ Updates.assertTelegramUpdateExecutionCurrent(message);
1373
+ const binding = resolveTelegramFollowerJournalBinding();
1374
+ const target = telegramBusFollowerRegistrationState.getTarget();
1375
+ if (
1376
+ !telegramBusFollowerRegistrationState.isRegistered() ||
1377
+ !binding ||
1378
+ binding.recoveryKey !==
1379
+ followerAdmissionLifecycleRuntime.getJournalBindingKey() ||
1380
+ !target ||
1381
+ (threadStore.getBotState().threadMode === "enabled" &&
1382
+ target.threadId === undefined)
1383
+ ) throw new Error("Telegram command reply lost its registered recipient.");
1384
+ return { ...target };
1385
+ },
1367
1386
  beginCommandEffectWork: publicationRuntime.beginWork,
1368
1387
  getMessageOwnership: messageOwnershipRuntime.getForwardOwnership,
1369
1388
  recordMessageOwnership: messageOwnershipRuntime.recordRouted,
@@ -1763,16 +1782,6 @@ export default function (pi: Pi.ExtensionAPI) {
1763
1782
  },
1764
1783
  );
1765
1784
  },
1766
- recoverRejectedRecipientTarget(error) {
1767
- const rejected =
1768
- error instanceof TelegramApi.TelegramApiFollowerNotAllowedError
1769
- ? error.requestTarget
1770
- : undefined;
1771
- const current = telegramBusFollowerRegistrationState.getTarget();
1772
- return rejected && current?.threadId !== undefined
1773
- ? { rejected, current: { chatId: current.chatId, threadId: current.threadId } }
1774
- : undefined;
1775
- },
1776
1785
  prepareUpdateForExecution(update) {
1777
1786
  return BusFollower.prepareTelegramBusFollowerJournaledUpdateForExecution(
1778
1787
  update,
@@ -2822,9 +2831,9 @@ export default function (pi: Pi.ExtensionAPI) {
2822
2831
  },
2823
2832
  }.reset,
2824
2833
  );
2825
- sessionActionsRuntime.register();
2826
2834
  Bindings.registerTelegramCommandsAndTools({
2827
2835
  pi,
2836
+ handleInternalAction: sessionActionsRuntime.handleInternalAction,
2828
2837
  agentDir: Paths.resolveAgentDir(),
2829
2838
  configStore,
2830
2839
  persistConfig: persistTelegramConfigWithSync,
@@ -90,7 +90,7 @@ export interface TelegramSettingsMenuCallbackDeps extends TelegramSettingsMutati
90
90
  export interface TelegramSettingsMenuRuntime<TContext> {
91
91
  openSettingsMenu: (
92
92
  chatId: number,
93
- replyToMessageId: number,
93
+ replyToMessageId: number | undefined,
94
94
  ctx: TContext,
95
95
  threadId?: number,
96
96
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
@@ -264,7 +264,7 @@ export interface TelegramMenuActionRuntimeDeps<
264
264
  canOfferInFlightModelSwitch: (ctx: TContext) => boolean;
265
265
  sendTextReply: (
266
266
  chatId: number,
267
- replyToMessageId: number,
267
+ replyToMessageId: number | undefined,
268
268
  text: string,
269
269
  options?: {
270
270
  target?: { chatId: number; threadId?: number };
@@ -294,21 +294,21 @@ export interface TelegramMenuActionRuntime<
294
294
  ) => Promise<void>;
295
295
  sendStatusMessage: (
296
296
  chatId: number,
297
- replyToMessageId: number,
297
+ replyToMessageId: number | undefined,
298
298
  ctx: TContext,
299
299
  threadId?: number,
300
300
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
301
301
  ) => Promise<void>;
302
302
  openModelMenu: (
303
303
  chatId: number,
304
- replyToMessageId: number,
304
+ replyToMessageId: number | undefined,
305
305
  ctx: TContext,
306
306
  threadId?: number,
307
307
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
308
308
  ) => Promise<void>;
309
309
  openThinkingMenu: (
310
310
  chatId: number,
311
- replyToMessageId: number,
311
+ replyToMessageId: number | undefined,
312
312
  ctx: TContext,
313
313
  threadId?: number,
314
314
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
@@ -1499,6 +1499,8 @@ export interface TelegramInboundRouteRuntimeDeps<
1499
1499
  getCurrentInstanceId?: () => string | undefined;
1500
1500
  getAdmissionScope?: () => string | undefined;
1501
1501
  getAdmissionJournalBinding?: () => string | undefined;
1502
+ getCommandReplyTarget?:
1503
+ Commands.TelegramCommandTargetRuntimeDeps<TContext>["getReplyTarget"];
1502
1504
  getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
1503
1505
  getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
1504
1506
  recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
@@ -1640,7 +1642,7 @@ export interface TelegramInboundRouteRuntimeDeps<
1640
1642
  ) => Promise<void>;
1641
1643
  openSettingsMenu?: (
1642
1644
  chatId: number,
1643
- replyToMessageId: number,
1645
+ replyToMessageId: number | undefined,
1644
1646
  ctx: TContext,
1645
1647
  threadId?: number,
1646
1648
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
@@ -1728,7 +1730,7 @@ export interface TelegramInboundRouteRuntimeDeps<
1728
1730
  startGuestPlaceholder?: (inlineMessageId: string) => void;
1729
1731
  sendTextReply: (
1730
1732
  chatId: number,
1731
- replyToMessageId: number,
1733
+ replyToMessageId: number | undefined,
1732
1734
  text: string,
1733
1735
  options?: {
1734
1736
  parseMode?: "HTML";
@@ -10181,6 +10183,7 @@ export function createTelegramInboundRouteRuntime<
10181
10183
  appendControlItem: deps.queueMutationRuntime.append,
10182
10184
  getAdmissionScope: deps.getAdmissionScope,
10183
10185
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
10186
+ getReplyTarget: deps.getCommandReplyTarget,
10184
10187
  onControlQueued: (message, receipt) =>
10185
10188
  reportQueueAdmission([message], [receipt]),
10186
10189
  showStatus: deps.menuActions.sendStatusMessage,
@@ -1049,17 +1049,21 @@ export class TelegramApiStaleTargetError extends Error {
1049
1049
  }
1050
1050
  }
1051
1051
 
1052
- /** The leader's exact refusal of a follower API call outside that follower's own Thread; carries only the requested target. */
1052
+ /** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
1053
1053
  export class TelegramApiFollowerNotAllowedError extends Error {
1054
1054
  readonly requestTarget: { chatId: number; threadId: number } | undefined;
1055
+ /** Present only after a structured pre-execution refusal matches the requested API method. */
1056
+ readonly rejectedRequestMethod: string | undefined;
1055
1057
 
1056
1058
  constructor(
1057
1059
  message: string,
1058
1060
  requestTarget?: { chatId: number; threadId: number },
1061
+ rejectedRequestMethod?: string,
1059
1062
  ) {
1060
1063
  super(message);
1061
1064
  this.name = "TelegramApiFollowerNotAllowedError";
1062
1065
  this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
1066
+ this.rejectedRequestMethod = rejectedRequestMethod;
1063
1067
  }
1064
1068
  }
1065
1069
 
@@ -2086,7 +2090,8 @@ export function createTelegramBridgeApiRuntime(
2086
2090
  notBeforeMs: number;
2087
2091
  };
2088
2092
  const chatActionGates = new Map<string, ChatActionGate>();
2089
- const chatActionChatGates = new Map<string, ChatActionGate>();
2093
+ // Pin shared cooldowns until every concurrent Thread settles; this state never owns their sends.
2094
+ const chatActionChatGates = new Map<string, { pendingRequests: number; notBeforeMs: number }>();
2090
2095
  const getChatActionKeys = (
2091
2096
  method: string,
2092
2097
  body: Record<string, unknown>,
@@ -2126,7 +2131,7 @@ export function createTelegramBridgeApiRuntime(
2126
2131
  }
2127
2132
  }
2128
2133
  for (const [key, candidate] of chatActionChatGates) {
2129
- if (!candidate.inFlight && nowMs >= candidate.notBeforeMs) {
2134
+ if (candidate.pendingRequests === 0 && nowMs >= candidate.notBeforeMs) {
2130
2135
  chatActionChatGates.delete(key);
2131
2136
  }
2132
2137
  }
@@ -2142,11 +2147,10 @@ export function createTelegramBridgeApiRuntime(
2142
2147
  if (chatActionChatGates.size >= chatActionMaxGates) {
2143
2148
  return true as TResponse;
2144
2149
  }
2145
- chatGate = { notBeforeMs: 0 };
2150
+ chatGate = { pendingRequests: 0, notBeforeMs: 0 };
2146
2151
  chatActionChatGates.set(chatActionKeys.chat, chatGate);
2147
2152
  }
2148
2153
  if (gate.inFlight) return (await gate.inFlight) as TResponse;
2149
- if (chatGate.inFlight) return true as TResponse;
2150
2154
  if (nowMs < gate.notBeforeMs || nowMs < chatGate.notBeforeMs) {
2151
2155
  return true as TResponse;
2152
2156
  }
@@ -2194,10 +2198,10 @@ export function createTelegramBridgeApiRuntime(
2194
2198
  })
2195
2199
  .finally(() => {
2196
2200
  if (gate.inFlight === request) gate.inFlight = undefined;
2197
- if (chatGate.inFlight === request) chatGate.inFlight = undefined;
2201
+ chatGate.pendingRequests--;
2198
2202
  });
2199
2203
  gate.inFlight = request;
2200
- chatGate.inFlight = request;
2204
+ chatGate.pendingRequests++;
2201
2205
  return request;
2202
2206
  }
2203
2207
  try {
@@ -10495,7 +10495,6 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
10495
10495
  | "getRecipientBindingKey"
10496
10496
  | "hasAuthority"
10497
10497
  | "prepareUpdateForExecution"
10498
- | "recoverRejectedRecipientTarget"
10499
10498
  >;
10500
10499
  leader: {
10501
10500
  resolveBinding: () =>
@@ -10508,9 +10507,6 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
10508
10507
  isRegistered: () => boolean;
10509
10508
  getGeneration: () => string | undefined;
10510
10509
  prepareUpdateForExecution: (update: TUpdate) => TUpdate;
10511
- recoverRejectedRecipientTarget?: NonNullable<
10512
- TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>["recoverRejectedRecipientTarget"]
10513
- >;
10514
10510
  /** Session succession before worker creation, under the exact generation-fenced binding. */
10515
10511
  prepareBinding?: (
10516
10512
  binding: TelegramUpdateAdmissionLifecycleJournalBinding & {
@@ -10535,13 +10531,6 @@ export type TelegramUpdateAdmissionWorkerRuntimeDeps<
10535
10531
  execution?: TelegramUpdateExecutionFence,
10536
10532
  ) => Promise<void>;
10537
10533
  prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
10538
- /**
10539
- * Recipient-only recovery for a journaled message whose own Thread changed (for example after its Thread was re-created):
10540
- * classifies a leader refusal of a reply to the original Thread and returns that Thread plus the owner's current one.
10541
- */
10542
- recoverRejectedRecipientTarget?: (error: unknown) =>
10543
- | { rejected: { chatId: number; threadId: number }; current: TelegramTarget & { threadId: number } }
10544
- | undefined;
10545
10534
  registry?: TelegramUpdateHandlerRegistry;
10546
10535
  };
10547
10536
 
@@ -10668,7 +10657,6 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
10668
10657
  const typedUpdate = update as TUpdate;
10669
10658
  const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
10670
10659
  // Journal/source evidence remains the original; only the warm released handler projection changes destination.
10671
- // Journal/source evidence remains the original; only the warm released handler projection changes destination.
10672
10660
  const projectTo = (destination: TelegramTarget & { threadId: number }): TUpdate => ({ ...prepared,
10673
10661
  ...(prepared.message ? { message: prepared.message.message_thread_id === destination.threadId ? { ...prepared.message }
10674
10662
  : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
@@ -10686,17 +10674,8 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
10686
10674
  if (await deps.settleTerminalExecutionFailure?.(error)) {
10687
10675
  return { kind: "complete" };
10688
10676
  }
10689
- // The leader refused a reply to a Thread this recipient no longer owns (re-created after the input was journaled).
10690
- // Re-run once against its current Thread; any other failure, or a second refusal, keeps the ordinary retry state.
10691
- const recovery = !target ? deps.recoverRejectedRecipientTarget?.(error) : undefined;
10692
- const message = prepared.message ?? prepared.edited_message;
10693
- if (recovery && message && message.message_thread_id === recovery.rejected.threadId &&
10694
- message.chat?.id === recovery.rejected.chatId && recovery.current.chatId === recovery.rejected.chatId &&
10695
- recovery.current.threadId !== recovery.rejected.threadId) {
10696
- deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "recipient-target-recovery", updateId: update.update_id,
10697
- chatId: recovery.current.chatId, staleThreadId: recovery.rejected.threadId, threadId: recovery.current.threadId });
10698
- return await executeUpdate(projectTo(recovery.current), ctx, signal);
10699
- }
10677
+ // A refused reply proves only that send was not issued, not that the handler made no prior effects.
10678
+ // Leave failure settlement to the worker's retry policy; never re-enter the handler to recover delivery here.
10700
10679
  throw error;
10701
10680
  }
10702
10681
  },
@@ -10777,9 +10756,6 @@ export function createTelegramUpdateAdmissionLifecycleAssembly<
10777
10756
  spendHistoricalInput: undefined,
10778
10757
  onHeldSourcesPrepared: undefined,
10779
10758
  prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
10780
- ...(deps.follower.recoverRejectedRecipientTarget
10781
- ? { recoverRejectedRecipientTarget: deps.follower.recoverRejectedRecipientTarget }
10782
- : {}),
10783
10759
  });
10784
10760
  },
10785
10761
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.57.7",
3
+ "version": "0.58.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Deterministic macOS CI test shards
3
+ * Zones: CI, test execution
4
+ * Keeps real-process tests isolated and partitions the remaining files without changing test deadlines.
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+ import { readdirSync } from "node:fs";
8
+ import { delimiter, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const [shard, phase, mode, ...extra] = process.argv.slice(2);
12
+ if (!["0", "1"].includes(shard) || !["process", "remaining"].includes(phase) ||
13
+ (mode !== undefined && mode !== "--list") || extra.length) {
14
+ throw new Error("Usage: run-macos-tests.mjs <0|1> <process|remaining> [--list]");
15
+ }
16
+ const root = fileURLToPath(new URL("..", import.meta.url));
17
+ const entries = readdirSync(join(root, "tests"), { withFileTypes: true })
18
+ .filter((entry) => entry.name.endsWith(".test.ts"));
19
+ if (entries.some((entry) => !entry.isFile()))
20
+ throw new Error("The CI test inventory must contain only regular test files.");
21
+ const names = entries.map((entry) => entry.name).sort();
22
+ const processTest = "process-shutdown.test.ts";
23
+ if (!names.includes(processTest)) throw new Error("The real-process test suite is missing.");
24
+ const files = (phase === "process"
25
+ ? (shard === "0" ? [processTest] : [])
26
+ : names.filter((name) => name !== processTest).filter((_, index) => index % 2 === Number(shard)))
27
+ .map((name) => `tests/${name}`);
28
+ if (mode === "--list") {
29
+ console.log(JSON.stringify(files));
30
+ } else if (files.length) {
31
+ console.log(`macOS shard ${Number(shard) + 1}/2 ${phase}: ${files.length} test file(s)`);
32
+ const result = spawnSync(process.execPath, [
33
+ "--experimental-strip-types", "--import", "./tests/fixtures/canonical-tmpdir.ts",
34
+ "--test", "--test-concurrency=1", `--test-reporter=${phase === "process" ? "spec" : "dot"}`,
35
+ ...files,
36
+ ], {
37
+ cwd: root,
38
+ stdio: "inherit",
39
+ env: { ...process.env, PATH: [join(root, "node_modules", ".bin"), process.env.PATH].filter(Boolean).join(delimiter) },
40
+ });
41
+ if (result.error) throw result.error;
42
+ process.exitCode = result.status ?? 1;
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.36.2",
3
+ "version": "0.37.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -50,7 +50,7 @@
50
50
  "@llblab/pi-codex-usage": "0.13.2",
51
51
  "@llblab/pi-grow-loop": "0.9.0",
52
52
  "@llblab/pi-state-flow": "0.26.5",
53
- "@llblab/pi-telegram": "0.57.7",
53
+ "@llblab/pi-telegram": "0.58.1",
54
54
  "@llblab/skills": "1.15.0"
55
55
  },
56
56
  "bundledDependencies": [