@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
@@ -80,7 +80,7 @@ const telegramBusProtocolIdentity = Bus.createTelegramCurrentBusProtocolIdentity
80
80
  export default function (pi) {
81
81
  Skills.registerTelegramSkillDiscovery(pi);
82
82
  const piRuntime = Pi.createExtensionApiRuntimePorts(pi);
83
- const { getActiveTools, getCommands, getThinkingLevel, sendUserMessage, registerCommand, setActiveTools, setModel, setThinkingLevel, } = piRuntime;
83
+ const { getActiveTools, getCommands, getThinkingLevel, sendUserMessage, setActiveTools, setModel, setThinkingLevel, } = piRuntime;
84
84
  const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
85
85
  const runtimeDiagnostics = Logging.createTelegramRuntimeDiagnosticsRuntime({
86
86
  sharedFile: true,
@@ -242,7 +242,6 @@ export default function (pi) {
242
242
  captureAuthority: captureTelegramStateAuthority,
243
243
  });
244
244
  const sessionActionAssembly = Commands.createTelegramSessionActionAssembly({
245
- registerCommand,
246
245
  sendUserMessage,
247
246
  store: threadStore,
248
247
  getProfileName: configStore.getActiveProfileName,
@@ -1093,6 +1092,25 @@ export default function (pi) {
1093
1092
  },
1094
1093
  getAdmissionScope: getTelegramUpdateAdmissionScope,
1095
1094
  getAdmissionJournalBinding: getTelegramQueueJournalBinding,
1095
+ getCommandReplyTarget(message) {
1096
+ if (message.chat.type !== "private" ||
1097
+ "business_connection_id" in message ||
1098
+ !Updates.getTelegramUpdateExecutionFence(message) ||
1099
+ lockRuntime.owns())
1100
+ return undefined;
1101
+ Updates.assertTelegramUpdateExecutionCurrent(message);
1102
+ const binding = resolveTelegramFollowerJournalBinding();
1103
+ const target = telegramBusFollowerRegistrationState.getTarget();
1104
+ if (!telegramBusFollowerRegistrationState.isRegistered() ||
1105
+ !binding ||
1106
+ binding.recoveryKey !==
1107
+ followerAdmissionLifecycleRuntime.getJournalBindingKey() ||
1108
+ !target ||
1109
+ (threadStore.getBotState().threadMode === "enabled" &&
1110
+ target.threadId === undefined))
1111
+ throw new Error("Telegram command reply lost its registered recipient.");
1112
+ return { ...target };
1113
+ },
1096
1114
  beginCommandEffectWork: publicationRuntime.beginWork,
1097
1115
  getMessageOwnership: messageOwnershipRuntime.getForwardOwnership,
1098
1116
  recordMessageOwnership: messageOwnershipRuntime.recordRouted,
@@ -1400,15 +1418,6 @@ export default function (pi) {
1400
1418
  return binding.hasAuthority?.() === true;
1401
1419
  });
1402
1420
  },
1403
- recoverRejectedRecipientTarget(error) {
1404
- const rejected = error instanceof TelegramApi.TelegramApiFollowerNotAllowedError
1405
- ? error.requestTarget
1406
- : undefined;
1407
- const current = telegramBusFollowerRegistrationState.getTarget();
1408
- return rejected && current?.threadId !== undefined
1409
- ? { rejected, current: { chatId: current.chatId, threadId: current.threadId } }
1410
- : undefined;
1411
- },
1412
1421
  prepareUpdateForExecution(update) {
1413
1422
  return BusFollower.prepareTelegramBusFollowerJournaledUpdateForExecution(update, textGroupRuntime.prepareForwardedMessage);
1414
1423
  },
@@ -2283,9 +2292,9 @@ export default function (pi) {
2283
2292
  }
2284
2293
  },
2285
2294
  }.reset);
