@llblab/pi-telegram 0.16.2 → 0.16.4

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.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.16.4: Follow-Up And Runtime Mode Hotfix
4
4
 
5
- No open changes.
5
+ - `[Runtime]` Feature-detect Pi `ctx.mode` and keep `print`/`json` runs passive by blocking polling start/resume in those modes. Impact: CLI/headless sessions can finish local work without inheriting Telegram polling, while `tui`/`rpc` and older Pi runtimes keep existing behavior.
6
+ - `[Queue]` Forward queued Telegram prompts and unknown callback fallbacks to Pi with explicit `followUp` delivery semantics. Impact: Telegram input keeps the existing non-steering queue contract even when Pi's native streaming-message API requires an explicit busy-run policy.
7
+
8
+ ## 0.16.3: Ownership And Shutdown Hotfix
9
+
10
+ - `[Ownership]` Lock-gated proactive local/headless final-result push so only the current `/telegram-connect` owner can send non-Telegram agent-end replies to the paired chat, while accepted Telegram turns and queued work still finalize session-locally after polling ownership moves away. Impact: child/headless/non-owner instances no longer leak unrelated local results into Telegram.
11
+ - `[Shutdown]` Made polling retry sleep abort-aware, `unref()`ed non-critical Telegram housekeeping timers, and routed registered session shutdown through the composed lifecycle runtime so session context cleanup runs with queue and polling cleanup. Impact: shutdown and print/headless runs are less likely to stay alive on retry/debounce/typing/preview timers, and direct-delivery ownership context is cleared at the session boundary.
12
+ - `[Tests]` Added regressions for inherited child lock refusal, child-process no-poll ownership, child-process direct-tool non-owner refusal, proactive owner/non-owner/stale/off states, queued dispatch after lock movement, abort-during-retry polling sleep, process-level session shutdown, and real `pi -p` exit/no-leak paths with a local custom provider. Impact: the cross-instance ownership, queue, and shutdown contracts are pinned from unit coverage through CLI smoke coverage.
13
+ - `[Status]` Changed `/telegram-status` bot identity fallback from `not configured` to `unknown` when a bot token exists but the bot username is absent. Impact: live sessions that are paired and polling no longer look unconfigured only because identity metadata is missing.
14
+ - `[Docs]` Documented ownership, proactive-push, timer/shutdown, and multi-extension prompt-boundary contracts in README and `/docs`. Impact: companion extensions get clearer boundaries without adding a core question/prompt-mirroring API.
6
15
 
7
16
  ## 0.16.2: Screenshot Refresh Hotfix
8
17
 
package/README.md CHANGED
@@ -47,7 +47,7 @@ Paste your bot token when prompted. If a bot token is already saved in `~/.pi/ag
47
47
  /telegram-connect
48
48
  ```
49
49
 
50
- The adapter is session-local: only one π instance polls Telegram at a time. `/telegram-connect` records only external control/polling ownership in `~/.pi/agent/locks.json`; live ownership moves require confirmation, while `/new` and same-`cwd` restarts resume automatically. Local queue and reply state stay per Pi instance, so an instance that loses Telegram control still finishes work it already accepted.
50
+ The adapter is session-local: only one π instance polls Telegram at a time. `/telegram-connect` records only external control/polling ownership in `~/.pi/agent/locks.json`; live ownership moves require confirmation, inherited child sessions do not start polling unless they take ownership, and `/new` plus same-`cwd` restarts resume automatically. Local queue and reply state stay per Pi instance, so an instance that loses Telegram control still finishes work it already accepted.
51
51
 
52
52
  ### 4. Pair your Telegram account
53
53
 
@@ -215,7 +215,7 @@ Import `registerTelegramSection()` from `@llblab/pi-telegram/sections` and retur
215
215
 
216
216
  ### Proactive push
217
217
 
218
- `telegram.json` can set `proactivePush: true` to send successful local non-Telegram final replies to the paired Telegram chat when no Telegram turn is active. Local prompt text is not mirrored because the bot does not own terminal user messages. The mode is off by default and can be toggled from settings.
218
+ `telegram.json` can set `proactivePush: true` to send successful local non-Telegram final replies to the paired Telegram chat when no Telegram turn is active and this π instance currently owns `/telegram-connect`. Non-owners skip proactive delivery instead of pushing unrelated local/headless results through a bot they no longer control. Local prompt text is not mirrored because the bot does not own terminal user messages. The mode is off by default and can be toggled from settings.
219
219
 
