@llblab/pi-telegram 0.54.3 → 0.55.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 (80) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +5 -3
  5. package/dist/lib/activity.d.ts +5 -1
  6. package/dist/lib/activity.js +31 -2
  7. package/dist/lib/bindings.d.ts +5 -1
  8. package/dist/lib/bindings.js +9 -2
  9. package/dist/lib/bus-api.d.ts +2 -2
  10. package/dist/lib/bus-api.js +7 -2
  11. package/dist/lib/bus-follower.d.ts +49 -3
  12. package/dist/lib/bus-follower.js +313 -146
  13. package/dist/lib/bus-leader.d.ts +3 -0
  14. package/dist/lib/bus-leader.js +99 -18
  15. package/dist/lib/bus.d.ts +5 -3
  16. package/dist/lib/bus.js +13 -10
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +56 -6
  20. package/dist/lib/lifecycle.d.ts +68 -0
  21. package/dist/lib/lifecycle.js +178 -58
  22. package/dist/lib/machine.d.ts +23 -0
  23. package/dist/lib/machine.js +37 -0
  24. package/dist/lib/media.d.ts +114 -28
  25. package/dist/lib/media.js +292 -119
  26. package/dist/lib/model.d.ts +54 -15
  27. package/dist/lib/model.js +98 -53
  28. package/dist/lib/polling.js +2 -0
  29. package/dist/lib/preview.d.ts +128 -113
  30. package/dist/lib/preview.js +448 -366
  31. package/dist/lib/queue.d.ts +2 -2
  32. package/dist/lib/queue.js +3 -3
  33. package/dist/lib/routing.d.ts +14 -2
  34. package/dist/lib/routing.js +546 -207
  35. package/dist/lib/runtime.d.ts +77 -11
  36. package/dist/lib/runtime.js +174 -92
  37. package/dist/lib/sections.js +0 -1
  38. package/dist/lib/telegram-api.d.ts +11 -9
  39. package/dist/lib/telegram-api.js +53 -4
  40. package/dist/lib/text-groups.d.ts +0 -26
  41. package/dist/lib/text-groups.js +51 -98
  42. package/dist/lib/thread-display.js +14 -6
  43. package/dist/lib/threads.d.ts +20 -5
  44. package/dist/lib/threads.js +111 -32
  45. package/dist/lib/turns.d.ts +2 -0
  46. package/dist/lib/turns.js +6 -1
  47. package/dist/lib/updates.d.ts +11 -4
  48. package/dist/lib/updates.js +54 -19
  49. package/dist/package.json +1 -1
  50. package/docs/activity.md +1 -1
  51. package/docs/architecture.md +16 -15
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +18 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +8 -6
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +13 -1
  58. package/lib/bus-api.ts +11 -3
  59. package/lib/bus-follower.ts +417 -203
  60. package/lib/bus-leader.ts +88 -14
  61. package/lib/bus.ts +17 -15
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +59 -9
  64. package/lib/lifecycle.ts +229 -55
  65. package/lib/machine.ts +60 -0
  66. package/lib/media.ts +438 -172
  67. package/lib/model.ts +169 -74
  68. package/lib/polling.ts +2 -0
  69. package/lib/preview.ts +715 -566
  70. package/lib/queue.ts +5 -5
  71. package/lib/routing.ts +644 -283
  72. package/lib/runtime.ts +260 -104
  73. package/lib/sections.ts +0 -2
  74. package/lib/telegram-api.ts +61 -8
  75. package/lib/text-groups.ts +75 -136
  76. package/lib/thread-display.ts +15 -4
  77. package/lib/threads.ts +130 -38
  78. package/lib/turns.ts +8 -1
  79. package/lib/updates.ts +58 -21
  80. package/package.json +1 -1
