@llblab/pi-kit 0.21.2 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-clean-room/CHANGELOG.md +5 -0
  4. package/node_modules/@llblab/pi-clean-room/README.md +4 -3
  5. package/node_modules/@llblab/pi-clean-room/index.ts +45 -1
  6. package/node_modules/@llblab/pi-clean-room/package.json +1 -1
  7. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -1
  8. package/node_modules/@llblab/pi-telegram/BACKLOG.md +3 -8
  9. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +6 -0
  10. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +1 -4
  12. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.d.ts +0 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +0 -3
  14. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +1 -2
  15. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.d.ts +3 -3
  16. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.js +17 -3
  17. package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +5 -7
  18. package/node_modules/@llblab/pi-telegram/dist/lib/menu.js +4 -5
  19. package/node_modules/@llblab/pi-telegram/dist/lib/model.d.ts +12 -12
  20. package/node_modules/@llblab/pi-telegram/dist/lib/model.js +49 -35
  21. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +0 -2
  22. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +1 -8
  23. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +0 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +3 -6
  25. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +4 -55
  26. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +36 -10
  27. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  28. package/node_modules/@llblab/pi-telegram/docs/architecture.md +8 -8
  29. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -2
  30. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +7 -4
  31. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +0 -7
  32. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1 -3
  33. package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +25 -4
  34. package/node_modules/@llblab/pi-telegram/lib/menu.ts +14 -9
  35. package/node_modules/@llblab/pi-telegram/lib/model.ts +77 -44
  36. package/node_modules/@llblab/pi-telegram/lib/queue.ts +1 -8
  37. package/node_modules/@llblab/pi-telegram/lib/routing.ts +0 -1
  38. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +7 -58
  39. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +43 -17
  40. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  41. package/package.json +3 -3
@@ -261,12 +261,12 @@ export function createPendingModelSwitchStore() {
261
261
  has: () => selection !== undefined,
262
262
  };
263
263
  }