220
220
  ### Time context
221
221
 
@@ -88,10 +88,13 @@ Telegram configuration lives in `~/.pi/agent/telegram.json`. Polling ownership l
88
88
  - `/telegram-connect` acquires or moves singleton polling ownership before polling starts.
89
89
  - `/telegram-disconnect` stops polling and releases ownership.
90
90
  - Session start resumes polling only when the existing lock already points at the current `pid`/`cwd`, or when a stale same-`cwd` lock can be safely replaced after process restart.
91
+ - Pi `print`/`json` run modes stay passive: they do not start or resume Telegram polling even if a lock is present. Older Pi runtimes without `ctx.mode` keep the previous compatibility behavior.
92
+ - Inherited child sessions that see the same `telegram.json` but do not own the `pid`/`cwd` lock must not auto-start polling or call `getUpdates` unless the operator force-takes ownership.
91
93
  - Session replacement suspends polling/watchers without releasing ownership so the next session-start hook in the same process can resume.
92
94
  - Live polling owners require explicit takeover confirmation.
93
95
  - Long-lived polling timers use snapshotted ownership context and stop local polling when the lock no longer points at their own process.
94
96
  - `locks.json` owns only external Telegram control/polling. Local extension and queue state are per Pi instance: losing the lock stops live Telegram control here, but does not drain or silence this instance's accepted queue, previews, final delivery, or dispatch.
97
+ - Proactive local/headless final-result push is not accepted-turn delivery. It is allowed only when proactive push is enabled and this instance currently owns the Telegram lock.
95
98
 
96
99
  Deleting `locks.json` resets runtime ownership without deleting Telegram configuration.
97
100
 
@@ -138,7 +141,7 @@ Dispatch requires:
138
141
 
139
142
  A dispatched prompt remains queued until `agent_start` consumes it. This keeps the active Telegram turn bound for previews, attachments, aborts, and final replies.
140
143
 
141
- Post-agent-end queue dispatch uses a session-bound deferred dispatcher. It is activated on session start, clears timers on shutdown, and skips callbacks from older generations before touching `ExtensionContext`. Dispatch stays session-bound after polling ownership moves elsewhere.
144
+ Post-agent-end queue dispatch uses a session-bound deferred dispatcher. It is activated on session start, clears timers on shutdown, and skips callbacks from older generations before touching `ExtensionContext`. Dispatch stays session-bound after polling ownership moves elsewhere. When a queued Telegram prompt is forwarded into Pi, it uses Pi's explicit `followUp` delivery option so Telegram input preserves the existing non-steering queue contract even if Pi is still settling active work.
142
145
 
143
146
  ### Controls And Menus
144
147
 
@@ -159,7 +162,7 @@ Queued controls:
159
162
  - Prompt-template commands expand Telegram-safe π template aliases before entering the prompt queue.
160
163
  - Model-switch continuation uses the control lane when an in-flight Telegram-owned run must be stopped and resumed.
161
164
 
162
- 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).
165
+ 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).
163
166
 
164
167
  ### Compaction And Typing Status
165
168
 
@@ -215,6 +218,8 @@ Unknown callback data outside owned prefixes is forwarded as `[callback] <data>`
215
218
 
216
219
  Extension callbacks must avoid `pi-telegram` owned prefixes such as `compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, and `section:`. Workflow-specific Telegram slash commands should use the public command registry instead of becoming new core built-ins unless they are bridge lifecycle, transport ownership, queue safety, or essential operator controls.
217
220
 
221
+ The bridge does not mirror arbitrary `ctx.ui.confirm/input/select/custom` prompts from other extensions into Telegram. Companion extensions that need Telegram operation should expose a Telegram-native command, section, settings row, callback, status line, inbound/update handler, or assistant action-markup path instead of relying on hidden TUI-only prompts.
222
+
218
223
  ## Diagnostics And Operational Behavior
219
224
 
220
225
  Status rendering distinguishes connected, active, dispatching, queued, tool-running, model-switching, and compacting states. If a queue mutation removes the last waiting item while Telegram-owned work still has running tools, status remains active instead of degrading to connected.
@@ -223,7 +228,7 @@ Queue reactions are shortcut controls for waiting turns. Promotion reactions (`
223
228
 