@@ -3,6 +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
+ import * as Machine from "./machine.ts";
6
7
  export interface TelegramRuntimeQueueCounters {
7
8
  nextQueuedTelegramItemOrder: number;
8
9
  nextQueuedTelegramControlOrder: number;
@@ -16,10 +17,6 @@ export interface TelegramRuntimeLifecycleFlags {
16
17
  }
17
18
  export interface TelegramBridgeRuntimeState extends TelegramRuntimeQueueCounters, TelegramRuntimeLifecycleFlags {
18
19
  abortHandler?: () => void;
19
- typingInterval?: ReturnType<typeof setInterval>;
20
- typingInFlight?: Promise<void>;
21
- typingLoopDeps?: TelegramTypingLoopDeps;
22
- typingLoopKey?: string;
23
20
  }
24
21
  export interface TelegramRuntimeQueuePort {
25
22
  syncCounters: (counters: Partial<TelegramRuntimeQueueCounters>) => void;
@@ -54,7 +51,8 @@ export interface TelegramRuntimeAbortPort {
54
51
  export interface TelegramRuntimeTypingPort {
55
52
  start: (deps: TelegramTypingLoopDeps) => boolean;
56
53
  stop: () => boolean;
57
- waitForIdle: () => Promise<void>;
54
+ /** Waits for the in-flight action, bounded so a never-settling request cannot hold shutdown. */
55
+ waitForIdle: (timeoutMs?: number) => Promise<void>;
58
56
  }
59
57
  export interface TelegramBridgeRuntime {
60
58
  state: TelegramBridgeRuntimeState;
@@ -65,7 +63,7 @@ export interface TelegramBridgeRuntime {
65
63
  typing: TelegramRuntimeTypingPort;
66
64
  }
67
65
  export declare function createTelegramBridgeRuntimeState(): TelegramBridgeRuntimeState;
68
- export declare function createTelegramBridgeRuntime(state?: TelegramBridgeRuntimeState): TelegramBridgeRuntime;
66
+ export declare function createTelegramBridgeRuntime(state?: TelegramBridgeRuntimeState, typing?: TelegramRuntimeTypingPort): TelegramBridgeRuntime;
69
67
  export declare function syncTelegramQueueRuntimeCounters(state: TelegramBridgeRuntimeState, counters: Partial<TelegramRuntimeQueueCounters>): void;
70
68
  export declare function allocateTelegramQueueItemOrder(state: TelegramBridgeRuntimeState): number;
71
69
  export declare function allocateTelegramQueueControlOrder(state: TelegramBridgeRuntimeState): number;
@@ -109,19 +107,87 @@ export interface TelegramTypingLoopStarterDeps<TContext> extends TelegramRuntime
109
107
  getDefaultChatId: () => number | undefined;
110
108
  sendTypingAction: (chatId: number, options?: {
111
109
  message_thread_id?: number;
110
+ assertAuthority?: () => void;
112
111
  }) => Promise<unknown>;
113
112
  updateStatus: (ctx: TContext, error?: string) => void;
114
113
  isContextActive?: (ctx: TContext) => boolean;
114
+ getSessionGeneration?: () => number;
115
115
  isTransportAvailable?: () => boolean;
116
+ isThreadedMode?: () => boolean;
116
117
  getTransportAuthority?: () => string | number | undefined;
117
118
  intervalMs?: number;
118
119
  }
119
120
  export declare function createTelegramTypingLoopStarter<TContext>(deps: TelegramTypingLoopStarterDeps<TContext>): (ctx: TContext, chatId?: number, options?: {
120
121
  target?: TelegramTypingLoopTarget;
121
122
  }) => boolean;
122
- export declare function startTelegramTypingLoop(state: TelegramBridgeRuntimeState, deps: TelegramTypingLoopDeps): boolean;
123
- export declare function stopTelegramTypingLoop(state: TelegramBridgeRuntimeState): boolean;
124
- export declare function waitForTelegramTypingLoopIdle(state: TelegramBridgeRuntimeState, timeoutMs?: number): Promise<void>;
123
+ /** The whole typing-loop state; only `transitionTelegramTypingLoop` produces a new one. */
124
+ export interface TelegramTypingMachineState {
125
+ /** The loop the interval serves; its callbacks live in the shell under `id`. A loop exists exactly while ticking. */
126
+ readonly loop: {
127
+ readonly id: number;
128
+ readonly key: string;
129
+ readonly chatId: number;
130
+ readonly threadParams?: {
131
+ readonly message_thread_id: number;
132
+ };
133
+ } | null;
134
+ /** The action in flight; a tick waits for it, and replacing or stopping the loop forgets it. */
135
+ readonly inFlight: number | null;
136
+ readonly nextSendId: number;
137
+ }
138
+ export type TelegramTypingEvent = {
139
+ type: "started";
140
+ /** Shell-assigned, so the shell can hold the caller's callbacks before effects run. */
141
+ loopId: number;
142
+ chatId: number | undefined;
143
+ key: string;
144
+ threadParams?: {
145
+ message_thread_id: number;
146
+ };
147
+ intervalMs: number;
148
+ /** The caller's `shouldContinue`, sampled at dispatch. */
149
+ continues: boolean;
150
+ } | {
151
+ type: "ticked";
152
+ continues: boolean;
153
+ } | {
154
+ type: "sendSettled";
155
+ sendId: number;
156
+ } | {
157
+ type: "stopRequested";
158
+ };
159
+ export type TelegramTypingEffect = {
160
+ type: "send";
161
+ loopId: number;
162
+ sendId: number;
163
+ chatId: number;
164
+ threadParams?: {
165
+ message_thread_id: number;
166
+ };
167
+ } | {
168
+ type: "armInterval";
169
+ intervalMs: number;
170
+ } | {
171
+ type: "cancelInterval";
172
+ } | {
173
+ type: "loopEnded";
174
+ loopId: number;
175
+ };
176
+ export interface TelegramTypingStep extends Machine.TelegramMachineStep<TelegramTypingMachineState, TelegramTypingEffect> {
177
+ /** `start`: a loop began or moved to a new target. `stop`: a running loop ended. */
178
+ result: boolean;
179
+ }
180
+ export declare function createTelegramTypingMachineState(): TelegramTypingMachineState;
181
+ /**
182
+ * Pure typing-loop transition. Every start replaces the caller's callbacks, even on the same target, and forgets the
183
+ * old in-flight action; only a new target sends at once on a running loop, whose interval keeps its phase.
184
+ */
185
+ export declare function transitionTelegramTypingLoop(state: TelegramTypingMachineState, event: TelegramTypingEvent): TelegramTypingStep;
186
+ /**
187
+ * The shell around `transitionTelegramTypingLoop`: it holds each loop's callbacks, samples `shouldContinue` into
188
+ * events, owns the interval and turns every action outcome into `sendSettled`.
189
+ */
190
+ export declare function createTelegramTypingLoop(): TelegramRuntimeTypingPort;
125
191
  export declare function createTelegramContextAbortHandlerSetter<TContext extends {
126
192
  abort: () => void;
127
193
  }>(abort: Pick<TelegramRuntimeAbortPort, "setHandler">): (ctx: TContext) => void;
@@ -149,11 +215,11 @@ export interface TelegramPromptDispatchRuntime<TContext> {
149
215
  startTypingLoop: (ctx: TContext, chatId?: number, options?: {
150
216
  target?: TelegramTypingLoopTarget;
151
217
  }) => boolean | void;
152
- onPromptDispatchStart: (ctx: TContext, chatId?: number) => void;
218
+ onPromptDispatchStart: (ctx: TContext, chatId?: number, target?: TelegramTypingLoopTarget) => void;
153
219
  onPromptDispatchFailure: (ctx: TContext, message: string) => void;
154
220
  }
155
221
  export declare function createTelegramPromptDispatchRuntime<TContext>(deps: TelegramPromptDispatchRuntimeDeps<TContext>): TelegramPromptDispatchRuntime<TContext>;
156
222
  export declare function createTelegramPromptDispatchLifecycle<TContext>(deps: TelegramPromptDispatchLifecycleDeps<TContext>): {
157
- onPromptDispatchStart: (ctx: TContext, chatId?: number) => void;
223
+ onPromptDispatchStart: (ctx: TContext, chatId?: number, target?: TelegramTypingLoopTarget) => void;
158
224
  onPromptDispatchFailure: (ctx: TContext, message: string) => void;
159
225
  };
@@ -3,6 +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
+ import * as Machine from "./machine.js";
6
7
  const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 3_000;
7
8
  const TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS = 250;
8
9
  export function createTelegramBridgeRuntimeState() {
@@ -16,7 +17,7 @@ export function createTelegramBridgeRuntimeState() {
16
17
  setupInProgress: false,
17
18
  };
18
19
  }
19
- export function createTelegramBridgeRuntime(state = createTelegramBridgeRuntimeState()) {
20
+ export function createTelegramBridgeRuntime(state = createTelegramBridgeRuntimeState(), typing = createTelegramTypingLoop()) {
20
21
  return {
21
22
  state,
22
23
  queue: {
@@ -49,11 +50,7 @@ export function createTelegramBridgeRuntime(state = createTelegramBridgeRuntimeS
49
50
  getHandler: () => getTelegramAbortHandler(state),
50
51
  abortTurn: () => abortTelegramTurn(state),
51
52
  },
52
- typing: {
53
- start: (deps) => startTelegramTypingLoop(state, deps),
54
- stop: () => stopTelegramTypingLoop(state),
55
- waitForIdle: () => waitForTelegramTypingLoopIdle(state),
56
- },
53
+ typing,
57
54
  };
58
55
  }
59
56
  export function syncTelegramQueueRuntimeCounters(state, counters) {
@@ -148,12 +145,6 @@ export function abortTelegramTurn(state) {
148
145
  state.abortHandler();
149
146
  return true;
150
147
  }
151
- function getTelegramTypingLoopThreadParams(target) {
152
- const threadId = target?.threadId;
153
- return Number.isInteger(threadId)
154
- ? { message_thread_id: threadId }
155
- : undefined;
156
- }
157
148
  function updateTelegramRuntimeStatusSafely(updateStatus, ctx, options) {
158
149
  try {
159
150
  updateStatus(ctx, options.error);
@@ -171,34 +162,47 @@ function updateTelegramRuntimeStatusSafely(updateStatus, ctx, options) {
171
162
  }
172
163
  export function createTelegramTypingLoopStarter(deps) {
173
164
  return (ctx, chatId, options) => {
165
+ const sessionGeneration = deps.getSessionGeneration?.();
174
166
  const transportAuthority = deps.getTransportAuthority?.();
167
+ const isContextCurrent = () => deps.isContextActive?.(ctx) !== false &&
168
+ (!deps.getSessionGeneration || Object.is(deps.getSessionGeneration(), sessionGeneration));
175
169
  const hasTransport = () => deps.getTransportAuthority
176
170
  ? transportAuthority !== undefined &&
177
171
  Object.is(deps.getTransportAuthority(), transportAuthority)
178
172
  : deps.isTransportAvailable?.() !== false;
179
- if (!hasTransport())
173
+ const target = options?.target ? { ...options.target } : undefined;
174
+ const canContinue = () => isContextCurrent() && hasTransport() &&
175
+ (deps.isThreadedMode?.() !== true ||
176
+ (target?.threadId !== undefined && Number.isSafeInteger(target.threadId) && target.threadId > 0));
177
+ // Queue dispatch precedes agent_start: only an explicit destination permits Threaded presence, never chat fallback.
178
+ if (!canContinue())
180
179
  return false;
181
180
  let active = true;
182
181
  return deps.typing.start({
183
182
  chatId: chatId ?? deps.getDefaultChatId(),
184
- target: options?.target,
183
+ target,
185
184
  intervalMs: deps.intervalMs ?? TELEGRAM_TYPING_ACTION_INTERVAL_MS,
186
185
  sendTypingAction: async (targetChatId, actionOptions) => {
187
186
  if (!active)
188
187
  return;
189
- if (!hasTransport()) {
188
+ if (!canContinue()) {
190
189
  deps.typing.stop();
191
190
  return;
192
191
  }
193
192
  try {
194
- await deps.sendTypingAction(targetChatId, actionOptions);
193
+ await deps.sendTypingAction(targetChatId, {
194
+ ...actionOptions,
195
+ // This stays process-local; adapters recheck it before HTTP/IPC issuance, not just loop ticks.
196
+ assertAuthority() {
197
+ if (!active || !canContinue())
198
+ throw new Error("Typing context authority changed.");
199
+ },
200
+ });
195
201
  }
196
202
  catch (error) {
197
- if (deps.isContextActive?.(ctx) === false)
198
- return;
199
203
  if (!active)
200
204
  return;
201
- if (!hasTransport()) {
205
+ if (!canContinue()) {
202
206
  deps.typing.stop();
203
207
  return;
204
208
  }
@@ -212,7 +216,7 @@ export function createTelegramTypingLoopStarter(deps) {
212
216
  }
213
217
  }
214
218
  },
215
- shouldContinue: hasTransport,
219
+ shouldContinue: canContinue,
216
220
  onStopped: () => {
217
221
  active = false;
218
222
  },
@@ -223,79 +227,157 @@ function getTelegramTypingLoopKey(deps) {
223
227
  const threadId = deps.target?.threadId;
224
228
  return `${deps.chatId ?? 0}:${Number.isInteger(threadId) ? threadId : "all"}`;
225
229
  }
226
- export function startTelegramTypingLoop(state, deps) {
227
- if (deps.chatId === undefined || deps.chatId === 0)
228
- return false;
229
- const previousKey = state.typingLoopKey;
230
- const previousDeps = state.typingLoopDeps;
231
- const nextKey = getTelegramTypingLoopKey(deps);
232
- if (previousDeps && previousDeps !== deps) {
233
- previousDeps.onStopped?.();
234
- state.typingInFlight = undefined;
235
- }
236
- state.typingLoopDeps = deps;
237
- state.typingLoopKey = nextKey;
238
- const sendTyping = () => {
239
- const activeDeps = state.typingLoopDeps;
240
- if (!activeDeps ||
241
- activeDeps.chatId === undefined ||
242
- activeDeps.chatId === 0)
243
- return;
244
- if (activeDeps.shouldContinue?.() === false) {
245
- stopTelegramTypingLoop(state);
246
- return;
230
+ export function createTelegramTypingMachineState() {
231
+ return { loop: null, inFlight: null, nextSendId: 0 };
232
+ }
233
+ const typingStep = (state, result, ...effects) => ({ state, effects, result });
234
+ function stopTypingLoop(state) {
235
+ if (!state.loop)
236
+ return typingStep(state, false);
237
+ return typingStep({ ...state, loop: null, inFlight: null }, true, { type: "cancelInterval" }, { type: "loopEnded", loopId: state.loop.id });
238
+ }
239
+ /** One action per tick, never two at once; a caller that stops continuing ends the loop instead. */
240
+ function sendTypingAction(state, continues) {
241
+ const loop = state.loop;
242
+ if (!loop)
243
+ return typingStep(state, false);
244
+ if (!continues)
245
+ return stopTypingLoop(state);
246
+ if (state.inFlight !== null)
247
+ return typingStep(state, false);
248
+ const sendId = state.nextSendId + 1;
249
+ return typingStep({ ...state, inFlight: sendId, nextSendId: sendId }, false, {
250
+ type: "send",
251
+ loopId: loop.id,
252
+ sendId,
253
+ chatId: loop.chatId,
254
+ ...(loop.threadParams ? { threadParams: loop.threadParams } : {}),
255
+ });
256
+ }
257
+ /**
258
+ * Pure typing-loop transition. Every start replaces the caller's callbacks, even on the same target, and forgets the
259
+ * old in-flight action; only a new target sends at once on a running loop, whose interval keeps its phase.
260
+ */
261
+ export function transitionTelegramTypingLoop(state, event) {
262
+ switch (event.type) {
263
+ case "started": {
264
+ if (event.chatId === undefined || event.chatId === 0)
265
+ return typingStep(state, false);
266
+ const previous = state.loop;
267
+ const replaced = previous
268
+ ? [{ type: "loopEnded", loopId: previous.id }]
269
+ : [];
270
+ const next = {
271
+ ...state,
272
+ inFlight: previous ? null : state.inFlight,
273
+ loop: {
274
+ id: event.loopId,
275
+ key: event.key,
276
+ chatId: event.chatId,
277
+ ...(event.threadParams ? { threadParams: event.threadParams } : {}),
278
+ },
279
+ };
280
+ if (previous?.key === event.key)
281
+ return typingStep(next, false, ...replaced);
282
+ if (previous) {
283
+ const sent = sendTypingAction(next, event.continues);
284
+ return typingStep(sent.state, true, ...replaced, ...sent.effects);
285
+ }
286
+ // A fresh loop always arms its interval; a caller that already stopped continuing ends it on the first tick.
287
+ const sent = event.continues
288
+ ? sendTypingAction(next, true)
289
+ : typingStep(next, false);
290
+ return typingStep(sent.state, true, ...sent.effects, {
291
+ type: "armInterval",
292
+ intervalMs: event.intervalMs,
293
+ });
247
294
  }
248
- if (state.typingInFlight)
249
- return;
250
- const targetChatId = activeDeps.chatId;
251
- const threadParams = getTelegramTypingLoopThreadParams(activeDeps.target);
252
- const typing = Promise.resolve()
253
- .then(() => activeDeps.sendTypingAction(targetChatId, threadParams))
254
- .then(() => undefined)
255
- .catch(() => undefined);
256
- state.typingInFlight = typing;
257
- void typing.finally(() => {
258
- if (state.typingInFlight === typing)
259
- state.typingInFlight = undefined;
260
- });
261
- };
262
- if (state.typingInterval) {
263
- if (previousKey === nextKey)
264
- return false;
265
- sendTyping();
266
- return true;
295
+ case "ticked":
296
+ return sendTypingAction(state, event.continues);
297
+ case "sendSettled":
298
+ return typingStep(state.inFlight === event.sendId ? { ...state, inFlight: null } : state, false);
299
+ case "stopRequested":
300
+ return stopTypingLoop(state);
267
301
  }
268
- sendTyping();
269
- state.typingInterval = setInterval(sendTyping, deps.intervalMs);
270
- state.typingInterval.unref?.();
271
- return true;
272
- }
273
- export function stopTelegramTypingLoop(state) {
274
- if (!state.typingInterval)
275
- return false;
276
- clearInterval(state.typingInterval);
277
- const activeDeps = state.typingLoopDeps;
278
- state.typingInterval = undefined;
279
- state.typingLoopDeps = undefined;
280
- state.typingLoopKey = undefined;
281
- state.typingInFlight = undefined;
282
- activeDeps?.onStopped?.();
283
- return true;
284
302
  }
285
- export async function waitForTelegramTypingLoopIdle(state, timeoutMs = TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS) {
286
- const inFlight = state.typingInFlight;
287
- if (!inFlight)
288
- return;
289
- if (timeoutMs <= 0) {
290
- await Promise.race([inFlight, Promise.resolve()]);
291
- return;
292
- }
293
- await Promise.race([
294
- inFlight,
295
- new Promise((resolve) => {
296
- setTimeout(resolve, timeoutMs);
297
- }),
298
- ]);
303
+ /**
304
+ * The shell around `transitionTelegramTypingLoop`: it holds each loop's callbacks, samples `shouldContinue` into
305
+ * events, owns the interval and turns every action outcome into `sendSettled`.
306
+ */
307
+ export function createTelegramTypingLoop() {
308
+ const callbacks = new Map();
309
+ let interval;
310
+ let loopIds = 0;
311
+ const continues = (loopId) => loopId === undefined
312
+ ? false
313
+ : callbacks.get(loopId)?.shouldContinue?.() !== false;
314
+ const machine = Machine.createTelegramMachineRunner({
315
+ initial: createTelegramTypingMachineState(),
316
+ transition: transitionTelegramTypingLoop,
317
+ run(effect) {
318
+ switch (effect.type) {
319
+ case "send": {
320
+ const send = callbacks.get(effect.loopId)?.sendTypingAction;
321
+ // Failures belong to the caller's action; the loop only needs to know the action is over.
322
+ void Promise.resolve()
323
+ .then(() => send?.(effect.chatId, effect.threadParams))
324
+ .then(() => undefined, () => undefined)
325
+ .then(() => machine.dispatch({ type: "sendSettled", sendId: effect.sendId }));
326
+ return;
327
+ }
328
+ case "armInterval":
329
+ interval = setInterval(() => machine.dispatch({
330
+ type: "ticked",
331
+ continues: continues(machine.state.loop?.id),
332
+ }), effect.intervalMs);
333
+ interval.unref?.();
334
+ return;
335
+ case "cancelInterval":
336
+ clearInterval(interval);
337
+ interval = undefined;
338
+ return;
339
+ case "loopEnded": {
340
+ const ended = callbacks.get(effect.loopId);
341
+ callbacks.delete(effect.loopId);
342
+ ended?.onStopped?.();
343
+ return;
344
+ }
345
+ }
346
+ },
347
+ });
348
+ return {
349
+ start(deps) {
350
+ const loopId = ++loopIds;
351
+ callbacks.set(loopId, deps);
352
+ const threadId = deps.target?.threadId;
353
+ const started = machine.dispatch({
354
+ type: "started",
355
+ loopId,
356
+ chatId: deps.chatId,
357
+ key: getTelegramTypingLoopKey(deps),
358
+ ...(Number.isInteger(threadId)
359
+ ? { threadParams: { message_thread_id: threadId } }
360
+ : {}),
361
+ intervalMs: deps.intervalMs,
362
+ continues: continues(loopId),
363
+ });
364
+ if (machine.state.loop?.id !== loopId)
365
+ callbacks.delete(loopId);
366
+ return started.result;
367
+ },
368
+ stop: () => machine.dispatch({ type: "stopRequested" }).result,
369
+ async waitForIdle(timeoutMs = TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS) {
370
+ if (machine.state.inFlight === null)
371
+ return;
372
+ const idle = machine.waitFor((state) => state.inFlight === null);
373
+ await Promise.race([
374
+ idle,
375
+ timeoutMs <= 0
376
+ ? Promise.resolve()
377
+ : new Promise((resolve) => setTimeout(resolve, timeoutMs)),
378
+ ]);
379
+ },
380
+ };
299
381
  }
300
382
  export function createTelegramContextAbortHandlerSetter(abort) {
301
383
  return (ctx) => {
@@ -327,9 +409,9 @@ export function createTelegramPromptDispatchRuntime(deps) {
327
409
  }
328
410
  export function createTelegramPromptDispatchLifecycle(deps) {
329
411
  return {
330
- onPromptDispatchStart: (ctx, chatId) => {
412
+ onPromptDispatchStart: (ctx, chatId, target) => {
331
413
  deps.lifecycle.setDispatchPending(true);
332
- deps.startTypingLoop(ctx, chatId);
414
+ deps.startTypingLoop(ctx, chatId, { target });
333
415
  updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
334
416
  category: "dispatch",
335
417
  phase: "status-update",
@@ -66,7 +66,6 @@ export function getTelegramSectionDiagnostics() {
66
66
  const registry = globalThis[SECTION_REGISTRY_KEY];
67
67
  return registry ? registry.getDiagnostics() : [];
68
68
  }
69
- // --- Registry ---
70
69
  const BACK_NAV_ROW = {
71
70
  text: "⬆️ Back",
72
71
  };
@@ -369,7 +369,9 @@ export interface TelegramApiTargetActivityRuntime {
369
369
  }[];
370
370
  listPendingChats: () => number[];
371
371
  }
372
- export declare function createTelegramApiTargetActivityRuntime(): TelegramApiTargetActivityRuntime;
372
+ export declare function createTelegramApiTargetActivityRuntime(deps?: {
373
+ captureDrain?: () => (() => void) | undefined;
374
+ }): TelegramApiTargetActivityRuntime;
373
375
  export declare function createTelegramApiTargetTrackingClient(client: TelegramApiClient, activity: TelegramApiTargetActivityRuntime): TelegramApiClient;
374
376
  export type TelegramApiWorkspaceAdmissionScopeLike = {
375
377
  kind: "target";
@@ -431,6 +433,10 @@ export interface TelegramBridgeApiRuntimeDeps {
431
433
  chatActionMinIntervalMs?: number;
432
434
  chatActionMaxGates?: number;
433
435
  }
436
+ /** Typing issuance guard is local authority, never Bot API body or IPC data. */
437
+ export interface TelegramTypingActionOptions extends Pick<TelegramApiCallOptions, "assertAuthority"> {
438
+ message_thread_id?: number;
439
+ }
434
440
  export interface TelegramBridgeApiRuntime extends Pick<TelegramApiClient, "call" | "callMultipart"> {
435
441
  downloadFile: (fileId: string, suggestedName: string, source?: TelegramAttachmentSource) => Promise<string>;
436
442
  deleteWebhook: (signal?: AbortSignal) => Promise<boolean>;
@@ -442,9 +448,7 @@ export interface TelegramBridgeApiRuntime extends Pick<TelegramApiClient, "call"
442
448
  sendChatAction: (chatId: number, action: string, options?: {
443
449
  message_thread_id?: number;
444
450
  }) => Promise<boolean>;
445
- sendTypingAction: (chatId: number, options?: {
446
- message_thread_id?: number;
447
- }) => Promise<unknown>;
451
+ sendTypingAction: (chatId: number, options?: TelegramTypingActionOptions) => Promise<unknown>;
448
452
  sendRecordVoiceAction: (chatId: number, options?: {
449
453
  message_thread_id?: number;
450
454
  }) => Promise<unknown>;
@@ -522,11 +526,7 @@ export declare function fetchTelegramBotIdentity(botToken: string, fetchImpl?: t
522
526
  export declare function callTelegramMultipart<TResponse>(botToken: string | undefined, method: string, fields: Record<string, string>, fileField: string, filePath: string, fileName: string, options?: TelegramApiCallOptions): Promise<TResponse>;
523
527
  export declare function downloadTelegramFile(botToken: string | undefined, fileId: string, suggestedName: string, tempDir: string, options?: TelegramFileDownloadOptions): Promise<string>;
524
528
  export declare function answerTelegramCallbackQuery(botToken: string | undefined, callbackQueryId: string, text?: string, options?: TelegramAnswerCallbackQueryOptions): Promise<void>;
525
- export declare function createTelegramChatActionSender<TAction extends string>(sendChatAction: (chatId: number, action: TAction, options?: {
526
- message_thread_id?: number;
527
- }) => Promise<unknown>, action: TAction): (chatId: number, options?: {
528
- message_thread_id?: number;
529
- }) => Promise<unknown>;
529
+ export declare function createTelegramChatActionSender<TAction extends string>(sendChatAction: (chatId: number, action: TAction, options?: TelegramTypingActionOptions) => Promise<unknown>, action: TAction): (chatId: number, options?: TelegramTypingActionOptions) => Promise<unknown>;
530
530
  export declare function createTelegramNativeMarkdownDraftSender(deps: {
531
531
  sendMessageDraft: TelegramBridgeApiRuntime["sendMessageDraft"];
532
532
  sendRichMessageDraft: TelegramBridgeApiRuntime["sendRichMessageDraft"];
@@ -545,6 +545,7 @@ export type TelegramWorkspaceThreadDeletionTransport = (authorize: () => {
545
545
  export declare function createDefaultTelegramBridgeApiRuntime(deps: {
546
546
  getBotToken: () => string | undefined;
547
547
  getBotScope?: () => string | undefined;
548
+ isThreadedMode?: () => boolean;
548
549
  recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
549
550
  captureRequestErrorHandler?: TelegramBridgeApiRuntimeDeps["captureRequestErrorHandler"];
550
551
  targetActivity?: TelegramApiTargetActivityRuntime;
@@ -560,5 +561,6 @@ export declare function createTelegramBridgeApiRuntime(deps: TelegramBridgeApiRu
560
561
  */
561
562
  export declare function createTelegramApiClient(getBotToken: () => string | undefined, options?: TelegramAnswerCallbackQueryOptions & {
562
563
  now?: () => number;
564
+ isThreadedMode?: () => boolean;
563
565
  }): TelegramApiClient;
564
566
  export {};