2286
- sessionActionsRuntime.register();
2287
2295
  Bindings.registerTelegramCommandsAndTools({
2288
2296
  pi,
2297
+ handleInternalAction: sessionActionsRuntime.handleInternalAction,
2289
2298
  agentDir: Paths.resolveAgentDir(),
2290
2299
  configStore,
2291
2300
  persistConfig: persistTelegramConfigWithSync,
@@ -52,7 +52,7 @@ export interface TelegramSettingsMenuCallbackDeps extends TelegramSettingsMutati
52
52
  sectionRegistry?: TelegramSectionRegistry;
53
53
  }
54
54
  export interface TelegramSettingsMenuRuntime<TContext> {
55
- openSettingsMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
55
+ openSettingsMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
56
56
  handleCallbackQuery: (query: {
57
57
  id: string;
58
58
  data?: string;
@@ -87,7 +87,7 @@ export interface TelegramMenuActionRuntimeDeps<TContext, TModel extends MenuMode
87
87
  storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
88
88
  isIdle: (ctx: TContext) => boolean;
89
89
  canOfferInFlightModelSwitch: (ctx: TContext) => boolean;
90
- sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
90
+ sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
91
91
  target?: {
92
92
  chatId: number;
93
93
  threadId?: number;
@@ -102,9 +102,9 @@ export interface TelegramMenuActionRuntime<TContext, TModel extends MenuModel =
102
102
  updateModelMenuMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
103
103
  updateThinkingMenuMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
104
104
  updateStatusMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
105
- sendStatusMessage: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
106
- openModelMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
107
- openThinkingMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
105
+ sendStatusMessage: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
106
+ openModelMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
107
+ openThinkingMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
108
108
  }
109
109
  export declare function handleTelegramMenuCallbackEntry(callbackQueryId: string, data: string | undefined, state: TelegramModelMenuState | undefined, deps: TelegramMenuCallbackEntryDeps): Promise<void>;
110
110
  export declare function handleStoredTelegramMenuCallback<TModel extends MenuModel = MenuModel>(query: MenuCallbackQuery, deps: StoredTelegramMenuCallbackDeps<TModel>): Promise<void>;
@@ -174,6 +174,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
174
174
  getCurrentInstanceId?: () => string | undefined;
175
175
  getAdmissionScope?: () => string | undefined;
176
176
  getAdmissionJournalBinding?: () => string | undefined;
177
+ getCommandReplyTarget?: Commands.TelegramCommandTargetRuntimeDeps<TContext>["getReplyTarget"];
177
178
  getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
178
179
  getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
179
180
  recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
@@ -256,7 +257,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
256
257
  isVoiceReplyActive?: () => boolean;
257
258
  updateSettingsMenuMessage?: (state: Menu.TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
258
259
  openQueueMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
259
- openSettingsMenu?: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
260
+ openSettingsMenu?: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
260
261
  settingsMenuCallbackHandler?: (query: TCallbackQuery, ctx: TContext) => Promise<boolean>;
261
262
  queueMenuCallbackHandler: (query: TCallbackQuery, ctx: TContext) => Promise<boolean>;
262
263
  buttonActionStore?: OutboundHandlers.TelegramButtonActionStore;
@@ -303,7 +304,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
303
304
  }) => Promise<string | undefined>;
304
305
  /** Starts the animated placeholder on an answered guest inline message. */
305
306
  startGuestPlaceholder?: (inlineMessageId: string) => void;
306
- sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
307
+ sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
307
308
  parseMode?: "HTML";
308
309
  target?: Queue.TelegramQueueTarget;
309
310
  assertAuthority?: TelegramApiCallOptions["assertAuthority"];
@@ -7139,6 +7139,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7139
7139
  appendControlItem: deps.queueMutationRuntime.append,
7140
7140
  getAdmissionScope: deps.getAdmissionScope,
7141
7141
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
7142
+ getReplyTarget: deps.getCommandReplyTarget,
7142
7143
  onControlQueued: (message, receipt) => reportQueueAdmission([message], [receipt]),
7143
7144
  showStatus: deps.menuActions.sendStatusMessage,
7144
7145
  openModelMenu: deps.menuActions.openModelMenu,
@@ -505,16 +505,18 @@ export declare class TelegramApiStaleTargetError extends Error {
505
505
  threadId: number;
506
506
  });
507
507
  }
508
- /** The leader's exact refusal of a follower API call outside that follower's own Thread; carries only the requested target. */
508
+ /** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
509
509
  export declare class TelegramApiFollowerNotAllowedError extends Error {
510
510
  readonly requestTarget: {
511
511
  chatId: number;
512
512
  threadId: number;
513
513
  } | undefined;
514
+ /** Present only after a structured pre-execution refusal matches the requested API method. */
515
+ readonly rejectedRequestMethod: string | undefined;
514
516
  constructor(message: string, requestTarget?: {
515
517
  chatId: number;
516
518
  threadId: number;
517
- });
519
+ }, rejectedRequestMethod?: string);
518
520
  }
519
521
  export declare function getTelegramApiErrorRequestTarget(error: unknown): {
520
522
  chatId: number;
@@ -338,13 +338,16 @@ export class TelegramApiStaleTargetError extends Error {
338
338
  this.requestTarget = { ...requestTarget };
339
339
  }
340
340
  }
341
- /** The leader's exact refusal of a follower API call outside that follower's own Thread; carries only the requested target. */
341
+ /** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
342
342
  export class TelegramApiFollowerNotAllowedError extends Error {
343
343
  requestTarget;
344
- constructor(message, requestTarget) {
344
+ /** Present only after a structured pre-execution refusal matches the requested API method. */
345
+ rejectedRequestMethod;
346
+ constructor(message, requestTarget, rejectedRequestMethod) {
345
347
  super(message);
346
348
  this.name = "TelegramApiFollowerNotAllowedError";
347
349
  this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
350
+ this.rejectedRequestMethod = rejectedRequestMethod;
348
351
  }
349
352
  }