224
229
  `/telegram-status` records grouped diagnostics for transport/API, polling/update, prompt dispatch, controls, typing, compaction, setup, session lifecycle, attachment queue/delivery, and recent redacted runtime events. Expected preview noise such as unchanged edit responses is filtered out.
225
230
 
226
- When proactive push is enabled, successful local non-Telegram final replies are sent to the paired chat. Local prompt text is not mirrored because the bot does not own terminal user messages.
231
+ When proactive push is enabled and this instance owns the Telegram lock, successful local non-Telegram final replies are sent to the paired chat. Non-owners skip proactive delivery and record a runtime diagnostic. Local prompt text is not mirrored because the bot does not own terminal user messages.
227
232
 
228
233
  Telegram prompt guidance asks assistants to keep dense mobile-visible text around 37 display cells where possible, because emoji and wide Unicode make raw character counts misleading.
229
234
 
@@ -238,6 +243,14 @@ When `/model` is used during an active Telegram-owned run, the bridge can emulat
238
243
 
239
244
  This is limited to Telegram-owned runs. If π is busy with non-Telegram work, the bridge refuses the switch instead of hijacking unrelated activity.
240
245
 
246
+ ## Shutdown And Timer Lifecycle
247
+
248
+ `session_shutdown` is the hard boundary for session-bound runtime work. It suspends Telegram polling through the locked polling runtime, aborts the poll controller, stops native typing, unbinds deferred queue dispatch, clears pending media/text-group input, clears preview state, clears active turns, and drops the active abort handler.
249
+
250
+ Non-critical timers are `unref()`ed so print/headless processes are not kept alive only by Telegram housekeeping. This includes typing keepalive intervals, bounded typing-idle waits, deferred queue dispatch, media/text-group debounce windows, preview flush timers, and polling retry sleeps. Polling retry sleep is abort-aware, so shutdown does not wait for the normal retry delay after a polling error.
251
+
252
+ Non-interactive `pi -p` runs must remain passive unless π provides a live Telegram session lifecycle. Loading the extension with `telegram.json`, proactive push settings, or existing lock state must not by itself keep the print-mode process alive or let a non-owner send proactive Telegram output.
253
+
241
254
  ## Related
242
255
 
243
256
  - [README.md](../README.md)
package/docs/locks.md CHANGED
@@ -123,7 +123,7 @@ Avoid repeating the extension name in the body. Color is encouraged: extension t
123
123
 
124
124
  The previous owner may use `fs.watch`, mtime polling, or an existing status/timer tick. Long-lived watchers should compare against a snapshotted `pid`/`cwd` identity rather than a live pi context object, because session replacement such as `/new` makes captured contexts stale. The important contract is graceful singleton-runtime shutdown after ownership mismatch while session-local state that does not require polling remains owned by its original instance.
125
125
 
126
- For `pi-telegram`, direct local/TUI delivery tools (`telegram_message` and no-active-turn `telegram_attach`) are singleton-controlled and require current `/telegram-connect` ownership. They must fail when the lock is inactive or active elsewhere. Already accepted Telegram-turn reply delivery, previews, queued attachments, and queue finalization remain session-local and may complete after polling ownership moves away.
126
+ For `pi-telegram`, direct local/TUI delivery tools (`telegram_message` and no-active-turn `telegram_attach`) and proactive local/headless final-result push are singleton-controlled and require current `/telegram-connect` ownership. They must fail or skip delivery when the lock is inactive or active elsewhere. Already accepted Telegram-turn reply delivery, previews, queued attachments, and queue finalization remain session-local and may complete after polling ownership moves away.
127
127
 
128
128
  ## Reset
129
129
 