264
- export function canRestartTelegramTurnForModelSwitch(state) {
265
- return !state.isIdle && state.hasActiveTelegramTurn && state.hasAbortHandler;
264
+ export function canRestartAgentRunForTelegramModelSwitch(state) {
265
+ return !state.isIdle && state.hasAbortHandler;
266
266
  }
267
267
  export function shouldTriggerPendingTelegramModelSwitchAbort(state) {
268
268
  return (state.hasPendingModelSwitch &&
269
- state.hasActiveTelegramTurn &&
269
+ state.hasContinuationTurn &&
270
270
  state.hasAbortHandler &&
271
271
  state.activeToolExecutions === 0);
272
272
  }
@@ -297,7 +297,7 @@ export function buildTelegramModelSwitchContinuationText(telegramPrefix, model,
297
297
  const thinkingSuffix = thinkingLevel
298
298
  ? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.`
299
299
  : "";
300
- return `${telegramPrefix} Continue the interrupted previous Telegram request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`;
300
+ return `${telegramPrefix} Continue the interrupted previous request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`;
301
301
  }
302
302
  export function buildTelegramModelSwitchContinuationTurn(options) {
303
303
  const modelLabel = `${options.selection.model.provider}/${options.selection.model.id}`;
@@ -318,7 +318,7 @@ export function buildTelegramModelSwitchContinuationTurn(options) {
318
318
  text: buildTelegramModelSwitchContinuationText(options.telegramPrefix ?? TELEGRAM_PREFIX, options.selection.model, options.selection.thinkingLevel),
319
319
  },
320
320
  ],
321
- historyText: `Continue interrupted Telegram request on ${modelLabel}`,
321
+ historyText: `Continue interrupted request on ${modelLabel}`,
322
322
  statusSummary: `↻ ${statusLabel || "continue"}`,
323
323
  };
324
324
  }
@@ -364,46 +364,60 @@ export function createTelegramModelSwitchControllerRuntime(deps) {
364
364
  });
365
365
  }
366
366
  export function createTelegramModelSwitchController(deps) {
367
+ let pendingContinuationTurn;
368
+ const triggerPendingAbort = (ctx) => {
369
+ const turn = pendingContinuationTurn ?? deps.getActiveTurn();
370
+ if (!shouldTriggerPendingTelegramModelSwitchAbort({
371
+ hasPendingModelSwitch: !!deps.getPendingModelSwitch(),
372
+ hasContinuationTurn: !!turn,
373
+ hasAbortHandler: deps.hasAbortHandler(),
374
+ activeToolExecutions: deps.getActiveToolExecutions(),
375
+ })) {
376
+ return false;
377
+ }
378
+ const selection = deps.getPendingModelSwitch();
379
+ const abort = deps.getAbortHandler();
380
+ if (!selection || !turn || !abort)
381
+ return false;
382
+ pendingContinuationTurn = undefined;
383
+ deps.setPendingModelSwitch(undefined);
384
+ deps.queueContinuation(turn, selection, ctx);
385
+ abort();
386
+ return true;
387
+ };
367
388
  return {
368
- canOfferInFlightSwitch: (ctx) => canRestartTelegramTurnForModelSwitch({
389
+ canOfferInFlightSwitch: (ctx) => canRestartAgentRunForTelegramModelSwitch({
369
390
  isIdle: deps.isIdle(ctx),
370
- hasActiveTelegramTurn: !!deps.getActiveTurn(),
371
391
  hasAbortHandler: deps.hasAbortHandler(),
372
392
  }),
373
- stagePendingSwitch: (selection, ctx) => {
393
+ stagePendingSwitch: (selection, ctx, continuationTurn) => {
394
+ pendingContinuationTurn = deps.getActiveTurn() ?? continuationTurn;
374
395
  deps.setPendingModelSwitch(selection);
375
- deps.updateStatus(ctx);
396
+ try {
397
+ deps.updateStatus(ctx);
398
+ }
399
+ finally {
400
+ triggerPendingAbort(ctx);
401
+ }
376
402
  },
377
403
  clearPendingSwitch: () => {
404
+ pendingContinuationTurn = undefined;
378
405
  deps.setPendingModelSwitch(undefined);
379
406
  },
380
407
  queueContinuation: deps.queueContinuation,
381
- triggerPendingAbort: (ctx) => {
382
- if (!shouldTriggerPendingTelegramModelSwitchAbort({
383
- hasPendingModelSwitch: !!deps.getPendingModelSwitch(),
384
- hasActiveTelegramTurn: !!deps.getActiveTurn(),
385
- hasAbortHandler: deps.hasAbortHandler(),
386
- activeToolExecutions: deps.getActiveToolExecutions(),
387
- })) {
388
- return false;
389
- }
390
- const selection = deps.getPendingModelSwitch();
391
- const turn = deps.getActiveTurn();
392
- const abort = deps.getAbortHandler();
393
- if (!selection || !turn || !abort)
394
- return false;
395
- deps.setPendingModelSwitch(undefined);
396
- deps.queueContinuation(turn, selection, ctx);
397
- abort();
398
- return true;
408
+ triggerPendingAbort,
409
+ restartInterruptedTurn: (selection, ctx, continuationTurn) => {
410
+ const restarted = restartTelegramModelSwitchContinuation({
411
+ activeTurn: deps.getActiveTurn() ?? continuationTurn,
412
+ abort: deps.getAbortHandler(),
413
+ selection,
414
+ queueContinuation: (turn, nextSelection) => {
415
+ deps.queueContinuation(turn, nextSelection, ctx);
416
+ },
417
+ });
418
+ if (restarted)
419
+ pendingContinuationTurn = undefined;
420
+ return restarted;
399
421
  },
400
- restartInterruptedTurn: (selection, ctx) => restartTelegramModelSwitchContinuation({
401
- activeTurn: deps.getActiveTurn(),
402
- abort: deps.getAbortHandler(),
403
- selection,
404
- queueContinuation: (turn, nextSelection) => {
405
- deps.queueContinuation(turn, nextSelection, ctx);
406
- },
407
- }),
408
422
  };
409
423
  }
@@ -353,7 +353,6 @@ export interface TelegramAgentStartHookRuntimeDeps<TTurn extends PendingTelegram
353
353
  }
354
354
  export type TelegramAgentStartHookEvent = unknown;
355
355
  export interface TelegramToolExecutionRuntimeDeps {
356
- hasActiveTurn: () => boolean;
357
356
  getActiveToolExecutions: () => number;
358
357
  setActiveToolExecutions: (count: number) => void;
359
358
  }
@@ -372,7 +371,6 @@ export declare function buildTelegramAgentStartPlan<TContext = unknown>(options:
372
371
  export declare function handleTelegramAgentStartRuntime<TTurn extends PendingTelegramTurn, TContext = unknown>(deps: TelegramAgentStartRuntimeDeps<TTurn, TContext>): void;
373
372
  export declare function createTelegramAgentStartHook<TTurn extends PendingTelegramTurn, TContext = unknown>(deps: TelegramAgentStartHookRuntimeDeps<TTurn, TContext>): (_event: TelegramAgentStartHookEvent, ctx: TContext) => Promise<void>;
374
373
  export declare function getNextTelegramToolExecutionCount(options: {
375
- hasActiveTurn: boolean;
376
374
  currentCount: number;
377
375
  event: "start" | "end";
378
376
  }): number;
@@ -741,8 +741,6 @@ export function createTelegramAgentStartHook(deps) {
741
741
  };
742
742
  }
743
743
  export function getNextTelegramToolExecutionCount(options) {
744
- if (!options.hasActiveTurn)
745
- return options.currentCount;
746
744
  if (options.event === "start") {
747
745
  return options.currentCount + 1;
748
746
  }
@@ -750,20 +748,16 @@ export function getNextTelegramToolExecutionCount(options) {
750
748
  }
751
749
  export function handleTelegramToolExecutionStartRuntime(deps) {
752
750
  deps.setActiveToolExecutions(getNextTelegramToolExecutionCount({
753
- hasActiveTurn: deps.hasActiveTurn(),
754
751
  currentCount: deps.getActiveToolExecutions(),
755
752
  event: "start",
756
753
  }));
757
754
  }
758
755
  export function handleTelegramToolExecutionEndRuntime(deps) {
759
- const hasActiveTurn = deps.hasActiveTurn();
760
756
  deps.setActiveToolExecutions(getNextTelegramToolExecutionCount({
761
- hasActiveTurn,
762
757
  currentCount: deps.getActiveToolExecutions(),
763
758
  event: "end",
764
759
  }));
765
- if (hasActiveTurn)
766
- deps.triggerPendingModelSwitchAbort();
760
+ deps.triggerPendingModelSwitchAbort();
767
761
  }
768
762
  export function createTelegramAgentLifecycleHooks(deps) {
769
763
  const onAgentStart = createTelegramAgentStartHook(deps);
@@ -806,7 +800,6 @@ export function createTelegramToolExecutionHooks(deps) {
806
800
  },
807
801
  onToolExecutionEnd: (_event, ctx) => {
808
802
  handleTelegramToolExecutionEndRuntime({
809
- hasActiveTurn: deps.hasActiveTurn,
810
803
  getActiveToolExecutions: deps.getActiveToolExecutions,
811
804
  setActiveToolExecutions: deps.setActiveToolExecutions,
812
805
  triggerPendingModelSwitchAbort: () => {
@@ -535,7 +535,6 @@ export function createTelegramInboundRouteRuntime(deps) {
535
535
  updateSettingsMenuMessage: deps.updateSettingsMenuMessage,
536
536
  answerCallbackQuery: deps.answerCallbackQuery,
537
537
  isIdle: deps.isIdle,
538
- hasActiveTelegramTurn: deps.activeTurnRuntime.has,
539
538
  hasAbortHandler: deps.bridgeRuntime.abort.hasHandler,
540
539
  getActiveToolExecutions: deps.bridgeRuntime.lifecycle.getActiveToolExecutions,
541
540
  persistScopedModelPatterns: deps.persistScopedModelPatterns,
@@ -98,7 +98,6 @@ export interface TelegramTypingLoopDeps {
98
98
  sendTypingAction: (chatId: number, options?: {
99
99
  message_thread_id?: number;
100
100
  }) => Promise<unknown>;
101
- sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
102
101
  shouldContinue?: () => boolean;
103
102
  onStopped?: () => void;
104
103
  }
@@ -111,7 +110,6 @@ export interface TelegramTypingLoopStarterDeps<TContext> extends TelegramRuntime
111
110
  sendTypingAction: (chatId: number, options?: {
112
111
  message_thread_id?: number;
113
112
  }) => Promise<unknown>;
114
- sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
115
113
  updateStatus: (ctx: TContext, error?: string) => void;
116
114
  isContextActive?: (ctx: TContext) => boolean;
117
115
  isTransportAvailable?: () => boolean;
@@ -120,7 +118,7 @@ export interface TelegramTypingLoopStarterDeps<TContext> extends TelegramRuntime
120
118
  }
121
119
  export declare function createTelegramTypingLoopStarter<TContext>(deps: TelegramTypingLoopStarterDeps<TContext>): (ctx: TContext, chatId?: number, options?: {
122
120
  target?: TelegramTypingLoopTarget;
123
- }) => void;
121
+ }) => boolean;
124
122
  export declare function startTelegramTypingLoop(state: TelegramBridgeRuntimeState, deps: TelegramTypingLoopDeps): boolean;
125
123
  export declare function stopTelegramTypingLoop(state: TelegramBridgeRuntimeState): boolean;
126
124
  export declare function waitForTelegramTypingLoopIdle(state: TelegramBridgeRuntimeState, timeoutMs?: number): Promise<void>;
@@ -141,7 +139,7 @@ export interface TelegramPromptDispatchLifecycleDeps<TContext> extends TelegramR
141
139
  typing: Pick<TelegramRuntimeTypingPort, "stop">;
142
140
  startTypingLoop: (ctx: TContext, chatId?: number, options?: {
143
141
  target?: TelegramTypingLoopTarget;
144
- }) => void;
142
+ }) => boolean | void;
145
143
  updateStatus: (ctx: TContext, error?: string) => void;
146
144
  }
147
145
  export interface TelegramPromptDispatchRuntimeDeps<TContext> extends TelegramRuntimeEventRecorderPort {
@@ -151,7 +149,6 @@ export interface TelegramPromptDispatchRuntimeDeps<TContext> extends TelegramRun
151
149
  sendTypingAction: (chatId: number, options?: {
152
150
  message_thread_id?: number;
153
151
  }) => Promise<unknown>;
154
- sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
155
152
  updateStatus: (ctx: TContext, error?: string) => void;
156
153
  isContextActive?: (ctx: TContext) => boolean;
157
154
  isTransportAvailable?: () => boolean;
@@ -161,7 +158,7 @@ export interface TelegramPromptDispatchRuntimeDeps<TContext> extends TelegramRun
161
158
  export interface TelegramPromptDispatchRuntime<TContext> {
162
159
  startTypingLoop: (ctx: TContext, chatId?: number, options?: {
163
160
  target?: TelegramTypingLoopTarget;
164
- }) => void;
161
+ }) => boolean | void;
165
162
  onPromptDispatchStart: (ctx: TContext, chatId?: number) => void;
166
163
  onPromptDispatchFailure: (ctx: TContext, message: string) => void;
167
164
  }
@@ -3,7 +3,7 @@
3
3
  * Zones: pi agent runtime state, telegram session, shared coordination
4
4
  * Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
5
5
  */
6
- const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 2500;
6
+ const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 3_000;
7
7
  const TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS = 250;
8
8
  export function createTelegramBridgeRuntimeState() {
9
9
  return {
@@ -177,9 +177,9 @@ export function createTelegramTypingLoopStarter(deps) {
177
177
  Object.is(deps.getTransportAuthority(), transportAuthority)
178
178
  : deps.isTransportAvailable?.() !== false;
179
179
  if (!hasTransport())
180
- return;
180
+ return false;
181
181
  let active = true;
182
- deps.typing.start({
182
+ return deps.typing.start({
183
183
  chatId: chatId ?? deps.getDefaultChatId(),
184
184
  target: options?.target,
185
185
  intervalMs: deps.intervalMs ?? TELEGRAM_TYPING_ACTION_INTERVAL_MS,
@@ -202,13 +202,6 @@ export function createTelegramTypingLoopStarter(deps) {
202
202
  deps.typing.stop();
203
203
  return;
204
204
  }
205
- const message = error instanceof Error ? error.message : String(error);
206
- updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
207
- error: message,
208
- category: "typing",
209
- phase: "status-update",
210
- recordRuntimeEvent: deps.recordRuntimeEvent,
211
- });
212
205
  try {
213
206
  deps.recordRuntimeEvent?.("typing", error, {
214
207
  chatId: targetChatId,
@@ -219,45 +212,6 @@ export function createTelegramTypingLoopStarter(deps) {
219
212
  }
220
213
  }
221
214
  },
222
- sendAggregateTypingAction: deps.sendAggregateTypingAction
223
- ? async (targetChatId) => {
224
- if (!active)
225
- return;
226
- if (!hasTransport()) {
227
- deps.typing.stop();
228
- return;
229
- }
230
- try {
231
- await deps.sendAggregateTypingAction?.(targetChatId);
232
- }
233
- catch (error) {
234
- if (deps.isContextActive?.(ctx) === false)
235
- return;
236
- if (!active)
237
- return;
238
- if (!hasTransport()) {
239
- deps.typing.stop();
240
- return;
241
- }
242
- const message = error instanceof Error ? error.message : String(error);
243
- updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
244
- error: message,
245
- category: "typing",
246
- phase: "status-update",
247
- recordRuntimeEvent: deps.recordRuntimeEvent,
248
- });
249
- try {
250
- deps.recordRuntimeEvent?.("typing", error, {
251
- chatId: targetChatId,
252
- aggregate: true,
253
- });
254
- }
255
- catch {
256
- // Typing diagnostics cannot escape the in-flight action owner.
257
- }
258
- }
259
- }
260
- : undefined,
261
215
  shouldContinue: hasTransport,
262
216
  onStopped: () => {
263
217
  active = false;
@@ -296,12 +250,7 @@ export function startTelegramTypingLoop(state, deps) {
296
250
  const targetChatId = activeDeps.chatId;
297
251
  const threadParams = getTelegramTypingLoopThreadParams(activeDeps.target);
298
252
  const typing = Promise.resolve()
299
- .then(async () => {
300
- await activeDeps.sendTypingAction(targetChatId, threadParams);
301
- if (threadParams?.message_thread_id !== undefined) {
302
- await activeDeps.sendAggregateTypingAction?.(targetChatId);
303
- }
304
- })
253
+ .then(() => activeDeps.sendTypingAction(targetChatId, threadParams))
305
254
  .then(() => undefined)
306
255
  .catch(() => undefined);
307
256
  state.typingInFlight = typing;
@@ -900,7 +900,8 @@ export function createTelegramBridgeApiRuntime(deps) {
900
900
  const chatActionMinIntervalMs = Math.max(0, deps.chatActionMinIntervalMs ?? 2_000);
901
901
  const chatActionMaxGates = Math.max(1, deps.chatActionMaxGates ?? 256);
902
902
  const chatActionGates = new Map();
903
- const getChatActionKey = (method, body) => {
903
+ const chatActionChatGates = new Map();
904
+ const getChatActionKeys = (method, body) => {
904
905
  if (method !== "sendChatAction")
905
906
  return undefined;
906
907
  const chatId = body.chat_id;
@@ -909,32 +910,52 @@ export function createTelegramBridgeApiRuntime(deps) {
909
910
  typeof action !== "string") {
910
911
  return undefined;
911
912
  }
913
+ const chat = String(chatId);
912
914
  const threadId = body.message_thread_id;
913
- return `${String(chatId)}:${typeof threadId === "number" || typeof threadId === "string"
914
- ? String(threadId)
915
- : "all"}:${action}`;
915
+ return {
916
+ action: `${chat}:${typeof threadId === "number" || typeof threadId === "string"
917
+ ? String(threadId)
918
+ : "all"}:${action}`,
919
+ chat,
920
+ };
916
921
  };
917
922
  const callRecorded = async (method, body, options) => {
918
923
  const recoverError = deps.captureRequestErrorHandler?.(body);
919
- const chatActionKey = getChatActionKey(method, body);
920
- if (chatActionKey) {
924
+ const chatActionKeys = getChatActionKeys(method, body);
925
+ if (chatActionKeys) {
921
926
  const nowMs = now();
922
927
  for (const [key, candidate] of chatActionGates) {
923
928
  if (!candidate.inFlight && nowMs >= candidate.notBeforeMs) {
924
929
  chatActionGates.delete(key);
925
930
  }
926
931
  }
927
- let gate = chatActionGates.get(chatActionKey);
932
+ for (const [key, candidate] of chatActionChatGates) {
933
+ if (!candidate.inFlight && nowMs >= candidate.notBeforeMs) {
934
+ chatActionChatGates.delete(key);
935
+ }
936
+ }
937
+ let gate = chatActionGates.get(chatActionKeys.action);
928
938
  if (!gate) {
929
939
  if (chatActionGates.size >= chatActionMaxGates)
930
940
  return true;
931
941
  gate = { notBeforeMs: 0 };
932
- chatActionGates.set(chatActionKey, gate);
942
+ chatActionGates.set(chatActionKeys.action, gate);
943
+ }
944
+ let chatGate = chatActionChatGates.get(chatActionKeys.chat);
945
+ if (!chatGate) {
946
+ if (chatActionChatGates.size >= chatActionMaxGates) {
947
+ return true;
948
+ }
949
+ chatGate = { notBeforeMs: 0 };
950
+ chatActionChatGates.set(chatActionKeys.chat, chatGate);
933
951
  }
934
952
  if (gate.inFlight)
935
953
  return (await gate.inFlight);
936
- if (now() < gate.notBeforeMs)
954
+ if (chatGate.inFlight)
937
955
  return true;
956
+ if (nowMs < gate.notBeforeMs || nowMs < chatGate.notBeforeMs) {
957
+ return true;
958
+ }
938
959
  let request;
939
960
  request = Promise.resolve()
940
961
  .then(() => deps.client.call(method, body, {
@@ -949,7 +970,9 @@ export function createTelegramBridgeApiRuntime(deps) {
949
970
  await recoverRequestError(recoverError, error);
950
971
  if (error instanceof TelegramApiHttpError && error.status === 429) {
951
972
  const retryAfterMs = Math.max(chatActionMinIntervalMs, (error.retryAfterSeconds ?? 0) * 1_000);
952
- gate.notBeforeMs = now() + retryAfterMs;
973
+ const notBeforeMs = now() + retryAfterMs;
974
+ gate.notBeforeMs = notBeforeMs;
975
+ chatGate.notBeforeMs = Math.max(chatGate.notBeforeMs, notBeforeMs);
953
976
  deps.recordRuntimeEvent("api", error, withTelegramTransportDiagnostics(error, {
954
977
  method,
955
978
  rateLimited: true,
@@ -963,8 +986,11 @@ export function createTelegramBridgeApiRuntime(deps) {
963
986
  .finally(() => {
964
987
  if (gate.inFlight === request)
965
988
  gate.inFlight = undefined;
989
+ if (chatGate.inFlight === request)
990
+ chatGate.inFlight = undefined;
966
991
  });
967
992
  gate.inFlight = request;
993
+ chatGate.inFlight = request;
968
994
  return request;
969
995
  }
970
996
  try {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.51.1",
3
+ "version": "0.51.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -526,7 +526,7 @@ Queued controls:
526
526
 
527
527
  - `/continue` creates a priority Telegram-owned `continue` prompt.
528
528
  - Prompt-template commands expand Telegram-safe Pi template aliases before entering the prompt queue.
529
- - Model-switch continuation uses the control lane when an in-flight Telegram-owned run must be stopped and resumed.
529
+ - Model-switch continuation uses the control lane when any interruptible in-flight agent run in the current Pi session must be stopped and resumed. A Telegram-owned run retains its prompt target; otherwise the exact model-menu chat/Thread/message supplies continuation and reply ownership.
530
530
 
531
531
  Queue and menu mutations are reachable through Telegram updates handled by the current polling owner. After ownership moves, the old instance keeps processing its accepted local queue, but it no longer receives new menu callbacks or control updates for remote mutation. UI label, navigation, tab, toggle, card, and dialog rules are defined in [UI Style](./ui-style.md). Callback prefix ownership is defined in [Callback Namespaces](./callback-namespaces.md).
532
532
 
@@ -539,10 +539,10 @@ Native typing during compaction follows connected-instance activity rather than
539
539
  - Confirmed manual `/compact` starts a native `typing` keepalive in the command target and stops it on completion/failure.
540
540
  - Automatic/session compaction with an active Telegram turn reuses that turn's target.
541
541
  - Automatic/session compaction without an active Telegram turn uses the connected instance's assigned target; an unconnected instance sends nothing.
542
- - Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface; completion, native failure, timeout, and shutdown stop the keyed loop.
542
+ - 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. Authority loss and shutdown still stop the keyed loop.
543
543
  - 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.
544
544
 
545
- 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. Each loop keeps one action in flight, while the leader API runtime coalesces identical chat/thread/action calls across local and follower traffic for two seconds; expired gates prune opportunistically and at most 256 currently active keys are retained. A Telegram 429 response opens the exact action's shared `retry_after` suppression window without scheduling delayed retries or projecting expected activity throttling as a terminal status error. Assistant message start/update hooks still re-arm it during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
545
+ 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. 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.
546
546
 
547
547
  ### Rendering And Delivery
548
548
 
@@ -625,14 +625,14 @@ Telegram prompt guidance is context- and authority-aware. The package and source
625
625
 
626
626
  ## In-Flight Model Switching
627
627
 
628
- When `/model` is used during an active Telegram-owned run, the bridge can emulate Pi's interactive stop/switch/continue workflow:
628
+ When `/model` is used during an interruptible active agent run in the current Pi session, the bridge emulates Pi's interactive stop/switch/continue workflow:
629
629
 
630
630
  1. Apply the selected model immediately.
631
- 2. Queue or stage a synthetic Telegram continuation turn.
632
- 3. Abort the active Telegram turn immediately, or wait for the current tool to finish before aborting.
633
- 4. Dispatch the continuation after abort completion.
631
+ 2. Queue or stage a synthetic Telegram continuation turn before aborting.
632
+ 3. Abort immediately, or wait for every active tool execution to finish before aborting.
633
+ 4. Dispatch the continuation in the same session context under the selected model.
634
634
 
635
- This is limited to Telegram-owned runs. If Pi is busy with non-Telegram work, the bridge refuses the switch instead of hijacking unrelated activity.
635
+ A Telegram-originated run retains its active prompt target and reply anchor. For local/TUI or autonomous work without an active Telegram turn, the exact authorized model-menu chat, Thread, and message become the continuation target and reply anchor. Merely busy non-agent lifecycle work remains ineligible because no active agent abort handler exists. Pending selection and fallback-target state clear together on cancellation, new agent start, settlement, or session replacement.
636
636
 
637
637
  ## Shutdown And Timer Lifecycle
638
638
 
@@ -336,7 +336,7 @@ In Telegram private-chat Threaded Mode:
336
336
  - Prompts typed in a thread route to the owning instance.
337
337
  - Replies, previews, files, voice, and buttons stay in that thread.
338
338
  - Queue controls and reactions affect only that instance target.
339
- - Telegram's native `…typing` indicator for real agent work is sent to that instance thread and mirrored to `All`; `All` is the aggregate surface and should show activity when any bound instance is running a Telegram turn, local prompt, or autonomous continuation. Terminal `Active` remains Telegram-turn-specific. Startup/connect/reload/recovery must not send activity by themselves.
339
+ - Telegram's native `…typing` indicator for real agent work is refreshed only in that instance's exact Thread. Aggregate `All` mirroring is intentionally omitted because it duplicates every keepalive against the shared chat and amplifies Telegram flood control. Telegram turns, local prompts, and autonomous continuations all use the exact instance target. Terminal `Active` remains Telegram-turn-specific. Startup/connect/reload/recovery must not send activity by themselves.
340
340
  - Generic heartbeat pruning remains silent and preserves the thread as a restart hint. With cleanup enabled, a later exact-PID death confirmation may delete it without posting an `Instance offline` notice. With cleanup disabled, a fenced owner-detachment commit records inactivity but retains the Thread and its Workspace slot for restoration or fully guarded capacity rotation. Confirmed deletion removes live routing authority but retains the Workspace binding's friendly name and uppercase ordering slot; a later authenticated reopen probes the old target, replaces it only on exact stale/deleted evidence, and carries that name and slot onto the new Thread.
341
341
  - If the same binding identity returns, authenticated registration can reclaim the thread after the required visibility proof.
342
342
 
@@ -406,7 +406,7 @@ Threaded Mode should make follower threads behave like normal Telegram instance
406
406
  | 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 |
407
407
  | 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 |
408
408
  | 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 |
409
- | Native activity status | `sendChatAction(typing)` renders Telegram's native `…typing` indicator in the assigned thread and mirrors aggregate `All` for every agent run, including local and autonomous work; terminal identity remains `connected`, with active work included in the green Queue count | Followers route one thread action and one aggregate action through leader transport for any agent run while retaining their stable terminal `follower` identity and green activity count | 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 |
409
+ | 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 |
410
410
  | 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 |
411
411
  | `/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` |
412
412
  | 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 |
@@ -1203,10 +1203,13 @@ export function registerTelegramLifecycleRuntimeHooks({
1203
1203
  if (uiPromptActive || !canSendAgentActivity(ctx)) return false;
1204
1204
  const turn = activeTurnRuntime.get();
1205
1205
  const target = turn?.target ?? proactivePushTargetGetter();
1206
- promptDispatchRuntime.startTypingLoop(ctx, turn?.chatId ?? target?.chatId, {
1207
- target,
1208
- });
1209
- return true;
1206
+ return (
1207
+ promptDispatchRuntime.startTypingLoop(
1208
+ ctx,
1209
+ turn?.chatId ?? target?.chatId,
1210
+ { target },
1211
+ ) !== false
1212
+ );
1210
1213
  };
1211
1214
  const startActiveTurnTypingLoop = (ctx: Pi.ExtensionContext): void => {
1212
1215
  if (uiPromptActive) return;
@@ -82,13 +82,6 @@ function rejectTelegramDirectOwnership(method: string): Promise<never> {
82
82
  );
83
83
  }
84
84
 
85
- export function createTelegramAggregateTypingActionSender(
86
- runtime: Pick<TelegramBridgeApiRuntime, "call">,
87
- ): (chatId: number) => Promise<unknown> {
88
- return (chatId) =>
89
- runtime.call("sendChatAction", { chat_id: chatId, action: "typing" });
90
- }
91
-
92
85
  export function createTelegramBusAwareApiRuntime(
93
86
  deps: TelegramBusAwareApiRuntimeDeps,
94
87
  ): TelegramBridgeApiRuntime {
@@ -667,8 +667,6 @@ export default function (pi: Pi.ExtensionAPI) {
667
667
  typing,
668
668
  getDefaultChatId: proactivePushChatIdGetter,
669
669
  sendTypingAction,
670
- sendAggregateTypingAction:
671
- BusApi.createTelegramAggregateTypingActionSender(telegramApiRuntime),
672
670
  updateStatus,
673
671
  isContextActive: telegramSessionContextStore.isCurrent,
674
672
  getTransportAuthority() {
@@ -1628,7 +1626,7 @@ export default function (pi: Pi.ExtensionAPI) {
1628
1626
  loadConfig: configStore.load,
1629
1627
  setQueuedItems: telegramQueueStore.setQueuedItems,
1630
1628
  setCurrentModel: currentModelRuntime.set,
1631
- setPendingModelSwitch: pendingModelSwitchStore.set,
1629
+ setPendingModelSwitch: modelSwitchController.clearPendingSwitch,
1632
1630
  syncCounters: queue.syncCounters,
1633
1631
  syncFlags: lifecycle.syncFlags,
1634
1632
  bindDeferredDispatchContext: deferredQueueDispatchRuntime.bind,
@@ -13,6 +13,7 @@ import {
13
13
  resolveScopedModelPatterns,
14
14
  type ScopedTelegramModel,
15
15
  sortScopedModels,
16
+ type TelegramModelSwitchContinuationSource,
16
17
  type ThinkingLevel,
17
18
  } from "./model.ts";
18
19
 
@@ -157,9 +158,13 @@ export type TelegramModelMenuCallbackDeps<
157
158
  setModel: (model: TModel) => Promise<boolean>;
158
159
  setCurrentModel: (model: TModel) => void;
159
160
  setThinkingLevel: (level: ThinkingLevel) => void;
160
- stagePendingModelSwitch: (selection: ScopedTelegramModel<TModel>) => void;
161
+ stagePendingModelSwitch: (
162
+ selection: ScopedTelegramModel<TModel>,
163
+ continuationTurn: TelegramModelSwitchContinuationSource,
164
+ ) => void;
161
165
  restartInterruptedTelegramTurn: (
162
166
  selection: ScopedTelegramModel<TModel>,
167
+ continuationTurn: TelegramModelSwitchContinuationSource,
163
168
  ) => Promise<boolean> | boolean;
164
169
  };
165
170
 
@@ -961,14 +966,28 @@ export async function handleTelegramModelMenuCallbackAction<
961
966
  if (plan.selection.thinkingLevel) {
962
967
  deps.setThinkingLevel(plan.selection.thinkingLevel);
963
968
  }
964
- await deps.updateModelMenuMessage();
969
+ const continuationTurn: TelegramModelSwitchContinuationSource = {
970
+ chatId: params.state.chatId,
971
+ replyToMessageId: params.state.messageId,
972
+ target: {
973
+ chatId: params.state.chatId,
974
+ ...(params.state.threadId === undefined
975
+ ? {}
976
+ : { threadId: params.state.threadId }),
977
+ },
978
+ };
965
979
  if (plan.mode === "restart-after-tool") {
966
- deps.stagePendingModelSwitch(plan.selection);
980
+ deps.stagePendingModelSwitch(plan.selection, continuationTurn);
981
+ await deps.updateModelMenuMessage();
967
982
  await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
968
983
  return true;
969
984
  }
970
985
  if (plan.mode === "restart-now") {
971
- const restarted = await deps.restartInterruptedTelegramTurn(plan.selection);
986
+ const restarted = await deps.restartInterruptedTelegramTurn(
987
+ plan.selection,
988
+ continuationTurn,
989
+ );
990
+ await deps.updateModelMenuMessage();
972
991
  if (!restarted) {
973
992
  await deps.answerCallbackQuery(
974
993
  callbackQueryId,
@@ -976,6 +995,8 @@ export async function handleTelegramModelMenuCallbackAction<
976
995
  );
977
996
  return true;
978
997
  }
998
+ } else {
999
+ await deps.updateModelMenuMessage();
979
1000
  }
980
1001
  await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
981
1002
  return true;