350
353
  export function getTelegramApiErrorRequestTarget(error) {
@@ -1036,6 +1039,7 @@ export function createTelegramBridgeApiRuntime(deps) {
1036
1039
  const chatActionMinIntervalMs = Math.max(0, deps.chatActionMinIntervalMs ?? 2_000);
1037
1040
  const chatActionMaxGates = Math.max(1, deps.chatActionMaxGates ?? 256);
1038
1041
  const chatActionGates = new Map();
1042
+ // Pin shared cooldowns until every concurrent Thread settles; this state never owns their sends.
1039
1043
  const chatActionChatGates = new Map();
1040
1044
  const getChatActionKeys = (method, body) => {
1041
1045
  if (method !== "sendChatAction")
@@ -1066,7 +1070,7 @@ export function createTelegramBridgeApiRuntime(deps) {
1066
1070
  }
1067
1071
  }
1068
1072
  for (const [key, candidate] of chatActionChatGates) {
1069
- if (!candidate.inFlight && nowMs >= candidate.notBeforeMs) {
1073
+ if (candidate.pendingRequests === 0 && nowMs >= candidate.notBeforeMs) {
1070
1074
  chatActionChatGates.delete(key);
1071
1075
  }
1072
1076
  }
@@ -1082,13 +1086,11 @@ export function createTelegramBridgeApiRuntime(deps) {
1082
1086
  if (chatActionChatGates.size >= chatActionMaxGates) {
1083
1087
  return true;
1084
1088
  }
1085
- chatGate = { notBeforeMs: 0 };
1089
+ chatGate = { pendingRequests: 0, notBeforeMs: 0 };
1086
1090
  chatActionChatGates.set(chatActionKeys.chat, chatGate);
1087
1091
  }
1088
1092
  if (gate.inFlight)
1089
1093
  return (await gate.inFlight);
1090
- if (chatGate.inFlight)
1091
- return true;
1092
1094
  if (nowMs < gate.notBeforeMs || nowMs < chatGate.notBeforeMs) {
1093
1095
  return true;
1094
1096
  }
@@ -1124,11 +1126,10 @@ export function createTelegramBridgeApiRuntime(deps) {
1124
1126
  .finally(() => {
1125
1127
  if (gate.inFlight === request)
1126
1128
  gate.inFlight = undefined;
1127
- if (chatGate.inFlight === request)
1128
- chatGate.inFlight = undefined;
1129
+ chatGate.pendingRequests--;
1129
1130
  });
1130
1131
  gate.inFlight = request;
1131
- chatGate.inFlight = request;
1132
+ chatGate.pendingRequests++;
1132
1133
  return request;
1133
1134
  }
1134
1135
  try {
@@ -1487,7 +1487,7 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate extends Te
1487
1487
  runtimeBinding: TelegramUpdateAdmissionRuntimeBinding<TContext>;
1488
1488
  inputCustodyBus?: TelegramInputCustodyBusBindingRuntime<TContext>;
1489
1489
  acquireSourceReference?: (role: "leader" | "follower", binding: TelegramUpdateAdmissionLifecycleJournalBinding) => () => void;
1490
- worker: Omit<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>, "journal" | "getJournalBindingKey" | "getRecipientBindingKey" | "hasAuthority" | "prepareUpdateForExecution" | "recoverRejectedRecipientTarget">;
1490
+ worker: Omit<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>, "journal" | "getJournalBindingKey" | "getRecipientBindingKey" | "hasAuthority" | "prepareUpdateForExecution">;
1491
1491
  leader: {
1492
1492
  resolveBinding: () => TelegramUpdateAdmissionLifecycleJournalBinding | undefined;
1493
1493
  hasAuthority: (ctx: TContext) => boolean;
@@ -1497,7 +1497,6 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate extends Te
1497
1497
  isRegistered: () => boolean;
1498
1498
  getGeneration: () => string | undefined;
1499
1499
  prepareUpdateForExecution: (update: TUpdate) => TUpdate;
1500
- recoverRejectedRecipientTarget?: NonNullable<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>["recoverRejectedRecipientTarget"]>;
1501
1500
  /** Session succession before worker creation, under the exact generation-fenced binding. */
1502
1501
  prepareBinding?: (binding: TelegramUpdateAdmissionLifecycleJournalBinding & {
1503
1502
  hasAuthority?: () => boolean;
@@ -1509,19 +1508,6 @@ export type TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate extends TelegramJou
1509
1508
  inputCustody?: TelegramCustodyExecutionJournal;
1510
1509
  defaultHandle: (update: TUpdate, ctx: TContext, execution?: TelegramUpdateExecutionFence) => Promise<void>;
1511
1510
  prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
1512
- /**
1513
- * Recipient-only recovery for a journaled message whose own Thread changed (for example after its Thread was re-created):
1514
- * classifies a leader refusal of a reply to the original Thread and returns that Thread plus the owner's current one.
1515
- */
1516
- recoverRejectedRecipientTarget?: (error: unknown) => {
1517
- rejected: {
1518
- chatId: number;
1519
- threadId: number;
1520
- };
1521
- current: TelegramTarget & {
1522
- threadId: number;
1523
- };
1524
- } | undefined;
1525
1511
  registry?: TelegramUpdateHandlerRegistry;
1526
1512
  };
1527
1513
  /** Compose source-bound routing and late grouped settlement under one worker. */
@@ -6359,7 +6359,6 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
6359
6359
  const typedUpdate = update;
6360
6360
  const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
6361
6361
  // Journal/source evidence remains the original; only the warm released handler projection changes destination.
6362
- // Journal/source evidence remains the original; only the warm released handler projection changes destination.
6363
6362
  const projectTo = (destination) => ({ ...prepared,
6364
6363
  ...(prepared.message ? { message: prepared.message.message_thread_id === destination.threadId ? { ...prepared.message }
6365
6364
  : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
@@ -6374,17 +6373,8 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
6374
6373
  if (await deps.settleTerminalExecutionFailure?.(error)) {
6375
6374
  return { kind: "complete" };
6376
6375
  }
6377
- // The leader refused a reply to a Thread this recipient no longer owns (re-created after the input was journaled).
6378
- // Re-run once against its current Thread; any other failure, or a second refusal, keeps the ordinary retry state.
6379
- const recovery = !target ? deps.recoverRejectedRecipientTarget?.(error) : undefined;
6380
- const message = prepared.message ?? prepared.edited_message;
6381
- if (recovery && message && message.message_thread_id === recovery.rejected.threadId &&
6382
- message.chat?.id === recovery.rejected.chatId && recovery.current.chatId === recovery.rejected.chatId &&
6383
- recovery.current.threadId !== recovery.rejected.threadId) {
6384
- deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "recipient-target-recovery", updateId: update.update_id,
6385
- chatId: recovery.current.chatId, staleThreadId: recovery.rejected.threadId, threadId: recovery.current.threadId });
6386
- return await executeUpdate(projectTo(recovery.current), ctx, signal);
6387
- }
6376
+ // A refused reply proves only that send was not issued, not that the handler made no prior effects.
6377
+ // Leave failure settlement to the worker's retry policy; never re-enter the handler to recover delivery here.
6388
6378
  throw error;
6389
6379
  }
6390
6380
  },
@@ -6457,9 +6447,6 @@ export function createTelegramUpdateAdmissionLifecycleAssembly(deps) {
6457
6447
  spendHistoricalInput: undefined,
6458
6448
  onHeldSourcesPrepared: undefined,
6459
6449
  prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
6460
- ...(deps.follower.recoverRejectedRecipientTarget
6461
- ? { recoverRejectedRecipientTarget: deps.follower.recoverRejectedRecipientTarget }
6462
- : {}),
6463
6450
  });
6464
6451
  },
6465
6452
  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"
@@ -42,7 +42,7 @@ Keep this boundary explicit:
42
42
 
43
43
  ### Instance, Session, And Context Cost
44
44
 
45
- Live Telegram routing belongs to the active authenticated Pi instance, while a durable Workspace Thread belongs to the exact profile/CWD/session identity. Ordinary Telegram prompts enter the session currently bound to that target. Local `/resume` replaces session-scoped runtime state and selects the destination session's binding; it never inherits the source Thread merely because the process is unchanged. Telegram exposes compaction and new-session replacement for the active session: `/new` first completes and removes its exact durable update, then dispatches an internal registered Pi command through `pi.sendUserMessage(..., { expandPromptTemplates: true })` so the handler runs with a real `ExtensionCommandContext` and calls the same `AgentSessionRuntime.newSession()` path as the terminal. Before invoking that path, the current transport owner CAS-publishes one expiring replacement intent inside the existing profile target snapshot. The intent discriminates `workspace-thread` from `classic-chat`. A same- or cross-process successor may consume either only for the exact profile, CWD, source session, target, and fresh lifetime: Threaded continuity re-keys the retained Workspace binding and preserves Thread, slot, and display identity, while Classic continuity preserves the exact chat target without creating a Workspace binding or entering topic lifecycle APIs. A confirmed callback is acknowledged and its dialog is deleted before session replacement begins. Once the successor has delivery authority, it atomically claims the intent by CAS-clearing it and then sends one separate terminal success result with bounded in-process transport retry. The old `withSession` path never publishes success, and an unclaimed intent never sends, preventing duplicate terminal notices across same-process startup and cleanup-ack ambiguity. Mismatch or expiry never authorizes binding takeover. Resume, fork, tree navigation, session switching, and full reload remain outside the stable Telegram API until Pi exposes safe public extension hooks for them.
45
+ Live Telegram routing belongs to the active authenticated Pi instance, while a durable Workspace Thread belongs to the exact profile/CWD/session identity. Ordinary Telegram prompts enter the session currently bound to that target. Local `/resume` replaces session-scoped runtime state and selects the destination session's binding; it never inherits the source Thread merely because the process is unchanged. Telegram exposes compaction and new-session replacement for the active session: `/new` first completes and removes its exact durable update, then dispatches the runtime-armed `/telegram-connect --internal new <token>` branch through `pi.sendUserMessage(..., { expandPromptTemplates: true })`, without entering ordinary Connect profile or transport startup, so the handler runs with a real `ExtensionCommandContext` and calls the same `AgentSessionRuntime.newSession()` path as the terminal. Before invoking that path, the current transport owner CAS-publishes one expiring replacement intent inside the existing profile target snapshot. The intent discriminates `workspace-thread` from `classic-chat`. A same- or cross-process successor may consume either only for the exact profile, CWD, source session, target, and fresh lifetime: Threaded continuity re-keys the retained Workspace binding and preserves Thread, slot, and display identity, while Classic continuity preserves the exact chat target without creating a Workspace binding or entering topic lifecycle APIs. A confirmed callback is acknowledged and its dialog is deleted before session replacement begins. Once the successor has delivery authority, it atomically claims the intent by CAS-clearing it and then sends one separate terminal success result with bounded in-process transport retry. The old `withSession` path never publishes success, and an unclaimed intent never sends, preventing duplicate terminal notices across same-process startup and cleanup-ack ambiguity. Mismatch or expiry never authorizes binding takeover. Resume, fork, tree navigation, session switching, and full reload remain outside the stable Telegram API until Pi exposes safe public extension hooks for them.
46
46
 
47
47
  `/telegram-connect` never launches a hidden or headless Pi process. A long-lived background Pi process can own Telegram only when something else explicitly launched that process and it satisfies the normal lock/runtime rules. Pi `print` and `json` modes stay passive and exit rather than becoming hidden polling owners.
48
48
 
@@ -685,7 +685,7 @@ Native typing during compaction follows connected-instance activity rather than
685
685
  - Thread-targeted typing is sent only to the concrete thread. Aggregate `All` mirroring is intentionally omitted because duplicating every keepalive multiplies shared-chat flood pressure. Compaction completion, failure, or timeout stops only a loop actually started by the compaction observer; a pre-existing agent-owned loop remains active. Ownership is sticky across a repeated compaction start, whose shared-loop start reports `false` for the observer's own running loop. Authority loss and shutdown still stop the keyed loop.
686
686
  - Pi `ui_prompt_start` pauses typing while an extension-owned local prompt waits for the operator; `ui_prompt_end` emits the matching Activity boundary and resumes typing whenever agent or compaction work remains unsettled.
687
687
 
688
- At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Streaming keepalives reuse a valid typing owner with the same session generation, transport and target rather than revoking its pending HTTP/IPC action. Fresh context wrappers may reuse that owner within a proven generation; without generation evidence, context identity must match. Explicit queue dispatch and Telegram prompt handoff still replace the owner. Queue dispatch carries the queued turn's exact target into typing before `agent_start`, rather than reducing it to a chat ID. In Threaded Mode, missing or invalid Thread targets are refused before starting a loop or invoking the API; mode changes also invalidate a captured Classic chat-only loop. A rejected start never replaces an existing valid Thread loop. The starter also requires the captured Pi context and session generation to remain current at start, queued send, tick and late failure boundaries, even if the transport epoch is unchanged; replacing a context object in place cannot revive old activity. Message activity hooks capture generation across awaited preview work, and agent-start bindings recheck it before post-await activity/typing, so an obsolete finally or continuation cannot acquire replacement ownership. The typing sender carries its captured loop/context/generation/transport assertion as process-local call authority: direct API issuance rechecks it after queued work, and the follower caller rechecks it after registration waits and immediately before socket issuance without putting callbacks in IPC data. Automatic typing uses one HTTP attempt with no transport-family fallback or delayed replay, including forwarded calls; a future keepalive is a fresh owner-checked attempt. An already issued HTTP/RPC cannot be retroactively unsent. Each loop refreshes its exact target every three seconds and keeps one action in flight. The leader API runtime coalesces identical chat/thread/action calls for two seconds, permits at most one concurrent chat action per chat, and shares a Telegram 429 `retry_after` fence across every Thread key in that chat; expired gates prune opportunistically and each gate family retains at most 256 active keys. Suppression never schedules a delayed retry. A failed typing action remains a structured diagnostic but cannot project `error` onto an otherwise healthy connected/leader/follower status. Assistant message start/update hooks still re-arm typing during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
688
+ At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Streaming keepalives reuse a valid typing owner with the same session generation, transport and target rather than revoking its pending HTTP/IPC action. Fresh context wrappers may reuse that owner within a proven generation; without generation evidence, context identity must match. Explicit queue dispatch and Telegram prompt handoff still replace the owner. Queue dispatch carries the queued turn's exact target into typing before `agent_start`, rather than reducing it to a chat ID. In Threaded Mode, missing or invalid Thread targets are refused before starting a loop or invoking the API; mode changes also invalidate a captured Classic chat-only loop. A rejected start never replaces an existing valid Thread loop. The starter also requires the captured Pi context and session generation to remain current at start, queued send, tick and late failure boundaries, even if the transport epoch is unchanged; replacing a context object in place cannot revive old activity. Message activity hooks capture generation across awaited preview work, and agent-start bindings recheck it before post-await activity/typing, so an obsolete finally or continuation cannot acquire replacement ownership. The typing sender carries its captured loop/context/generation/transport assertion as process-local call authority: direct API issuance rechecks it after queued work, and the follower caller rechecks it after registration waits and immediately before socket issuance without putting callbacks in IPC data. Automatic typing uses one HTTP attempt with no transport-family fallback or delayed replay, including forwarded calls; a future keepalive is a fresh owner-checked attempt. An already issued HTTP/RPC cannot be retroactively unsent. Each loop refreshes its exact target every three seconds and keeps one action in flight. The leader API runtime coalesces identical chat/thread/action calls for two seconds, admits different Threads independently, and shares a real Telegram 429 `retry_after` fence across every Thread key in that chat. A pending-request count retains that shared state until every concurrent action settles, so completion of a faster peer cannot orphan a late 429; expired gates prune opportunistically and each gate family retains at most 256 active keys. Suppression never schedules a delayed retry. A failed typing action remains a structured diagnostic but cannot project `error` onto an otherwise healthy connected/leader/follower status. Assistant message start/update hooks still re-arm typing during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
689
689
 
690
690
  ### Rendering And Delivery
691
691
 
@@ -485,7 +485,7 @@ Threaded Mode should make follower threads behave like normal Telegram instance
485
485
  | Replies/finals | Final replies land in the same thread | Follower finals go through leader transport into follower thread | Outbound calls carry target and inject `message_thread_id` | Reply delivery and bus API tests |
486
486
  | Previews/Rich Drafts | Draft previews use the active thread target | Follower previews use the same native draft lifecycle through the leader | Preview transport preserves target and draft id | Preview thread-target tests |
487
487
  | Attachments/voice | Files and voice upload in the instance thread | Follower uploads route through leader multipart transport | Multipart calls are target-scoped and follower-authorized | Bus allowlist and outbound delivery tests |
488
- | Native activity status | `sendChatAction(typing)` refreshes Telegram's native `…typing` indicator only in the assigned Thread for every agent run, including local and autonomous work; aggregate `All` is not mirrored, terminal identity remains `connected`, and active work stays included in the green Queue count | Followers route the exact Thread action through leader transport while retaining their stable terminal `follower` identity and green activity count; the leader admits one concurrent action per chat and shares Telegram 429 cooldown across that chat's Thread keys | Agent lifecycle targets the active Telegram turn when present and otherwise the instance binding; one keyed loop avoids duplicate aggregate sends/rate-limit pressure | Agent-start binding, typing-loop, target-routing, and terminal-status regressions |
488
+ | Native activity status | `sendChatAction(typing)` refreshes Telegram's native `…typing` indicator only in the assigned Thread for every agent run, including local and autonomous work; aggregate `All` is not mirrored, terminal identity remains `connected`, and active work stays included in the green Queue count | Followers route the exact Thread action through leader transport while retaining their stable terminal `follower` identity and green activity count; the leader admits different Threads independently, coalesces exact chat/Thread/action calls and shares real Telegram 429 cooldown across that chat's Thread keys | Agent lifecycle targets the active Telegram turn when present and otherwise the instance binding; one keyed loop avoids duplicate aggregate sends/rate-limit pressure | Agent-start binding, typing-loop, target-routing, and terminal-status regressions |
489
489
  | Leader election / promotion | Current leader keeps its thread across reload | A promoted follower keeps its existing thread, slot, and name when elected and after later reload | Promotion converts the current follower binding into the leader profile before forced lock acquisition, so leader startup reuses it instead of provisioning a new thread | Follower heartbeat recovery passes binding snapshot into promotion; own-topic provisioner reuses promoted bindings |
490
490
  | `/start` command/menu bootstrap | Registers visible bot commands and opens the menu | Follower `/start` can refresh the bot command menu through the leader and open its local menu without warnings | Bot command registration is a validated global Bot API call allowed through trusted follower bus transport | Bus allowlist regression for `setMyCommands` |
491
491
  | Follower reconnect | Existing leader binding is reused only when still usable | Same-process `/new` or `/reload` suspends the old follower socket/context and automatically re-registers the new session to the exact prior target; explicit reconnect to a genuinely closed/stale Telegram tab still recreates a visible thread before success | A short-lived handoff carries the assigned target across session replacement, and the leader transfers that binding to the new runtime instance id by stable manual-follower identity; stale Bot API errors remain the proof for fresh provisioning | Session handoff/refresh, leader binding-transfer, persisted leader-reload reuse, and stale-target replacement regressions |
@@ -673,12 +673,26 @@ This inspection authorizes no reload/restart, live-state mutation, fault injecti
673
673
  ## Security Boundaries
674
674
 
675
675
  - Messages, edits, callbacks, and reactions check user authorization, not only chat/thread membership.
676
- - Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary. A reply refused because it targets a Thread other than the follower's own surfaces as the typed `TelegramApiFollowerNotAllowedError` carrying only the requested target. A journaled recipient input whose Thread was since re-created re-runs exactly once against the follower's current Thread (reply anchor cleared, diagnostic `recipient-target-recovery`); a second refusal or any other failure keeps the ordinary durable `retry-wait`.
676
+ - Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary. Follower API authorization refusal surfaces as `TelegramApiFollowerNotAllowedError` with the requested target when available. Only the leader's structured `follower-api-not-allowed` negative acknowledgement, emitted before API delegation and matched to the requested method, populates `rejectedRequestMethod`. Legacy error text, identical text thrown after delegation, a mismatched method or a lost acknowledgement supplies no such proof; old peers continue to receive the same human-readable rejection. The structured refusal proves only that the rejected API request was not executed, not that the command had no earlier effects. Admission therefore never immediately re-enters the handler or retargets its source to recover a reply. The original input remains in durable `retry-wait`; ordinary retries retain their documented at-least-once semantics. Safe reply-request recovery for re-created recipient Threads remains open in the backlog.
677
677
  - Follower Bot API proxying is allowlisted and target-scoped where applicable. Ordinary sends remain confined to the follower's assigned thread; trusted runtime-marked `telegram_message` cross-target sends may address only a different thread inside the same paired chat, and the leader strips the internal marker before calling Telegram. This preserves requested inter-thread delivery without granting arbitrary bot or cross-chat control.
678
678
  - Button and section callbacks verify authorized `from.id` and owning target/instance.
679
679
  - Generated artifacts stay scoped to the owning thread after leader failover.
680
680
  - Diagnostics redact bot tokens, large prompts, attachment paths, and handler output.
681
681
 
682
+ ### Reply Recovery Ownership Boundary
683
+
684
+ A destination belongs to the complete delivery operation, not just the outgoing Bot API body. `replies.sendTelegramChunkSequence` captures one target for all chunks and records message ownership against it. Menu state independently retains `chatId`, `threadId` and the returned message ID through `menu-model.createTelegramMenuDelivery`. Its numeric send result cannot report a relocated Thread to the menu owner. Rewriting only a refused send's `message_thread_id` would therefore separate actual delivery from ownership and callback state; moving only later chunks would also split one reply across tabs.
685
+
686
+ Follower API arguments use one private JSON-wire snapshot made before registration waits. Issued method/body, refusal target/method and slow-call diagnostics derive from that same snapshot; later caller mutations cannot relabel the attempt. Caller serialization runs once, with local authority checked before capture and again before issuance.
687
+
688
+ Admission and recipient identity are separate. The local update execution fence carries its admission generation, update ID and abort signal; the queue-owner snapshot carries instance, process/birth and session-generation identity. Neither contains a complete recipient/session/profile/registration/target grant. Receipt and Workspace-admission namespaces are not the follower's registered profile key: selected-menu composition supplies that registered key separately, while the leader independently captures its active Workspace scope. No ordinary recipient-scoped reply-delivery lease joins these views today.
689
+
690
+ Pre-publication destination selection is separate from refused-send recovery. For an executing private-DM follower journal, the status/model/settings and text-reply target adapters select the acknowledged recipient target while the active profile/session journal binding still matches the worker. The execution fence checks admission currency; it does not grant delivery. The selected target is passed to the menu owner before state construction and to the existing reply sender before chunking, with the old reply anchor removed on a Thread change. The original input and its journal are not rewritten. A different chat, a Thread-to-All fallback or missing recipient binding refuses selection; explicit selected-menu authority keeps its original fixed destination. The ordinary leader-side API authorization remains unchanged. This prevents a stale source address from choosing the initial reply destination; it neither relocates an issued request nor supplies the missing reply-recovery lease.
691
+
692
+ Structured refusal is evidence about one API attempt, not a delivery or command recovery grant. Current delivery propagates that refusal without a replacement send, without editing the target or reply anchor, and without replaying acknowledged chunks. Already acknowledged messages keep their original ownership; the refused message acquires none. This applies to interactive HTML menus, split native Markdown and single Rich Message sections.
693
+
694
+ Guarded follower delivery must use a recipient-scoped transport that enforces authority at the leader, not serialize a local callback through the ordinary proxy. The existing selected-menu channel is bound to an exact live-rebind operation, source cohort and recipient; a raw journaled command with no matching retained operation cannot borrow that grant. Any future reply-only recovery must establish its own exact authority, keep the destination consistent in delivery and menu ownership, clear stale reply metadata, and refuse relocation after partial publication or an unknown outcome. Handler-replay containment stays enabled until that boundary is proven; this is not an automatic recovery feature.
695
+
682
696
  ## Acceptance Criteria
683
697
 
684
698
  - [x] The lock semantics are redesigned as Telegram bus leadership with liveness proof and stale takeover rules.
@@ -44,6 +44,8 @@ Stable commands inside Pi:
44
44
  - `/telegram-disconnect` — after destructive confirmation, stop polling and release ownership without deleting or silencing accepted local queue state. A successful command queues the corresponding hidden, non-triggering disconnected context note; cancelled or failed disconnects do not publish a false state transition. In Threaded Mode it deletes this instance's current Telegram thread; a follower waits for its active leader to confirm generation-fenced cleanup before stopping. Graceful Pi `quit` performs the same teardown without prompting, while session replacement suspends transport without disconnect teardown. Local `/resume` carries connection intent rather than the source Thread; other supported replacements retain their existing continuity rules.
45
45
  - `/telegram-status` — show connection, polling, execution, queue, and recent event diagnostics; debug output separates poller and worker progress, durable automatic-retry state, exact foreign queued-owner identity, and negotiated protocol/build/capabilities.
46
46
 
47
+ The `/telegram-connect --internal new <token>` form is a runtime-only dispatch, not a profile connection or a manual new-session shortcut. It requires the exact prepared one-use action and bypasses profile activation, connection intents and startup/recovery. Missing, forged, replayed, unsupported or malformed internal calls fail closed; invalid calls do not consume a valid pending action. The flag is reserved, but `internal` without the leading dashes remains a valid profile name. The standalone `/telegram-internal` command is no longer registered.
48
+
47
49
  ### Telegram commands
48
50
 
49
51
  - `/name Name` — set the durable manual display name of the current Thread. The routed leader or follower uses the authenticated target-fenced mutation and edits the visible title over the active automatic projection. Bare `/name` immediately opens expiring exact-target input; the next valid name is consumed before agent dispatch. Cancel is always available; **Reset to automatic** appears only when a manual name exists. Entering a bare uppercase slot letter such as `A` is also treated as an explicit reset to the current automatic display projection rather than as a manual name.
@@ -51,7 +53,7 @@ Stable commands inside Pi:
51
53
  Stable commands inside the paired Telegram DM:
52
54
 
53
55
  - `/start` — pair when needed and open the main application menu.
54
- - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-internal` Pi gateway using `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Manual invocation reports that the gateway cannot be run manually; the armed handler receives a real `ExtensionCommandContext` and calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. In a follower Thread the leader publishes and claims the intent on the follower's behalf over capability-gated, exact-generation local bus requests; followers never persist leader-owned state. Busy, identity-mismatch, stale-registration, incompatible-leader, and unavailable-host paths fail closed.
56
+ - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-connect --internal new <token>` Pi gateway using `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Manual invocation reports that the gateway cannot be run manually; the armed handler receives a real `ExtensionCommandContext` and calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. In a follower Thread the leader publishes and claims the intent on the follower's behalf over capability-gated, exact-generation local bus requests; followers never persist leader-owned state. Busy, identity-mismatch, stale-registration, incompatible-leader, and unavailable-host paths fail closed.
55
57
  - `/compact` — open confirmation and compact when idle.
56
58
  - `/next` — abort active work first when needed, attempt the interrupted prompt's abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. That dispatch notice owns the turn's one reply header; later answer messages do not reply to the same prompt again. Abort-notice failure is diagnostic and cannot block dispatch. A later `/abort` or `/stop` cancels both pending transition notices before taking ownership. The `/next` command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
57
59
  - `/continue` — enqueue a priority `continue` prompt.
@@ -869,6 +869,8 @@ export function createTelegramLiveTargetWorkObserver<TContext>(deps: {
869
869
 
870
870
  interface TelegramCommandsAndToolsBindingDeps {
871
871
  pi: Pi.ExtensionAPI;
872
+ handleInternalAction:
873
+ Commands.TelegramSessionActionRuntime["handleInternalAction"];
872
874
  agentDir: string;
873
875
  configStore: Config.TelegramConfigStore;
874
876
  persistConfig: (config?: Config.TelegramConfig) => Promise<void>;
@@ -926,6 +928,7 @@ interface TelegramCommandsAndToolsBindingDeps {
926
928
 
927
929
  export function registerTelegramCommandsAndTools({
928
930
  pi,
931
+ handleInternalAction,
929
932
  agentDir,
930
933
  configStore,
931
934
  persistConfig,
@@ -1023,6 +1026,7 @@ export function registerTelegramCommandsAndTools({
1023
1026
  );
1024
1027
  };
1025
1028
  Commands.registerTelegramBridgeCommands(pi, {
1029
+ handleInternalAction,
1026
1030
  promptForConfig: async (ctx, profileName) => {
1027
1031
  const nextProfileName = profileName ?? undefined;
1028
1032
  if (profileName && !Config.isValidTelegramProfileName(profileName)) {
@@ -1919,11 +1919,16 @@ export function createTelegramBusFollowerApiCaller(
1919
1919
  method: string,
1920
1920
  args: unknown[],
1921
1921
  localOptions: Pick<TelegramApiCallOptions, "assertAuthority"> | undefined,
1922
- timing: { registeredAtMs?: number },
1922
+ timing: { registeredAtMs?: number; apiMethod?: string },
1923
1923
  ): Promise<unknown> => {
1924
1924
  const assertion = localOptions?.assertAuthority;
1925
1925
  const assertCurrent = () => assertTelegramApiCallAuthority(assertion, false);
1926
1926
  assertCurrent();
1927
+ // Match IPC's JSON semantics once, before awaits; send and refusal metadata share this identity.
1928
+ const requestArgs = JSON.parse(JSON.stringify(args)) as unknown[];
1929
+ const apiMethod = (method === "call" || method === "callMultipart") && typeof requestArgs[0] === "string"
1930
+ ? requestArgs[0] : method;
1931
+ timing.apiMethod = apiMethod;
1927
1932
  const registration = await resolveTelegramBusFollowerRegistration(
1928
1933
  deps,
1929
1934
  timeoutMs,
@@ -1943,18 +1948,13 @@ export function createTelegramBusFollowerApiCaller(
1943
1948
  instanceId: deps.instanceId,
1944
1949
  registrationGeneration: registration.generation,
1945
1950
  method,
1946
- args,
1951
+ args: requestArgs,
1947
1952
  sentAtMs: getNowMs(),
1948
1953
  },
1949
1954
  assertion ? assertCurrent : undefined,
1950
1955
  );
1951
1956
  } catch (error) {
1952
1957
  if (error instanceof TelegramBusLocalAuthorityError) throw new TelegramApiAuthorityError(error.requestIssued);
1953
- const apiMethod =
1954
- (method === "call" || method === "callMultipart") &&
1955
- typeof args[0] === "string"
1956
- ? args[0]
1957
- : method;
1958
1958
  if (!isTelegramApiMethodRetrySafe(apiMethod)) {
1959
1959
  throw new TelegramApiCommitUnknownError(apiMethod, error);
1960
1960
  }
@@ -1988,25 +1988,30 @@ export function createTelegramBusFollowerApiCaller(
1988
1988
  new Error(message ?? "Telegram bus API call result is ambiguous."),
1989
1989
  );
1990
1990
  }
1991
- if (message === TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE) {
1991
+ const refusedMethod = response?.kind === "bus.ack" && !response.ok &&
1992
+ response.error?.code === "follower-api-not-allowed" &&
1993
+ response.error.method === apiMethod
1994
+ ? apiMethod : undefined;
1995
+ if (refusedMethod || message === TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE) {
1992
1996
  const body =
1993
- (method === "call" || method === "callMultipart") && isRecord(args[1])
1994
- ? args[1]
1997
+ (method === "call" || method === "callMultipart") && isRecord(requestArgs[1])
1998
+ ? requestArgs[1]
1995
1999
  : undefined;
1996
2000
  const chatId = Number(body?.chat_id);
1997
2001
  const threadId = Number(body?.message_thread_id);
1998
2002
  throw new TelegramApiFollowerNotAllowedError(
1999
- message,
2003
+ message ?? TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
2000
2004
  Number.isSafeInteger(chatId) && Number.isSafeInteger(threadId)
2001
2005
  ? { chatId, threadId }
2002
2006
  : undefined,
2007
+ refusedMethod,
2003
2008
  );
2004
2009
  }
2005
2010
  throw new Error(message ?? "Telegram bus API call failed.");
2006
2011
  };
2007
2012
  return async (method, args, localOptions) => {
2008
2013
  const startedAtMs = getNowMs();
2009
- const timing: { registeredAtMs?: number } = {};
2014
+ const timing: { registeredAtMs?: number; apiMethod?: string } = {};
2010
2015
  let outcome = "ok";
2011
2016
  try {
2012
2017
  return await caller(method, args, localOptions, timing);
@@ -2016,11 +2021,7 @@ export function createTelegramBusFollowerApiCaller(
2016
2021
  } finally {
2017
2022
  const elapsedMs = getNowMs() - startedAtMs;
2018
2023
  if (elapsedMs >= slowCallMs) {
2019
- const apiMethod =
2020
- (method === "call" || method === "callMultipart") &&
2021
- typeof args[0] === "string"
2022
- ? args[0]
2023
- : method;
2024
+ const apiMethod = timing.apiMethod ?? method;
2024
2025
  try {
2025
2026
  deps.recordRuntimeEvent?.(
2026
2027
  "bus",
@@ -4189,10 +4189,17 @@ async function handleFollowerApiCall(
4189
4189
  args: envelope.args,
4190
4190
  })
4191
4191
  ) {
4192
- return rejectTelegramBusRequest(
4193
- envelope.requestId,
4194
- TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
4195
- );
4192
+ const apiMethod = getFollowerApiMethodAndBody(envelope).apiMethod;
4193
+ return {
4194
+ ...rejectTelegramBusRequest(
4195
+ envelope.requestId,
4196
+ TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
4197
+ ),
4198
+ // Only this pre-dispatch authorizer refusal certifies that the API method was not called.
4199
+ ...(/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(apiMethod)
4200
+ ? { error: { code: "follower-api-not-allowed" as const, method: apiMethod } }
4201
+ : {}),
4202
+ };
4196
4203
  }
4197
4204
  if (!deps.callApi) {
4198
4205
  return rejectTelegramBusRequest(
@@ -1260,6 +1260,7 @@ export type TelegramBusEnvelope = (
1260
1260
  | "ledger-overloaded"
1261
1261
  | "incompatible-protocol"
1262
1262
  | "stale-target"
1263
+ | "follower-api-not-allowed"
1263
1264
  | "workspace-binding-unavailable";
1264
1265
  method?: string;
1265
1266
  chatId?: number;
@@ -4148,10 +4149,17 @@ function parseAckEnvelope(
4148
4149
  code === "ledger-overloaded" ||
4149
4150
  code === "incompatible-protocol" ||
4150
4151
  code === "stale-target" ||
4152
+ code === "follower-api-not-allowed" ||
4151
4153
  code === "workspace-binding-unavailable"
4152
4154
  ) {
4153
4155
  const chatId = value.error.chatId;
4154
4156
  const threadId = value.error.threadId;
4157
+ if (
4158
+ code === "follower-api-not-allowed" &&
4159
+ (value.ok || Object.hasOwn(value, "result") ||
4160
+ typeof value.error.method !== "string" ||
4161
+ !/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(value.error.method))
4162
+ ) return undefined;
4155
4163
  if (
4156
4164
  code === "stale-target" &&
4157
4165
  (!Number.isSafeInteger(chatId) || !Number.isSafeInteger(threadId))