package/index.ts CHANGED
@@ -386,6 +386,8 @@ export default function (pi: Pi.ExtensionAPI) {
386
386
  const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
387
387
  lock: lockRuntime,
388
388
  hasBotToken: configStore.hasBotToken,
389
+ canStartPolling: Pi.canStartPollingInExtensionContext,
390
+ formatStartBlockedMessage: Pi.formatPollingStartBlockedByRunMode,
389
391
  startPolling: pollingRuntime.start,
390
392
  stopPolling: pollingRuntime.stop,
391
393
  updateStatus,
@@ -452,7 +454,6 @@ export default function (pi: Pi.ExtensionAPI) {
452
454
  ...sessionLifecycleRuntime,
453
455
  onModelSelect: currentModelRuntime.onModelSelect,
454
456
  },
455
- queueSessionLifecycle,
456
457
  configStore,
457
458
  abort,
458
459
  typing,
package/lib/bindings.ts CHANGED
@@ -113,10 +113,6 @@ interface TelegramLifecycleBindingDeps {
113
113
  Lifecycle.TelegramLifecycleRegistrationDeps,
114
114
  "onSessionStart" | "onSessionShutdown" | "onModelSelect"
115
115
  >;
116
- queueSessionLifecycle: Pick<
117
- Lifecycle.TelegramLifecycleRegistrationDeps,
118
- "onSessionShutdown"
119
- >;
120
116
  configStore: Pick<Config.TelegramConfigStore, "getOutboundHandlers">;
121
117
  abort: Runtime.TelegramRuntimeAbortPort;
122
118
  typing: Runtime.TelegramRuntimeTypingPort;
@@ -190,7 +186,6 @@ interface TelegramLifecycleBindingDeps {
190
186
  export function registerTelegramLifecycleRuntimeHooks({
191
187
  pi,
192
188
  sessionLifecycleRuntime,
193
- queueSessionLifecycle,
194
189
  configStore,
195
190
  abort,
196
191
  typing,
@@ -283,6 +278,7 @@ export function registerTelegramLifecycleRuntimeHooks({
283
278
  sendOutboundReplyArtifacts: outboundReplyArtifactSender,
284
279
  getDefaultChatId: proactivePushChatIdGetter,
285
280
  isProactivePushEnabled,
281
+ canSendProactivePush: lockOwnershipGuard.ownsContext,
286
282
  recordRuntimeEvent,
287
283
  getActiveToolExecutions: lifecycle.getActiveToolExecutions,
288
284
  setActiveToolExecutions: lifecycle.setActiveToolExecutions,
@@ -315,7 +311,7 @@ export function registerTelegramLifecycleRuntimeHooks({
315
311
  ...agentLifecycleHooks,
316
312
  async onSessionShutdown(event, ctx) {
317
313
  compactionObserver.onSessionShutdown();
318
- await queueSessionLifecycle.onSessionShutdown(event, ctx);
314
+ await sessionLifecycleRuntime.onSessionShutdown(event, ctx);
319
315
  },
320
316
  onSessionBeforeCompact: compactionObserver.onSessionBeforeCompact,
321
317
  onSessionCompact: compactionObserver.onSessionCompact,
package/lib/locks.ts CHANGED
@@ -101,6 +101,8 @@ export interface TelegramLockedPollingRuntimeDeps<
101
101
  > {
102
102
  lock: TelegramLockRuntime<TContext>;
103
103
  hasBotToken: () => boolean;
104
+ canStartPolling?: (ctx: TContext) => boolean;
105
+ formatStartBlockedMessage?: (ctx: TContext) => string;
104
106
  startPolling: (ctx: TContext) => void | Promise<void>;
105
107
  stopPolling: () => Promise<void>;
106
108
  updateStatus: (ctx: TContext) => void;
@@ -309,10 +311,18 @@ export function createTelegramLockedPollingRuntime<
309
311
  }, ownershipCheckMs);
310
312
  ownershipInterval.unref?.();
311
313
  };
314
+ const canStartPolling = (ctx: TContext): boolean =>
315
+ deps.canStartPolling?.(ctx) ?? true;
316
+ const formatStartBlockedMessage = (ctx: TContext): string =>
317
+ deps.formatStartBlockedMessage?.(ctx) ??
318
+ "Telegram polling is unavailable in this π run mode.";
312
319
  return {
313
320
  start: async (ctx, options = {}) => {
314
321
  if (!deps.hasBotToken())
315
322
  return { ok: false, message: "Telegram bot is not configured." };
323
+ if (!canStartPolling(ctx)) {
324
+ return { ok: false, message: formatStartBlockedMessage(ctx) };
325
+ }
316
326
  const acquired = deps.lock.acquire(ctx, options);
317
327
  if (!acquired.ok) {
318
328
  return {
@@ -341,6 +351,7 @@ export function createTelegramLockedPollingRuntime<
341
351
  suspend: suspendPolling,
342
352
  onSessionStart: async (_event, ctx) => {
343
353
  if (!deps.hasBotToken()) return;
354
+ if (!canStartPolling(ctx)) return;
344
355
  const ownsCurrentLock = deps.lock.owns(ctx);
345
356
  const state = ownsCurrentLock ? undefined : deps.lock.getState();
346
357
  const canResumeStaleSameCwd =
package/lib/media.ts CHANGED
@@ -299,6 +299,7 @@ export function queueTelegramMediaGroupMessage<
299
299
  if (!state) return;
300
300
  options.dispatchMessages(state.messages, state.context);
301
301
  }, options.debounceMs);
302
+ existing.flushTimer.unref?.();
302
303
  options.groups.set(key, existing);
303
304
  return true;
304
305
  }
package/lib/pi.ts CHANGED
@@ -41,6 +41,44 @@ export interface PiSettingsManager {
41
41
  }
42
42
 
43
43
  export type PiSlashCommandInfo = SlashCommandInfo;
44
+ export type PiRunMode = "tui" | "rpc" | "json" | "print";
45
+
46
+ function isPiRunMode(value: unknown): value is PiRunMode {
47
+ return (
48
+ value === "tui" ||
49
+ value === "rpc" ||
50
+ value === "json" ||
51
+ value === "print"
52
+ );
53
+ }
54
+
55
+ export function getExtensionContextMode(ctx: unknown): PiRunMode | undefined {
56
+ const mode =
57
+ typeof ctx === "object" && ctx !== null
58
+ ? (ctx as { mode?: unknown }).mode
59
+ : undefined;
60
+ return isPiRunMode(mode) ? mode : undefined;
61
+ }
62
+
63
+ export function isExtensionContextPassiveRunMode(ctx: unknown): boolean {
64
+ const mode = getExtensionContextMode(ctx);
65
+ return mode === "print" || mode === "json";
66
+ }
67
+
68
+ export function canStartPollingInExtensionContext(ctx: unknown): boolean {
69
+ return !isExtensionContextPassiveRunMode(ctx);
70
+ }
71
+
72
+ export function formatPollingStartBlockedByRunMode(ctx: unknown): string {
73
+ const mode = getExtensionContextMode(ctx);
74
+ return mode
75
+ ? `Telegram polling is unavailable in π ${mode} mode. Use /telegram-connect from a long-lived π session.`
76
+ : "Telegram polling is unavailable in this π run mode.";
77
+ }
78
+
79
+ export type PiSendUserMessageOptions = NonNullable<
80
+ Parameters<ExtensionAPI["sendUserMessage"]>[1]
81
+ >;
44
82
 
45
83
  export interface PiExtensionApiRuntimePorts {
46
84
  sendUserMessage: ExtensionAPI["sendUserMessage"];
@@ -63,7 +101,7 @@ export function createExtensionApiRuntimePorts(
63
101
  >,
64
102
  ): PiExtensionApiRuntimePorts {
65
103
  return {
66
- sendUserMessage: (content) => api.sendUserMessage(content),
104
+ sendUserMessage: (content, options) => api.sendUserMessage(content, options),
67
105
  exec: (command, args, options) => api.exec(command, args, options),
68
106
  getCommands: () => api.getCommands(),
69
107
  getThinkingLevel: () => api.getThinkingLevel(),
package/lib/polling.ts CHANGED
@@ -261,7 +261,7 @@ export interface TelegramPollLoopDeps<
261
261
  handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
262
262
  onErrorStatus: (message: string) => void;
263
263
  onStatusReset: () => void;
264
- sleep: (ms: number) => Promise<void>;
264
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
265
265
  maxUpdateFailures?: number;
266
266
  }
267
267
 
@@ -278,22 +278,42 @@ export interface TelegramPollLoopRunnerDeps<
278
278
  persistConfig: () => Promise<void>;
279
279
  handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
280
280
  updateStatus: (ctx: TContext, message?: string) => void;
281
- sleep?: (ms: number) => Promise<void>;
281
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
282
282
  maxUpdateFailures?: number;
283
283
  }
284
284
 
285
+ export function sleepTelegramPollingRetry(
286
+ ms: number,
287
+ signal?: AbortSignal,
288
+ ): Promise<void> {
289
+ if (ms <= 0 || signal?.aborted) return Promise.resolve();
290
+ return new Promise<void>((resolve) => {
291
+ let settled = false;
292
+ let timer: ReturnType<typeof setTimeout>;
293
+ const finish = () => {
294
+ if (settled) return;
295
+ settled = true;
296
+ signal?.removeEventListener("abort", onAbort);
297
+ resolve();
298
+ };
299
+ const onAbort = () => {
300
+ clearTimeout(timer);
301
+ finish();
302
+ };
303
+ timer = setTimeout(finish, ms);
304
+ timer.unref?.();
305
+ signal?.addEventListener("abort", onAbort, { once: true });
306
+ if (signal?.aborted) onAbort();
307
+ });
308
+ }
309
+
285
310
  export function createTelegramPollLoopRunner<
286
311
  TUpdate extends TelegramUpdate,
287
312
  TContext = unknown,
288
313
  >(
289
314
  deps: TelegramPollLoopRunnerDeps<TUpdate, TContext>,
290
315
  ): (ctx: TContext, signal: AbortSignal) => Promise<void> {
291
- const sleep =
292
- deps.sleep ??
293
- ((ms: number) =>
294
- new Promise<void>((resolve) => {
295
- setTimeout(resolve, ms);
296
- }));
316
+ const sleep = deps.sleep ?? sleepTelegramPollingRetry;
297
317
  return (ctx, signal) =>
298
318
  runTelegramPollLoop({
299
319
  ctx,
@@ -393,7 +413,8 @@ export async function runTelegramPollLoop<
393
413
  deps.recordRuntimeEvent?.("polling", error, { phase: "loop" });
394
414
  }
395
415
  deps.onErrorStatus(getTelegramPollingErrorMessage(error));
396
- await deps.sleep(3000);
416
+ await deps.sleep(3000, deps.signal);
417
+ if (deps.signal.aborted) return;
397
418
  deps.onStatusReset();
398
419
  }
399
420
  }
package/lib/preview.ts CHANGED
@@ -467,6 +467,7 @@ export function createTelegramPreviewController<
467
467
  state.flushTimer = setTimer(() => {
468
468
  void flushTelegramPreview(chatId, getRuntimeDeps());
469
469
  }, throttleMs);
470
+ state.flushTimer.unref?.();
470
471
  },
471
472
  finalize: (chatId, replyToMessageId) =>
472
473
  finalizeTelegramPreview(chatId, getRuntimeDeps(replyToMessageId)),
package/lib/queue.ts CHANGED
@@ -842,6 +842,7 @@ export interface TelegramAgentEndRuntimeDeps<
842
842
  ) => Promise<void>;
843
843
  getDefaultChatId?: () => number | undefined;
844
844
  isProactivePushEnabled?: () => boolean;
845
+ canSendProactivePush?: () => boolean;
845
846
  recordRuntimeEvent?: (
846
847
  category: string,
847
848
  error: unknown,
@@ -888,6 +889,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
888
889
  sendOutboundReplyArtifacts?: TelegramAgentEndRuntimeDeps<TTurn>["sendOutboundReplyArtifacts"];
889
890
  getDefaultChatId?: TelegramAgentEndRuntimeDeps<TTurn>["getDefaultChatId"];
890
891
  isProactivePushEnabled?: TelegramAgentEndRuntimeDeps<TTurn>["isProactivePushEnabled"];
892
+ canSendProactivePush?: (ctx: TContext) => boolean;
891
893
  recordRuntimeEvent?: TelegramAgentEndRuntimeDeps<TTurn>["recordRuntimeEvent"];
892
894
  }
893
895
 
@@ -980,6 +982,7 @@ export function createTelegramAgentEndHook<
980
982
  ): Promise<void> {
981
983
  const turn = deps.getActiveTurn();
982
984
  const proactiveEnabled = deps.isProactivePushEnabled?.() ?? false;
985
+ const canProactivePush = deps.canSendProactivePush?.(ctx) ?? false;
983
986
  await handleTelegramAgentEndRuntime({
984
987
  turn,
985
988
  assistant:
@@ -1005,6 +1008,7 @@ export function createTelegramAgentEndHook<
1005
1008
  sendOutboundReplyArtifacts: deps.sendOutboundReplyArtifacts,
1006
1009
  getDefaultChatId: deps.getDefaultChatId,
1007
1010
  isProactivePushEnabled: deps.isProactivePushEnabled,
1011
+ canSendProactivePush: () => canProactivePush,
1008
1012
  recordRuntimeEvent: deps.recordRuntimeEvent,
1009
1013
  });
1010
1014
  };
@@ -1057,20 +1061,26 @@ export async function handleTelegramAgentEndRuntime<
1057
1061
  foldQueuedPromptsIntoHistory: deps.foldQueuedPromptsIntoHistory,
1058
1062
  });
1059
1063
  if (!turn) {
1060
- if (
1061
- deps.isProactivePushEnabled?.() &&
1062
- finalText &&
1063
- !assistant.errorMessage
1064
- ) {
1065
- const defaultChatId = deps.getDefaultChatId?.();
1066
- if (defaultChatId !== undefined) {
1067
- try {
1068
- await deps.sendMarkdownReply(defaultChatId, undefined, finalText);
1069
- } catch (error) {
1070
- deps.recordRuntimeEvent?.("proactive-push", error, {
1071
- chatId: defaultChatId,
1072
- });
1064
+ const proactiveEnabled = deps.isProactivePushEnabled?.() ?? false;
1065
+ const canProactivePush = deps.canSendProactivePush?.() ?? false;
1066
+ if (proactiveEnabled && finalText && !assistant.errorMessage) {
1067
+ if (canProactivePush) {
1068
+ const defaultChatId = deps.getDefaultChatId?.();
1069
+ if (defaultChatId !== undefined) {
1070
+ try {
1071
+ await deps.sendMarkdownReply(defaultChatId, undefined, finalText);
1072
+ } catch (error) {
1073
+ deps.recordRuntimeEvent?.("proactive-push", error, {
1074
+ chatId: defaultChatId,
1075
+ });
1076
+ }
1073
1077
  }
1078
+ } else {
1079
+ deps.recordRuntimeEvent?.(
1080
+ "proactive-push",
1081
+ new Error("Proactive push skipped because this instance does not own Telegram polling."),
1082
+ { phase: "ownership" },
1083
+ );
1074
1084
  }
1075
1085
  }
1076
1086
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
@@ -1802,6 +1812,7 @@ export function createTelegramDeferredQueueDispatchRuntime<TContext = unknown>(
1802
1812
  return;
1803
1813
  dispatchNextQueuedTelegramTurn(boundContext);
1804
1814
  }, delayMs);
1815
+ timer.unref?.();
1805
1816
  timers.add(timer);
1806
1817
  },
1807
1818
  };
@@ -1809,6 +1820,14 @@ export function createTelegramDeferredQueueDispatchRuntime<TContext = unknown>(
1809
1820
 
1810
1821
  // --- Dispatch Runtime ---
1811
1822
 
1823
+ export interface TelegramPromptDeliveryOptions {
1824
+ deliverAs: "followUp";
1825
+ }
1826
+
1827
+ export const TELEGRAM_PROMPT_FOLLOW_UP_DELIVERY = {
1828
+ deliverAs: "followUp",
1829
+ } as const satisfies TelegramPromptDeliveryOptions;
1830
+
1812
1831
  export interface TelegramDispatchRuntimeDeps<TContext = unknown> {
1813
1832
  executeControlItem: (
1814
1833
  item: Extract<
@@ -1822,6 +1841,7 @@ export interface TelegramDispatchRuntimeDeps<TContext = unknown> {
1822
1841
  TelegramQueueDispatchAction,
1823
1842
  { kind: "prompt" }
1824
1843
  >["item"]["content"],
1844
+ options?: TelegramPromptDeliveryOptions,
1825
1845
  ) => void;
1826
1846
  onPromptDispatchFailure: (message: string) => void;
1827
1847
  onIdle: () => void;
@@ -1859,7 +1879,7 @@ export function executeTelegramQueueDispatchPlan<TContext = unknown>(
1859
1879
  }
1860
1880
  deps.onPromptDispatchStart(plan.item.chatId);
1861
1881
  try {
1862
- deps.sendUserMessage(plan.item.content);
1882
+ deps.sendUserMessage(plan.item.content, TELEGRAM_PROMPT_FOLLOW_UP_DELIVERY);
1863
1883
  } catch (error) {
1864
1884
  const message = getTelegramQueueErrorMessage(error);
1865
1885
  deps.onPromptDispatchFailure(message);
package/lib/routing.ts CHANGED
@@ -122,7 +122,10 @@ export interface TelegramInboundRouteRuntimeDeps<
122
122
  ctx: TContext,
123
123
  ) => Promise<void>;
124
124
  setModel: (model: TModel) => Promise<boolean>;
125
- sendUserMessage?: (message: string) => void;
125
+ sendUserMessage?: (
126
+ message: string,
127
+ options?: Queue.TelegramPromptDeliveryOptions,
128
+ ) => void;
126
129
  isIdle: (ctx: TContext) => boolean;
127
130
  hasPendingMessages: (ctx: TContext) => boolean;
128
131
  compact: (
@@ -315,7 +318,10 @@ export function createTelegramInboundRouteRuntime<
315
318
  callbackData &&
316
319
  !isTelegramOwnedCallbackData(callbackData)
317
320
  ) {
318
- deps.sendUserMessage(`[callback] ${callbackData}`);
321
+ deps.sendUserMessage(
322
+ `[callback] ${callbackData}`,
323
+ Queue.TELEGRAM_PROMPT_FOLLOW_UP_DELIVERY,
324
+ );
319
325
  await deps.answerCallbackQuery(query.id);
320
326
  return;
321
327
  }
package/lib/runtime.ts CHANGED
@@ -406,6 +406,7 @@ export function startTelegramTypingLoop(
406
406
  };
407
407
  sendTyping();
408
408
  state.typingInterval = setInterval(sendTyping, deps.intervalMs);
409
+ state.typingInterval.unref?.();
409
410
  return true;
410
411
  }
411
412
 
@@ -431,7 +432,8 @@ export async function waitForTelegramTypingLoopIdle(
431
432
  await Promise.race([
432
433
  inFlight,
433
434
  new Promise<void>((resolve) => {
434
- setTimeout(resolve, timeoutMs);
435
+ const timer = setTimeout(resolve, timeoutMs);
436
+ timer.unref?.();
435
437
  }),
436
438
  ]);
437
439
  }
package/lib/status.ts CHANGED
@@ -104,6 +104,7 @@ export interface TelegramRuntimeEventRecorderOptions {
104
104
  }
105
105
 
106
106
  export interface TelegramBridgeStatusLineState {
107
+ hasBotToken?: boolean;
107
108
  botUsername?: string;
108
109
  allowedUserId?: number;
109
110
  lockState?: string;
@@ -488,6 +489,7 @@ export function createTelegramBridgeStatusRuntime<
488
489
  getBridgeStatusLineState: () => {
489
490
  const config = deps.getConfig();
490
491
  return {
492
+ hasBotToken: Boolean(config.botToken),
491
493
  botUsername: config.botUsername,
492
494
  allowedUserId: config.allowedUserId,
493
495
  lockState: deps.getRuntimeLockState?.(),
@@ -552,6 +554,13 @@ export function buildTelegramStatusBarText(
552
554
  return `${label} ${theme.fg("success", "connected")}`;
553
555
  }
554
556
 
557
+ function formatTelegramBridgeBotStatus(
558
+ state: Pick<TelegramBridgeStatusLineState, "hasBotToken" | "botUsername">,
559
+ ): string {
560
+ if (state.botUsername) return `@${state.botUsername}`;
561
+ return state.hasBotToken ? "unknown" : "not configured";
562
+ }
563
+
555
564
  export function buildTelegramBridgeStatusLines(
556
565
  state: TelegramBridgeStatusLineState,
557
566
  ): string[] {
@@ -566,7 +575,7 @@ export function buildTelegramBridgeStatusLines(
566
575
  ).length;
567
576
  return [
568
577
  "connection:",
569
- `- bot: ${state.botUsername ? `@${state.botUsername}` : "not configured"}`,
578
+ `- bot: ${formatTelegramBridgeBotStatus(state)}`,
570
579
  `- allowed user: ${state.allowedUserId ?? "not paired"}`,
571
580
  ...(state.lockState ? [`- owner: ${state.lockState}`] : []),
572
581
  "",
@@ -132,6 +132,7 @@ export function queueTelegramTextGroupMessage<
132
132
  if (!queued || queued.context === undefined) return;
133
133
  options.dispatchMessages(queued.messages, queued.context);
134
134
  }, options.debounceMs);
135
+ state.flushTimer.unref?.();
135
136
  options.groups.set(key, state);
136
137
  return true;
137
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.16.2",
3
+ "version": "0.16.4",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/screenshot.png CHANGED
Binary file