@llblab/pi-kit 0.21.0 → 0.21.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 (30) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  4. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
  5. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -0
  6. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +2 -0
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +3 -1
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +6 -1
  9. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +5 -0
  10. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +9 -0
  11. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +5 -1
  12. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +8 -0
  13. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +63 -11
  14. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +4 -0
  15. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +14 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +1 -0
  17. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +5 -0
  18. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  19. package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
  20. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
  21. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  22. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +7 -0
  23. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +13 -2
  24. package/node_modules/@llblab/pi-telegram/lib/commands.ts +19 -0
  25. package/node_modules/@llblab/pi-telegram/lib/extension.ts +9 -0
  26. package/node_modules/@llblab/pi-telegram/lib/queue.ts +75 -16
  27. package/node_modules/@llblab/pi-telegram/lib/replies.ts +18 -0
  28. package/node_modules/@llblab/pi-telegram/lib/routing.ts +7 -0
  29. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  30. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.21.1 - 2026-09-23
6
+
7
+ - `Queue Transition Reliability`: Advances the exact Telegram pin to `0.51.1`. Busy `/next` now reports the interrupted turn and exact selected queued prompt in order, preserves one reply-header owner through agent start, keeps rapid ordinary messages separate, and lets `/abort` or `/stop` cancel stale notices without allowing notice failures to block dispatch.
8
+ - `Follower Heartbeat Stability`: Uses a dedicated eight-second follower heartbeat response deadline aligned with leader stale-liveness policy, preventing ordinary multi-second Pi/TUI event-loop stalls from destroying a healthy socket, producing leader `EPIPE` noise, or cycling registration status.
9
+ - `Package Cohort`: Keeps every other bundled package at its current exact version. Package membership, resource paths, load order, Pi minimum, and bundled Skill ownership remain unchanged.
10
+
5
11
  ## 0.21.0 - 2026-09-23
6
12
 
7
13
  - `Independent Scope Revisions`: Advances the exact State Flow pin to `0.18.0`. Global, CWD, and Session now persist independent semantic revision counters, Effective displays the truthful `G#/C#/S#` vector, and Session exclusively owns response state while Global and CWD retain only their structural placeholders.
package/README.md CHANGED
@@ -15,7 +15,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
15
15
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.10.0` | Compact Codex/Spark subscription-limit and Business credit-usage status |
16
16
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.8.1` | Visible continuation scheduling and bounded worker Skills |
17
17
  | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.18.0` | Incremental scoped context/memory compiler with independent scope revisions, canonical file persistence, optional replicated Git backups, native working context within each run, and targeted historical reads |
18
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.51.0` | Telegram companion with pressure-safe Workspace slot rotation, fenced follower readiness, adaptive Thread continuity, exact queues, files, voice, controls, and bundled Telegram interaction Skills |
18
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.51.1` | Telegram companion with stable follower heartbeats, exact queue-transition notices, pressure-safe Workspace rotation, adaptive Thread continuity, files, voice, controls, and bundled Telegram interaction Skills |
19
19
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
20
20
 
21
21
  Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
@@ -74,7 +74,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
74
74
  - Threaded Mode has exactly one live leader per bot profile. Followers are real operator-started Pi processes and must authenticate/register over local IPC; Telegram never spawns hidden Pi processes. A live but unreachable owner does not authorize split-brain polling.
75
75
  - Local IPC is a trust boundary, not merely a private socket. Unknown, stale, mismatched-generation, or unauthorized requests must not inject prompts, callbacks, API sends, artifacts, liveness, or bindings. Follower registration identity may prepare a binding, but inbound generation authority is published only after successful preparation for the same generation and current context; pending or failed startup cannot append into a retained old journal. Readiness also binds the active Pi context and supplied session generation. Session refresh awaits binding preparation without re-registering; reusing a context object cannot carry readiness across a session-generation change. Registration requests capture session authority before asynchronous startup and fence every publication/finalization against the current attempt. Stop or supersession invalidates that attempt; obsolete cleanup cannot erase a newer registration or a refreshed context.
76
76
  - Protocol compatibility is independent from package version. Registration negotiates protocol version, runtime build, and canonical capabilities before target provisioning or live publication. `durable-follower-admission-v1` gates source forwarding; `queue-handoff-v1` independently gates semantic queue transfer for every participant and is advertised only with exact source/recipient journal-binding composition. `follower.register` and capability-gated restore-only `follower.restoreWorkspace` are bootstrap requests; other requests require exact live-registry generation authority, and `bus.ack` is response-only. `thread-display-mode-v1` gates follower display-setting requests; the leader owns their serialized profile preference and title application.
77
- - Long-lived timers, pollers, watchers, receivers, heartbeats, background delivery, and deferred dispatch are session-bound. Replacement stops stale activity and makes late work inert; teardown must recheck the captured session generation even when context identity is reused. Same-process handoff may preserve exact profile/target identity but never stale Pi context or cross-profile authority. Participating source observations must hold a reference for the actual read, including pending-mutation/count queries against a stopped worker's retained source; a scoped observation never restores receipt readiness or execution authority. A donor cancellation resumed after remote handoff awaits must also hold a source reference; only the existing exact journal CAS may cancel, never undo accepted recipient custody. Stable source keys do not certify captured callable lifetimes: snapshot prepared worker capabilities at construction and replace them on source-handle renewal without replaying unsettled input. Aborting a durable update generation does not release that `update_id`: replacement replay waits for its actual handler settlement, and effectful handlers use the shared execution fence immediately before commit and after awaited delegation. Admission also rechecks that fence after the default handler returns, before its outcome can settle custody; a stopped handler's ordinary return is not completion authority. Internal clones explicitly carry the hidden fence; reroute forwarding, thread-store mutation, cleanup, and Bot API boundaries retain the originating generation.
77
+ - Long-lived timers, pollers, watchers, receivers, heartbeats, background delivery, and deferred dispatch are session-bound. Replacement stops stale activity and makes late work inert; teardown must recheck the captured session generation even when context identity is reused. Same-process handoff may preserve exact profile/target identity but never stale Pi context or cross-profile authority. Follower heartbeat response deadlines align with the eight-second leader stale-liveness window rather than the generic one-second local-RPC default; an ordinary multi-second Pi/TUI event-loop stall is not registration loss. Participating source observations must hold a reference for the actual read, including pending-mutation/count queries against a stopped worker's retained source; a scoped observation never restores receipt readiness or execution authority. A donor cancellation resumed after remote handoff awaits must also hold a source reference; only the existing exact journal CAS may cancel, never undo accepted recipient custody. Stable source keys do not certify captured callable lifetimes: snapshot prepared worker capabilities at construction and replace them on source-handle renewal without replaying unsettled input. Aborting a durable update generation does not release that `update_id`: replacement replay waits for its actual handler settlement, and effectful handlers use the shared execution fence immediately before commit and after awaited delegation. Admission also rechecks that fence after the default handler returns, before its outcome can settle custody; a stopped handler's ordinary return is not completion authority. Internal clones explicitly carry the hidden fence; reroute forwarding, thread-store mutation, cleanup, and Bot API boundaries retain the originating generation.
78
78
  - Runtime state is event-driven reconciliation of local assumptions against Telegram signals, not a complete bot read-model and not permission to query Telegram on every action. Destructive thread cleanup goes through `thread-reconciler` with current proof and leader fencing. Fresh Workspace Thread creation derives its initial Bot API title from the active display mode before issuance; the stable generated `threadName` remains separate from the acknowledged `displayTitle`.
79
79
 
80
80
  ### 4.3 Durable Admission And Settlement
@@ -95,7 +95,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
95
95
  ### 4.4 Queue, Delivery, And User Surfaces
96
96
 
97
97
  - Queue lane/kind admission is explicit. Dispatch waits for active-turn, pending-dispatch, control, compaction, `ctx.isIdle()`, and Pi pending-message guards; a dispatched prompt stays queued until `agent_start` consumes it. The terminal `+N` suffix is a yellow count of executable prompts still waiting, excludes the dispatched head immediately, and never counts current agent work from any source. Each prompt is one object with one active lane and no reserved return slot. Normal and Priority are separate FIFO lanes: crossing lanes removes it from the source and appends it at the destination tail, while Keep/Skip and same-category emoji changes preserve lane position. Complete reaction sets independently derive Priority from recognized positive emoji and Skip from recognized negative emoji; both may coexist, suppressed turns retain durable receipts while waiting, and Skip settles them only when the prompt reaches dispatch before dropping it without inference. Suppressed turns remain visible at a struck-through physical ordinal without contributing to executable queue counters, while graceful session shutdown discards all remaining queue authority before clearing memory.
98
- - `/stop`, `/abort`, `/next`, and `/continue` respectively reset+abort, abort while preserving queue, force the next turn, and enqueue a control-lane continuation. Abort-history folding applies only to Telegram-owned active turns.
98
+ - `/stop`, `/abort`, `/next`, and `/continue` respectively reset+abort, abort while preserving queue, force the next turn, and enqueue a control-lane continuation. Abort-history folding applies only to Telegram-owned active turns. A busy `/next` marks only its exact active Telegram turn: terminal abort settlement attempts one explicit abort notice before the exact selected queued prompt receives its dispatch notice. Notice failure is diagnostic and cannot block dispatch; a later `/abort` or `/stop` cancels both pending transition notices before taking ownership. Target command adapters must forward these transition ports. A successful dispatch notice keeps the queued prompt's one reply-header claim across agent start, so later messages in that turn never repeat it.
99
99
  - Telegram extension side effects must not hold Pi's core lifecycle hostage after semantic completion. Preserve ordering in extension-owned background work, record failures, and fence target/profile/transport/session authority.
100
100
  - Complete assistant/guest model answers use Telegram-native Rich Markdown. Harness-owned menus, status, diagnostics, thinking, and tool evidence remain explicit HTML/plain or their documented native surface. Before Telegram preview or final delivery, strip every assistant-authored HTML comment regardless of Markdown position while keeping action activation top-level-only; a comment-only result sends no text message. Preserve literal code outside comments and structurally safe chunking; never split invalid markup.
101
101
  - `preview` owns streaming lifecycle only, not assistant rendering. Finalization waits for active preview flushes and must not issue pre/post-final draft-clear calls that create transient Telegram draft UI. Turns that already answer as one atomic reply (voice replies, Guest Mode queries) never stream previews.
@@ -4,6 +4,11 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.51.1: Queue transitions and follower heartbeat stability
8
+
9
+ - `/next lifecycle notices`: Target command composition now forwards request, exact-turn marker, and cancellation ports. Busy `/next` orders abort then selected-prompt dispatch notices; failures cannot block dispatch, and `/abort`/`/stop` cancel stale notices. A successful dispatch notice keeps first-reply ownership through agent start, so later answer messages do not repeat the queued-prompt reply. Rapid ordinary messages remain distinct turns.
10
+ - `Follower heartbeat stability`: Follower heartbeats now use a dedicated eight-second response deadline aligned with leader stale-liveness policy instead of the generic one-second local-RPC default. Ordinary multi-second TUI/event-loop stalls therefore retain registration and stable `follower` status rather than destroying the socket, producing leader EPIPE noise, and entering repeated reconnect cycles.
11
+
7
12
  ## 0.51.0: Safe Workspace rotation and follower readiness
8
13
 
9
14
  - `Workspace slot rotation`: Fresh allocation reclaims the oldest proven inactive slot only at full A–Z pressure. Exact grouped receipts may be journal-CAS discarded only after complete source inspection proves every process-birth owner dead. Live/unknown owners, partial groups, handoffs and unrelated work stay protected. Fresh protection precedes Thread/history deletion and slot reuse. Pi sessions/files/memory/journals remain; restore-only never evicts; ambiguous deletion never replays.
@@ -34,6 +34,7 @@ export interface TelegramQueueBindingRuntime<TContext> {
34
34
  mutation: Queue.TelegramQueueMutationController<TContext>;
35
35
  dispatchNext: (ctx: TContext) => void;
36
36
  requestNextDispatchAnnouncement: () => void;
37
+ cancelNextDispatchAnnouncement: () => void;
37
38
  watchdog: Queue.TelegramQueueDispatchWatchdogRuntime<TContext>;
38
39
  }
39
40
  export declare function createTelegramQueueBindingRuntime<TContext>(deps: {
@@ -58,6 +59,7 @@ export declare function createTelegramQueueBindingRuntime<TContext>(deps: {
58
59
  updateStatus: (ctx: TContext, error?: string) => void;
59
60
  sendTextReply: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendTextReply"];
60
61
  sendUserMessage: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendUserMessage"];
62
+ reconcileNextDispatchAnnouncementReplyOwnership?: (item: Queue.PendingTelegramTurn) => void;
61
63
  recordRuntimeEvent?: TelegramRuntimeEventRecorder;
62
64
  }): TelegramQueueBindingRuntime<TContext>;
63
65
  export interface TelegramGenerativeAppLiveSurfaceBinding {
@@ -79,6 +79,7 @@ export function createTelegramQueueBindingRuntime(deps) {
79
79
  },
80
80
  updateStatus: deps.updateStatus,
81
81
  sendTextReply: deps.sendTextReply,
82
+ reconcileNextDispatchAnnouncementReplyOwnership: deps.reconcileNextDispatchAnnouncementReplyOwnership,
82
83
  recordRuntimeEvent: deps.recordRuntimeEvent,
83
84
  ...deps.promptDispatch,
84
85
  sendUserMessage: deps.sendUserMessage,
@@ -87,6 +88,7 @@ export function createTelegramQueueBindingRuntime(deps) {
87
88
  mutation,
88
89
  dispatchNext: dispatch.dispatchNext,
89
90
  requestNextDispatchAnnouncement: dispatch.requestNextDispatchAnnouncement,
91
+ cancelNextDispatchAnnouncement: dispatch.cancelNextDispatchAnnouncement,
90
92
  watchdog: Queue.createTelegramQueueDispatchWatchdogRuntime({
91
93
  hasQueuedItems: deps.store.hasQueuedItems,
92
94
  dispatchNextQueuedTelegramTurn: dispatch.dispatchNext,
@@ -8,7 +8,7 @@
8
8
  import * as Sync from "./sync.ts";
9
9
  import * as Threads from "./threads.ts";
10
10
  import { type TelegramUpdateJournalStoreOptions } from "./journal.ts";
11
- import type { TelegramLockEntry, TelegramLockState } from "./locks.ts";
11
+ import { type TelegramLockEntry, type TelegramLockState } from "./locks.ts";
12
12
  import type { TelegramQueueHandoffPayload, TelegramQueueHandoffStageResult } from "./queue.ts";
13
13
  import type { TelegramTarget } from "./target.ts";
14
14
  import { type TelegramBusAgentMessage, type TelegramBusAgentTargetSelector, type TelegramBusEnvelope, type TelegramBusForwardOwnership, type TelegramBusProtocolIdentity, type TelegramBusSocketPathSource } from "./bus.ts";
@@ -18,6 +18,7 @@ export declare const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2500;
18
18
  export declare const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30000;
19
19
  export declare const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30000;
20
20
  export declare const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30000;
21
+ export declare const TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS = 8000;
21
22
  export declare const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS: number;
22
23
  export declare const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS: number;
23
24
  export interface TelegramFollowerSessionHandoff {
@@ -170,6 +171,7 @@ export interface TelegramBusFollowerRegistrationRuntimeDeps<TContext extends {
170
171
  registrationRetryAttempts?: number;
171
172
  registrationRetryDelayMs?: number;
172
173
  heartbeatMs?: number;
174
+ heartbeatTimeoutMs?: number;
173
175
  recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
174
176
  onHeartbeatFailure?: (error: unknown, ctx: TContext) => Promise<void> | void;
175
177
  onRegistered?: (ctx: TContext) => Promise<void> | void;
@@ -9,6 +9,7 @@ import { basename } from "node:path";
9
9
  import * as Sync from "./sync.js";
10
10
  import * as Threads from "./threads.js";
11
11
  import { parseTelegramUpdateJournalQueueOwner } from "./journal.js";
12
+ import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS, } from "./locks.js";
12
13
  import { isTelegramApiMethodRetrySafe, TelegramApiCommitUnknownError, TelegramApiStaleTargetError, } from "./telegram-api.js";
13
14
  import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusFollowerTargetController, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, } from "./bus.js";
14
15
  import { getTelegramBusTransportRetryPolicy, TELEGRAM_BUS_REGISTRATION_RETRY, } from "./bus-transport.js";
@@ -17,6 +18,7 @@ export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
17
18
  export const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
18
19
  export const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30_000;
19
20
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30_000;
21
+ export const TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS = TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS;
20
22
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS = TELEGRAM_BUS_REGISTRATION_RETRY.attempts;
21
23
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS = TELEGRAM_BUS_REGISTRATION_RETRY.delayMs;
22
24
  const TELEGRAM_FOLLOWER_SESSION_HANDOFF_KEY = "__piTelegramFollowerSessionHandoff";
@@ -920,6 +922,9 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
920
922
  const getNowMs = deps.getNowMs ?? Date.now;
921
923
  const getPid = deps.getPid ?? (() => process.pid);
922
924
  const heartbeatMs = deps.heartbeatMs ?? 1000;
925
+ const heartbeatTimeoutMs = deps.heartbeatTimeoutMs ??
926
+ deps.timeoutMs ??
927
+ TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS;
923
928
  const registrationTimeoutMs = deps.registrationTimeoutMs ?? deps.timeoutMs ?? 30000;
924
929
  const registrationRetryAttempts = deps.registrationRetryAttempts ??
925
930
  TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS;
@@ -979,7 +984,7 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
979
984
  try {
980
985
  const response = await sendTelegramBusLocalEnvelope({
981
986
  socketPath: leaderSocketPath,
982
- timeoutMs: deps.timeoutMs,
987
+ timeoutMs: heartbeatTimeoutMs,
983
988
  retry: getTelegramBusTransportRetryPolicy({
984
989
  endpoint: leaderSocketPath,
985
990
  operation: "operation",
@@ -210,6 +210,7 @@ export interface TelegramCommandActionDeps<TMessage, TContext> {
210
210
  export interface TelegramStopCommandDeps {
211
211
  hasAbortHandler: () => boolean;
212
212
  clearPendingModelSwitch: () => void;
213
+ cancelNextTransitionAnnouncements?: () => void;
213
214
  clearQueuedTelegramItems: () => number;
214
215
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
215
216
  abortCurrentTurn: () => void;
@@ -392,6 +393,8 @@ export interface TelegramCommandRuntimeDeps<TMessage extends TelegramCommandRunt
392
393
  isContextActive?: (ctx: TContext) => boolean;
393
394
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
394
395
  requestNextDispatchAnnouncement?: () => void;
396
+ markActiveTurnNextAbortAnnouncement?: () => boolean;
397
+ cancelNextTransitionAnnouncements?: () => void;
395
398
  requestDeferredDispatchNextQueuedTelegramTurn?: (dispatch: (ctx: TContext) => void) => void;
396
399
  startTypingLoop?: (ctx: TContext, chatId?: number, options?: {
397
400
  target?: {
@@ -504,6 +507,7 @@ export declare function handleTelegramAbortCommand(deps: {
504
507
  hasAbortHandler: () => boolean;
505
508
  hasActiveTelegramTurn: () => boolean;
506
509
  clearPendingModelSwitch: () => void;
510
+ cancelNextTransitionAnnouncements?: () => void;
507
511
  abortCurrentTurn: () => void;
508
512
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
509
513
  updateStatus: () => void;
@@ -519,6 +523,7 @@ export declare function handleTelegramNextCommand(deps: {
519
523
  abortCurrentTurn: () => void;
520
524
  dispatchNextQueuedTurn: () => void;
521
525
  requestNextDispatchAnnouncement?: () => void;
526
+ markActiveTurnNextAbortAnnouncement?: () => boolean;
522
527
  clearFoldForDispatch: () => void;
523
528
  updateStatus: () => void;
524
529
  sendTextReply: (text: string, options?: {
@@ -631,6 +631,7 @@ function formatTelegramQueuedTurnCount(count) {
631
631
  }
632
632
  export async function handleTelegramStopCommand(deps) {
633
633
  deps.clearPendingModelSwitch();
634
+ deps.cancelNextTransitionAnnouncements?.();
634
635
  const clearedCount = deps.clearQueuedTelegramItems();
635
636
  deps.setFoldQueuedPromptsIntoHistory(false);
636
637
  if (!deps.hasAbortHandler()) {
@@ -651,6 +652,7 @@ export async function handleTelegramStopCommand(deps) {
651
652
  }
652
653
  export async function handleTelegramAbortCommand(deps) {
653
654
  deps.clearPendingModelSwitch();
655
+ deps.cancelNextTransitionAnnouncements?.();
654
656
  if (!deps.hasAbortHandler()) {
655
657
  await deps.sendTextReply(formatTelegramInformationHeading("💤", "No active turn."), { parseMode: "HTML" });
656
658
  return;
@@ -669,6 +671,7 @@ export async function handleTelegramNextCommand(deps) {
669
671
  if (!deps.isIdle() && deps.hasAbortHandler()) {
670
672
  deps.clearFoldForDispatch();
671
673
  deps.requestNextDispatchAnnouncement?.();
674
+ deps.markActiveTurnNextAbortAnnouncement?.();
672
675
  deps.abortCurrentTurn();
673
676
  deps.updateStatus();
674
677
  return;
@@ -934,6 +937,9 @@ export function createTelegramCommandHandlerTargetRuntime(deps) {
934
937
  updateStatus: deps.updateStatus,
935
938
  isContextActive: deps.isContextActive,
936
939
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
940
+ requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
941
+ markActiveTurnNextAbortAnnouncement: deps.markActiveTurnNextAbortAnnouncement,
942
+ cancelNextTransitionAnnouncements: deps.cancelNextTransitionAnnouncements,
937
943
  startTypingLoop: deps.startTypingLoop,
938
944
  stopTypingLoop: deps.stopTypingLoop,
939
945
  enqueueContinueTurn: deps.enqueueContinueTurn,
@@ -1039,6 +1045,7 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
1039
1045
  await handleTelegramStopCommand({
1040
1046
  hasAbortHandler: deps.hasAbortHandler,
1041
1047
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
1048
+ cancelNextTransitionAnnouncements: deps.cancelNextTransitionAnnouncements,
1042
1049
  clearQueuedTelegramItems: () => deps.clearQueuedTelegramItems(commandCtx),
1043
1050
  setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
1044
1051
  abortCurrentTurn: deps.abortCurrentTurn,
@@ -1085,6 +1092,7 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
1085
1092
  hasAbortHandler: deps.hasAbortHandler,
1086
1093
  hasActiveTelegramTurn: deps.hasActiveTelegramTurn,
1087
1094
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
1095
+ cancelNextTransitionAnnouncements: deps.cancelNextTransitionAnnouncements,
1088
1096
  abortCurrentTurn: deps.abortCurrentTurn,
1089
1097
  setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
1090
1098
  updateStatus: updateStatusFor(commandCtx),
@@ -1100,6 +1108,7 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
1100
1108
  abortCurrentTurn: deps.abortCurrentTurn,
1101
1109
  dispatchNextQueuedTurn: () => deps.dispatchNextQueuedTelegramTurn(commandCtx),
1102
1110
  requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
1111
+ markActiveTurnNextAbortAnnouncement: deps.markActiveTurnNextAbortAnnouncement,
1103
1112
  clearFoldForDispatch: () => deps.setFoldQueuedPromptsIntoHistory(false),
1104
1113
  updateStatus: updateStatusFor(commandCtx),
1105
1114
  sendTextReply: sendReplyFor(nextMessage),
@@ -642,7 +642,7 @@ export default function (pi) {
642
642
  editMessageText: editTelegramMessageText,
643
643
  },
644
644
  });
645
- const { mutation: queueMutationRuntime, dispatchNext: dispatchNextQueuedTelegramTurn, requestNextDispatchAnnouncement, watchdog: queueDispatchWatchdogRuntime, } = Bindings.createTelegramQueueBindingRuntime({
645
+ const { mutation: queueMutationRuntime, dispatchNext: dispatchNextQueuedTelegramTurn, requestNextDispatchAnnouncement, cancelNextDispatchAnnouncement, watchdog: queueDispatchWatchdogRuntime, } = Bindings.createTelegramQueueBindingRuntime({
646
646
  store: telegramQueueStore,
647
647
  queue,
648
648
  lifecycle,
@@ -656,6 +656,9 @@ export default function (pi) {
656
656
  updateStatus,
657
657
  sendTextReply,
658
658
  sendUserMessage,
659
+ reconcileNextDispatchAnnouncementReplyOwnership(item) {
660
+ Replies.preserveTransportReplyDedupOnNextReset(item.chatId, item.replyToMessageId, item.target);
661
+ },
659
662
  recordRuntimeEvent,
660
663
  });
661
664
  const { finalizeMarkdownPreview, preparePreviewDelivery } = Outbound.createTelegramOutboundTextPreviewRuntime({
@@ -887,6 +890,7 @@ export default function (pi) {
887
890
  isContextActive: telegramSessionContextStore.isCurrent,
888
891
  dispatchNextQueuedTelegramTurn,
889
892
  requestNextDispatchAnnouncement,
893
+ cancelNextDispatchAnnouncement,
890
894
  requestDeferredDispatchNextQueuedTelegramTurn: deferredQueueDispatchRuntime.request,
891
895
  hasDeferredDispatchContext: deferredQueueDispatchRuntime.isBound,
892
896
  startTypingLoop: promptDispatchRuntime.startTypingLoop,
@@ -84,6 +84,8 @@ export interface PendingTelegramTurn extends TelegramQueueItemBase {
84
84
  historyText: string;
85
85
  priorityEmoji?: string;
86
86
  reactionSuppressionEmoji?: string;
87
+ /** Emit the explicit aborted-turn notice when /next settles this active turn. */
88
+ announceNextAbortOnEnd?: boolean;
87
89
  /** Turn should preferably be delivered as voice (mirror mode + user sent voice) */
88
90
  voiceReplyPreferred?: boolean;
89
91
  /** Turn must be delivered as voice (voice mode) */
@@ -165,6 +167,8 @@ export interface TelegramActiveTurnStore<TTurn extends PendingTelegramTurn = Pen
165
167
  has: () => boolean;
166
168
  set: (turn: TTurn) => void;
167
169
  clear: () => void;
170
+ markNextAbortAnnouncement: () => boolean;
171
+ clearNextAbortAnnouncement: () => boolean;
168
172
  getChatId: () => number | undefined;
169
173
  getTarget: () => TelegramQueueTarget | undefined;
170
174
  getReplyToMessageId: () => number | undefined;
@@ -391,6 +395,7 @@ export interface TelegramAgentEndPlan {
391
395
  kind: "no-turn" | "aborted" | "error" | "text" | "attachments-only" | "empty";
392
396
  shouldClearPreview: boolean;
393
397
  shouldDispatchNext: boolean;
398
+ shouldSendAbortMessage: boolean;
394
399
  shouldSendErrorMessage: boolean;
395
400
  shouldSendAttachmentNotice: boolean;
396
401
  }
@@ -517,6 +522,7 @@ export declare function buildTelegramAgentEndPlan(options: {
517
522
  hasFinalText: boolean;
518
523
  hasQueuedAttachments: boolean;
519
524
  foldQueuedPromptsIntoHistory: boolean;
525
+ announceNextAbortOnEnd?: boolean;
520
526
  }): TelegramAgentEndPlan;
521
527
  export declare function createTelegramAgentEndHook<TTurn extends PendingTelegramTurn, TContext, TMessage, TReplyMarkup = unknown>(deps: TelegramAgentEndHookRuntimeDeps<TTurn, TContext, TMessage, TReplyMarkup>): (event: TelegramAgentEndHookEvent<TMessage>, ctx: TContext, assistantOverride?: TelegramAgentEndAssistantResult) => Promise<void>;
522
528
  export declare function handleTelegramAgentEndRuntime<TTurn extends PendingTelegramTurn, TReplyMarkup = unknown>(deps: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>): Promise<void>;
@@ -759,6 +765,7 @@ export interface TelegramQueueDispatchControllerDeps<TContext = unknown> extends
759
765
  }>["item"], ctx: TContext) => boolean;
760
766
  sendUserMessage: TelegramDispatchRuntimeDeps<TContext>["sendUserMessage"];
761
767
  onPromptDispatchFailure: (ctx: TContext, message: string) => void;
768
+ reconcileNextDispatchAnnouncementReplyOwnership?: (item: PendingTelegramTurn) => void;
762
769
  isQueueItemTransportActive?: (item: TelegramQueueItem<TContext>) => boolean;
763
770
  hasPendingInboundQueueMutationForItem?: (item: TelegramQueueItem<TContext>) => boolean;
764
771
  isQueueItemAdmissionReady?: (item: TelegramQueueItem<TContext>) => boolean;
@@ -768,6 +775,7 @@ export interface TelegramQueueDispatchControllerDeps<TContext = unknown> extends
768
775
  export interface TelegramQueueDispatchController<TContext = unknown> {
769
776
  dispatchNext: (ctx: TContext) => void;
770
777
  requestNextDispatchAnnouncement: () => void;
778
+ cancelNextDispatchAnnouncement: () => void;
771
779
  }
772
780
  export declare function executeTelegramQueueDispatchPlan<TContext = unknown>(plan: TelegramQueueDispatchAction<TContext>, deps: TelegramDispatchRuntimeDeps<TContext>): void;
773
781
  export type TelegramQueueDispatchRuntimeDeps<TContext = unknown> = Omit<TelegramQueueDispatchControllerDeps<TContext>, "canDispatch"> & TelegramDispatchReadinessDeps<TContext>;
@@ -168,6 +168,18 @@ export function createTelegramActiveTurnStore() {
168
168
  clear: () => {
169
169
  activeTurn = undefined;
170
170
  },
171
+ markNextAbortAnnouncement: () => {
172
+ if (!activeTurn)
173
+ return false;
174
+ activeTurn.announceNextAbortOnEnd = true;
175
+ return true;
176
+ },
177
+ clearNextAbortAnnouncement: () => {
178
+ if (!activeTurn?.announceNextAbortOnEnd)
179
+ return false;
180
+ delete activeTurn.announceNextAbortOnEnd;
181
+ return true;
182
+ },
171
183
  getChatId: () => activeTurn?.chatId,
172
184
  getTarget: () => activeTurn?.target ? { ...activeTurn.target } : undefined,
173
185
  getReplyToMessageId: () => activeTurn?.replyToMessageId,
@@ -815,6 +827,7 @@ export function buildTelegramAgentEndPlan(options) {
815
827
  kind: "no-turn",
816
828
  shouldClearPreview: false,
817
829
  shouldDispatchNext,
830
+ shouldSendAbortMessage: false,
818
831
  shouldSendErrorMessage: false,
819
832
  shouldSendAttachmentNotice: false,
820
833
  };
@@ -824,6 +837,7 @@ export function buildTelegramAgentEndPlan(options) {
824
837
  kind: "aborted",
825
838
  shouldClearPreview: true,
826
839
  shouldDispatchNext,
840
+ shouldSendAbortMessage: options.announceNextAbortOnEnd === true,
827
841
  shouldSendErrorMessage: false,
828
842
  shouldSendAttachmentNotice: false,
829
843
  };
@@ -833,6 +847,7 @@ export function buildTelegramAgentEndPlan(options) {
833
847
  kind: "error",
834
848
  shouldClearPreview: true,
835
849
  shouldDispatchNext,
850
+ shouldSendAbortMessage: false,
836
851
  shouldSendErrorMessage: true,
837
852
  shouldSendAttachmentNotice: false,
838
853
  };
@@ -842,6 +857,7 @@ export function buildTelegramAgentEndPlan(options) {
842
857
  kind: "text",
843
858
  shouldClearPreview: false,
844
859
  shouldDispatchNext,
860
+ shouldSendAbortMessage: false,
845
861
  shouldSendErrorMessage: false,
846
862
  shouldSendAttachmentNotice: false,
847
863
  };
@@ -851,6 +867,7 @@ export function buildTelegramAgentEndPlan(options) {
851
867
  kind: "attachments-only",
852
868
  shouldClearPreview: true,
853
869
  shouldDispatchNext,
870
+ shouldSendAbortMessage: false,
854
871
  shouldSendErrorMessage: false,
855
872
  shouldSendAttachmentNotice: true,
856
873
  };
@@ -859,6 +876,7 @@ export function buildTelegramAgentEndPlan(options) {
859
876
  kind: "empty",
860
877
  shouldClearPreview: true,
861
878
  shouldDispatchNext,
879
+ shouldSendAbortMessage: false,
862
880
  shouldSendErrorMessage: false,
863
881
  shouldSendAttachmentNotice: false,
864
882
  };
@@ -991,6 +1009,7 @@ export async function handleTelegramAgentEndRuntime(deps) {
991
1009
  hasFinalText: !!finalText || hasOutboundArtifacts,
992
1010
  hasQueuedAttachments: (turn?.queuedAttachments.length ?? 0) > 0,
993
1011
  foldQueuedPromptsIntoHistory: deps.foldQueuedPromptsIntoHistory,
1012
+ announceNextAbortOnEnd: turn?.announceNextAbortOnEnd === true,
994
1013
  });
995
1014
  if (!turn) {
996
1015
  if (endPlan.shouldDispatchNext)
@@ -1116,17 +1135,26 @@ export async function handleTelegramAgentEndRuntime(deps) {
1116
1135
  if (!isDeliveryActive())
1117
1136
  return;
1118
1137
  }
1119
- if (endPlan.shouldSendErrorMessage) {
1138
+ if (endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage) {
1120
1139
  const errorMessage = assistant.errorMessage ||
1121
1140
  "Telegram bridge: Pi failed while processing the request.";
1122
- const isOperationAborted = errorMessage.trim().replace(/\.$/, "") ===
1123
- "This operation was aborted";
1124
- await deps.sendTextReply(turn.chatId, turn.replyToMessageId, isOperationAborted
1125
- ? "<b>⏹️ This operation was aborted.</b>"
1126
- : errorMessage, {
1127
- target: turn.target,
1128
- ...(isOperationAborted ? { parseMode: "HTML" } : {}),
1129
- });
1141
+ const isOperationAborted = endPlan.shouldSendAbortMessage ||
1142
+ errorMessage.trim().replace(/\.$/, "") === "This operation was aborted";
1143
+ try {
1144
+ await deps.sendTextReply(turn.chatId, turn.replyToMessageId, isOperationAborted
1145
+ ? "<b>⏹️ This operation was aborted.</b>"
1146
+ : errorMessage, {
1147
+ target: turn.target,
1148
+ ...(isOperationAborted ? { parseMode: "HTML" } : {}),
1149
+ });
1150
+ }
1151
+ catch (error) {
1152
+ if (!isOperationAborted)
1153
+ throw error;
1154
+ deps.recordRuntimeEvent?.("dispatch", error, {
1155
+ phase: "next-abort-announcement",
1156
+ });
1157
+ }
1130
1158
  if (!isDeliveryActive())
1131
1159
  return;
1132
1160
  if (endPlan.shouldDispatchNext)
@@ -1267,7 +1295,7 @@ export async function handleTelegramAgentEndRuntime(deps) {
1267
1295
  deps.dispatchNextQueuedTelegramTurn();
1268
1296
  };
1269
1297
  if (deps.scheduleActiveTurnDelivery &&
1270
- (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)) {
1298
+ (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)) {
1271
1299
  deps.scheduleActiveTurnDelivery(deliverActiveTurn);
1272
1300
  return;
1273
1301
  }
@@ -1765,6 +1793,7 @@ export function createTelegramQueueDispatchRuntime(deps) {
1765
1793
  commitPromptDispatch: deps.commitPromptDispatch,
1766
1794
  sendUserMessage: deps.sendUserMessage,
1767
1795
  onPromptDispatchFailure: deps.onPromptDispatchFailure,
1796
+ reconcileNextDispatchAnnouncementReplyOwnership: deps.reconcileNextDispatchAnnouncementReplyOwnership,
1768
1797
  isQueueItemTransportActive: deps.isQueueItemTransportActive,
1769
1798
  hasPendingInboundQueueMutationForItem: deps.hasPendingInboundQueueMutationForItem,
1770
1799
  isQueueItemAdmissionReady: deps.isQueueItemAdmissionReady,
@@ -1776,12 +1805,19 @@ export function createTelegramQueueDispatchRuntime(deps) {
1776
1805
  export function createTelegramQueueDispatchController(deps) {
1777
1806
  let controlDispatchPending = false;
1778
1807
  let nextDispatchAnnouncementRequested = false;
1808
+ let nextDispatchAnnouncementGeneration = 0;
1779
1809
  let nextDispatchAnnouncementAnchor;
1780
1810
  const controller = {
1781
1811
  requestNextDispatchAnnouncement: () => {
1812
+ nextDispatchAnnouncementGeneration += 1;
1782
1813
  nextDispatchAnnouncementRequested = true;
1783
1814
  nextDispatchAnnouncementAnchor = undefined;
1784
1815
  },
1816
+ cancelNextDispatchAnnouncement: () => {
1817
+ nextDispatchAnnouncementGeneration += 1;
1818
+ nextDispatchAnnouncementRequested = false;
1819
+ nextDispatchAnnouncementAnchor = undefined;
1820
+ },
1785
1821
  dispatchNext: (ctx) => {
1786
1822
  if (deps.hasDispatchContext && !deps.hasDispatchContext())
1787
1823
  return;
@@ -1972,11 +2008,22 @@ export function createTelegramQueueDispatchController(deps) {
1972
2008
  if (dispatchPlan.kind === "prompt" && nextDispatchAnnouncementRequested) {
1973
2009
  nextDispatchAnnouncementRequested = false;
1974
2010
  controlDispatchPending = true;
2011
+ const announcementGeneration = nextDispatchAnnouncementGeneration;
1975
2012
  const dispatchGeneration = deps.getDispatchGeneration?.();
1976
2013
  deps.updateStatus(ctx);
1977
2014
  void deps.sendTextReply(dispatchPlan.item.chatId, dispatchPlan.item.replyToMessageId, "<b>⏩ Dispatching next queued turn.</b>", { target: dispatchPlan.item.target }).catch((error) => {
1978
- deps.recordRuntimeEvent?.("dispatch", error, { phase: "next-announcement" });
2015
+ deps.recordRuntimeEvent?.("dispatch", error, {
2016
+ phase: "next-announcement",
2017
+ });
1979
2018
  }).finally(() => {
2019
+ try {
2020
+ deps.reconcileNextDispatchAnnouncementReplyOwnership?.(dispatchPlan.item);
2021
+ }
2022
+ catch (error) {
2023
+ deps.recordRuntimeEvent?.("dispatch", error, {
2024
+ phase: "next-announcement-reply-ownership",
2025
+ });
2026
+ }
1980
2027
  controlDispatchPending = false;
1981
2028
  if (deps.hasDispatchContext && !deps.hasDispatchContext())
1982
2029
  return;
@@ -1984,6 +2031,11 @@ export function createTelegramQueueDispatchController(deps) {
1984
2031
  deps.isDispatchGenerationActive &&
1985
2032
  !deps.isDispatchGenerationActive(dispatchGeneration))
1986
2033
  return;
2034
+ if (announcementGeneration !== nextDispatchAnnouncementGeneration) {
2035
+ if (nextDispatchAnnouncementRequested)
2036
+ controller.dispatchNext(ctx);
2037
+ return;
2038
+ }
1987
2039
  executePlan();
1988
2040
  });
1989
2041
  return;
@@ -23,6 +23,10 @@ export interface ReplyDedupRuntime {
23
23
  }
24
24
  export declare function createReplyDedupRuntime(): ReplyDedupRuntime;
25
25
  export declare function resetTransportReplyDedup(): void;
26
+ /** Keeps a successfully published transition notice as the first reply of the
27
+ * next agent turn. The following agent-start reset consumes this one-shot
28
+ * preservation, so later messages in that turn do not repeat the reply header. */
29
+ export declare function preserveTransportReplyDedupOnNextReset(chatId: number, messageId: number, target?: TelegramTarget): void;
26
30
  export declare function buildTelegramReplyParameters(chatId: number, messageId: number | undefined, target?: TelegramTarget): TelegramReplyParameters | undefined;
27
31
  export declare function withTelegramReplyParameters<T>(chatId: number, messageId: number | undefined, target: TelegramTarget | undefined, send: (parameters: TelegramReplyParameters | undefined) => Promise<T>): Promise<T>;
28
32
  export declare function isAssistantAgentMessage(message: unknown): boolean;
@@ -31,6 +31,7 @@ export function createReplyDedupRuntime() {
31
31
  }
32
32
  // --- Transport-level dedup ---
33
33
  const lastRepliedToMessageIdByTarget = new Map();
34
+ const replyDedupPreservedOnNextReset = new Map();
34
35
  let replyDedupGeneration = 0;
35
36
  function getReplyDedupTargetKey(chatId, target) {
36
37
  const threadId = target?.threadId;
@@ -41,6 +42,19 @@ function getReplyDedupTargetKey(chatId, target) {
41
42
  export function resetTransportReplyDedup() {
42
43
  replyDedupGeneration += 1;
43
44
  lastRepliedToMessageIdByTarget.clear();
45
+ for (const [key, messageId] of replyDedupPreservedOnNextReset) {
46
+ lastRepliedToMessageIdByTarget.set(key, messageId);
47
+ }
48
+ replyDedupPreservedOnNextReset.clear();
49
+ }
50
+ /** Keeps a successfully published transition notice as the first reply of the
51
+ * next agent turn. The following agent-start reset consumes this one-shot
52
+ * preservation, so later messages in that turn do not repeat the reply header. */
53
+ export function preserveTransportReplyDedupOnNextReset(chatId, messageId, target) {
54
+ const key = getReplyDedupTargetKey(chatId, target);
55
+ if (lastRepliedToMessageIdByTarget.get(key) !== messageId)
56
+ return;
57
+ replyDedupPreservedOnNextReset.set(key, messageId);
44
58
  }
45
59
  export function buildTelegramReplyParameters(chatId, messageId, target) {
46
60
  if (messageId === undefined || messageId <= 0)
@@ -122,6 +122,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
122
122
  isContextActive?: (ctx: TContext) => boolean;
123
123
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
124
124
  requestNextDispatchAnnouncement?: () => void;
125
+ cancelNextDispatchAnnouncement?: () => void;
125
126
  requestDeferredDispatchNextQueuedTelegramTurn?: (dispatch: (ctx: TContext) => void) => void;
126
127
  hasDeferredDispatchContext?: () => boolean;
127
128
  startTypingLoop?: (ctx: TContext, chatId?: number, options?: {
@@ -1418,6 +1418,10 @@ export function createTelegramInboundRouteRuntime(deps) {
1418
1418
  isContextActive: deps.isContextActive,
1419
1419
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
1420
1420
  requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
1421
+ cancelNextTransitionAnnouncements: () => {
1422
+ deps.activeTurnRuntime.clearNextAbortAnnouncement();
1423
+ deps.cancelNextDispatchAnnouncement?.();
1424
+ },
1421
1425
  requestDeferredDispatchNextQueuedTelegramTurn: deps.requestDeferredDispatchNextQueuedTelegramTurn,
1422
1426
  startTypingLoop: deps.startTypingLoop,
1423
1427
  stopTypingLoop: deps.stopTypingLoop,
@@ -1476,6 +1480,7 @@ export function createTelegramInboundRouteRuntime(deps) {
1476
1480
  },
1477
1481
  getPromptTemplateCommands,
1478
1482
  sendTextReply: deps.sendTextReply,
1483
+ markActiveTurnNextAbortAnnouncement: deps.activeTurnRuntime.markNextAbortAnnouncement,
1479
1484
  getActiveTurnReply: () => {
1480
1485
  const activeTurn = deps.activeTurnRuntime.get();
1481
1486
  if (!activeTurn)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.51.0",
3
+ "version": "0.51.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -518,7 +518,7 @@ Immediate controls:
518
518
  - `/start` opens the main inline application menu.
519
519
  - `/model`, `/thinking`, `/queue`, and `/settings` are hidden shortcuts to menu sections.
520
520
  - `/compact` opens an inline confirmation dialog and then runs compaction when the bridge is idle.
521
- - `/next` dispatches the next queued turn, aborting Pi first when needed. Active-turn settlement delivers its abort notice to the interrupted prompt before queue dispatch continues. The queue owner then emits `Dispatching next queued turn.` against the exact selected prompt's chat/thread/reply id before its one model dispatch; skipped, inactive, pending-mutation, and admission-blocked candidates never receive it. The `/next` command itself is never a lifecycle-notice reply target. Aborted pending assistant text is not projected as a second reply, while already completed intermediate output remains visible.
521
+ - `/next` dispatches the next queued turn, aborting Pi first when needed. Target-bound command composition must forward the announcement request, exact-turn marker, superseding cancellation, and deferred-dispatch ports rather than silently dropping them. Active-turn settlement attempts its abort notice against the interrupted prompt before queue dispatch continues; definite or ambiguous notice failure is recorded but cannot block the selected turn. The queue owner then emits `Dispatching next queued turn.` against the exact selected prompt's chat/thread/reply id before its one model dispatch; skipped, inactive, pending-mutation, and admission-blocked candidates never receive it. A successful dispatch notice carries its reply-dedup anchor through the following agent-start reset, so later messages in that turn do not repeat the queued-prompt reply header. A later `/abort` or `/stop` cancels both pending `/next` notices before taking ownership, preventing duplicate abort results and stale future dispatch announcements. The `/next` command itself is never a lifecycle-notice reply target. Aborted pending assistant text is not projected as a second reply, while already completed intermediate output remains visible.
522
522
  - `/abort` aborts active work while preserving queued items. Abort-history preservation is enabled only for Telegram-owned active turns; later local/non-Telegram agent starts clear stale abort-history mode so the next Telegram prompt appends instead of absorbing old queued turns as history.
523
523
  - `/stop` aborts and clears waiting Telegram queue items.
524
524
 
@@ -485,7 +485,7 @@ All files containing routing, chat ids, thread ids, or process details use priva
485
485
 
486
486
  ### Follower heartbeat is missed
487
487
 
488
- - Leader tolerates up to 15 seconds without a follower heartbeat before pruning it from the live registry, so short local event-loop or IPC stalls do not create false routing gaps. A follower response deadline defers its final timeout through one socket poll phase: an acknowledgement already buffered while the Pi/TUI event loop was blocked wins, while a genuinely silent peer still fails in the same event-loop turn. Heartbeat pruning remains liveness bookkeeping. One leader generation owns at most one prune operation; stop makes late endpoint, policy, and cleanup settlement inert, while durable-profile mutation serialization prevents replacement registration from crossing confirmed-dead cleanup.
488
+ - Leader tolerates up to 15 seconds without a follower heartbeat before pruning it from the live registry, so short local event-loop or IPC stalls do not create false routing gaps. Follower heartbeat requests use a dedicated eight-second response deadline aligned with leader stale-liveness policy rather than the generic one-second local-RPC default. The deadline also defers its final timeout through one socket poll phase: an acknowledgement already buffered while the Pi/TUI event loop was blocked wins, while a genuinely silent peer still fails in the same event-loop turn. Heartbeat pruning remains liveness bookkeeping. One leader generation owns at most one prune operation; stop makes late endpoint, policy, and cleanup settlement inert, while durable-profile mutation serialization prevents replacement registration from crossing confirmed-dead cleanup.
489
489
  - A missed heartbeat does not delete, close, mark offline, or send a disconnected notice for the follower's Telegram thread binding because the common cause may be leader reload, IPC handoff, or transient reconnect rather than a dead follower.
490
490
  - After removal from live routing, the current leader retains at most 26 volatile preservation observations with exact registered PID, generation, target, profile and leader epoch. The existing prune loop rechecks alive/unverifiable PIDs and retries non-destructive publication only after fresh explicit absence and a known disabled-cleanup policy. Missing identity or overflow stays protected. Observations never answer heartbeat/API/forwarding requests, launch processes, or authorize deletion. Enabling cleanup cancels deferred preservation rather than promoting it into destructive work; the existing first-prune cleanup path is not retried by this mechanism.
491
491
  - Registry registration invalidates overlapping instance/profile-owner/target observations, even if that replacement is removed before the next prune. Admitted same-instance provisioning cancels the old observation before its store writes can precede live registration. Profile/epoch drift, registry clear and leader stop invalidate it; late old completion cannot erase a replacement runtime's observation. No observation survives leader restart, reconstructs a PID from an opaque instance ID, or invents legacy inactivity.
@@ -53,7 +53,7 @@ Stable commands inside the paired Telegram DM:
53
53
  - `/start` — pair when needed and open the main application menu.
54
54
  - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-internal` Pi gateway using `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Manual invocation reports that the gateway cannot be run manually; the armed handler receives a real `ExtensionCommandContext` and calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. Busy, identity-mismatch, and unavailable-host paths fail closed.
55
55
  - `/compact` — open confirmation and compact when idle.
56
- - `/next` — abort active work first when needed, let the interrupted prompt receive its abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. The command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
56
+ - `/next` — abort active work first when needed, attempt the interrupted prompt's abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. That dispatch notice owns the turn's one reply header; later answer messages do not reply to the same prompt again. Abort-notice failure is diagnostic and cannot block dispatch. A later `/abort` or `/stop` cancels both pending transition notices before taking ownership. The `/next` command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
57
57
  - `/continue` — enqueue a priority `continue` prompt.
58
58
  - `/abort` — abort active work and keep the queue; abort-history is scoped to Telegram-owned active turns.
59
59
  - `/stop` — abort active Telegram-owned work and clear waiting Telegram queue items.
@@ -52,6 +52,7 @@ export interface TelegramQueueBindingRuntime<TContext> {
52
52
  mutation: Queue.TelegramQueueMutationController<TContext>;
53
53
  dispatchNext: (ctx: TContext) => void;
54
54
  requestNextDispatchAnnouncement: () => void;
55
+ cancelNextDispatchAnnouncement: () => void;
55
56
  watchdog: Queue.TelegramQueueDispatchWatchdogRuntime<TContext>;
56
57
  }
57
58
 
@@ -96,6 +97,9 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
96
97
  updateStatus: (ctx: TContext, error?: string) => void;
97
98
  sendTextReply: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendTextReply"];
98
99
  sendUserMessage: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendUserMessage"];
100
+ reconcileNextDispatchAnnouncementReplyOwnership?: (
101
+ item: Queue.PendingTelegramTurn,
102
+ ) => void;
99
103
  recordRuntimeEvent?: TelegramRuntimeEventRecorder;
100
104
  }): TelegramQueueBindingRuntime<TContext> {
101
105
  const settleDiscardedItems = (
@@ -159,6 +163,8 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
159
163
  },
160
164
  updateStatus: deps.updateStatus,
161
165
  sendTextReply: deps.sendTextReply,
166
+ reconcileNextDispatchAnnouncementReplyOwnership:
167
+ deps.reconcileNextDispatchAnnouncementReplyOwnership,
162
168
  recordRuntimeEvent: deps.recordRuntimeEvent,
163
169
  ...deps.promptDispatch,
164
170
  sendUserMessage: deps.sendUserMessage,
@@ -167,6 +173,7 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
167
173
  mutation,
168
174
  dispatchNext: dispatch.dispatchNext,
169
175
  requestNextDispatchAnnouncement: dispatch.requestNextDispatchAnnouncement,
176
+ cancelNextDispatchAnnouncement: dispatch.cancelNextDispatchAnnouncement,
170
177
  watchdog: Queue.createTelegramQueueDispatchWatchdogRuntime({
171
178
  hasQueuedItems: deps.store.hasQueuedItems,
172
179
  dispatchNextQueuedTelegramTurn: dispatch.dispatchNext,
@@ -11,7 +11,11 @@ import { basename } from "node:path";
11
11
  import * as Sync from "./sync.ts";
12
12
  import * as Threads from "./threads.ts";
13
13
  import { parseTelegramUpdateJournalQueueOwner, type TelegramUpdateJournalStoreOptions } from "./journal.ts";
14
- import type { TelegramLockEntry, TelegramLockState } from "./locks.ts";
14
+ import {
15
+ TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS,
16
+ type TelegramLockEntry,
17
+ type TelegramLockState,
18
+ } from "./locks.ts";
15
19
  import type {
16
20
  TelegramQueueHandoffPayload,
17
21
  TelegramQueueHandoffStageResult,
@@ -60,6 +64,8 @@ export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
60
64
  export const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
61
65
  export const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30_000;
62
66
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30_000;
67
+ export const TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS =
68
+ TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS;
63
69
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS =
64
70
  TELEGRAM_BUS_REGISTRATION_RETRY.attempts;
65
71
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS =
@@ -301,6 +307,7 @@ export interface TelegramBusFollowerRegistrationRuntimeDeps<
301
307
  registrationRetryAttempts?: number;
302
308
  registrationRetryDelayMs?: number;
303
309
  heartbeatMs?: number;
310
+ heartbeatTimeoutMs?: number;
304
311
  recordRuntimeEvent?: (
305
312
  category: string,
306
313
  error: unknown,
@@ -1619,6 +1626,10 @@ export function createTelegramBusFollowerRegistrationRuntime<
1619
1626
  const getNowMs = deps.getNowMs ?? Date.now;
1620
1627
  const getPid = deps.getPid ?? (() => process.pid);
1621
1628
  const heartbeatMs = deps.heartbeatMs ?? 1000;
1629
+ const heartbeatTimeoutMs =
1630
+ deps.heartbeatTimeoutMs ??
1631
+ deps.timeoutMs ??
1632
+ TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS;
1622
1633
  const registrationTimeoutMs =
1623
1634
  deps.registrationTimeoutMs ?? deps.timeoutMs ?? 30000;
1624
1635
  const registrationRetryAttempts =
@@ -1679,7 +1690,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
1679
1690
  try {
1680
1691
  const response = await sendTelegramBusLocalEnvelope({
1681
1692
  socketPath: leaderSocketPath,
1682
- timeoutMs: deps.timeoutMs,
1693
+ timeoutMs: heartbeatTimeoutMs,
1683
1694
  retry: getTelegramBusTransportRetryPolicy({
1684
1695
  endpoint: leaderSocketPath,
1685
1696
  operation: "operation",
@@ -711,6 +711,7 @@ export interface TelegramCommandActionDeps<TMessage, TContext> {
711
711
  export interface TelegramStopCommandDeps {
712
712
  hasAbortHandler: () => boolean;
713
713
  clearPendingModelSwitch: () => void;
714
+ cancelNextTransitionAnnouncements?: () => void;
714
715
  clearQueuedTelegramItems: () => number;
715
716
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
716
717
  abortCurrentTurn: () => void;
@@ -1146,6 +1147,8 @@ export interface TelegramCommandRuntimeDeps<
1146
1147
  isContextActive?: (ctx: TContext) => boolean;
1147
1148
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
1148
1149
  requestNextDispatchAnnouncement?: () => void;
1150
+ markActiveTurnNextAbortAnnouncement?: () => boolean;
1151
+ cancelNextTransitionAnnouncements?: () => void;
1149
1152
  requestDeferredDispatchNextQueuedTelegramTurn?: (
1150
1153
  dispatch: (ctx: TContext) => void,
1151
1154
  ) => void;
@@ -1352,6 +1355,7 @@ export async function handleTelegramStopCommand(
1352
1355
  deps: TelegramStopCommandDeps,
1353
1356
  ): Promise<void> {
1354
1357
  deps.clearPendingModelSwitch();
1358
+ deps.cancelNextTransitionAnnouncements?.();
1355
1359
  const clearedCount = deps.clearQueuedTelegramItems();
1356
1360
  deps.setFoldQueuedPromptsIntoHistory(false);
1357
1361
  if (!deps.hasAbortHandler()) {
@@ -1385,6 +1389,7 @@ export async function handleTelegramAbortCommand(deps: {
1385
1389
  hasAbortHandler: () => boolean;
1386
1390
  hasActiveTelegramTurn: () => boolean;
1387
1391
  clearPendingModelSwitch: () => void;
1392
+ cancelNextTransitionAnnouncements?: () => void;
1388
1393
  abortCurrentTurn: () => void;
1389
1394
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
1390
1395
  updateStatus: () => void;
@@ -1394,6 +1399,7 @@ export async function handleTelegramAbortCommand(deps: {
1394
1399
  ) => Promise<void>;
1395
1400
  }): Promise<void> {
1396
1401
  deps.clearPendingModelSwitch();
1402
+ deps.cancelNextTransitionAnnouncements?.();
1397
1403
  if (!deps.hasAbortHandler()) {
1398
1404
  await deps.sendTextReply(
1399
1405
  formatTelegramInformationHeading("💤", "No active turn."),
@@ -1418,6 +1424,7 @@ export async function handleTelegramNextCommand(deps: {
1418
1424
  abortCurrentTurn: () => void;
1419
1425
  dispatchNextQueuedTurn: () => void;
1420
1426
  requestNextDispatchAnnouncement?: () => void;
1427
+ markActiveTurnNextAbortAnnouncement?: () => boolean;
1421
1428
  clearFoldForDispatch: () => void;
1422
1429
  updateStatus: () => void;
1423
1430
  sendTextReply: (
@@ -1439,6 +1446,7 @@ export async function handleTelegramNextCommand(deps: {
1439
1446
  if (!deps.isIdle() && deps.hasAbortHandler()) {
1440
1447
  deps.clearFoldForDispatch();
1441
1448
  deps.requestNextDispatchAnnouncement?.();
1449
+ deps.markActiveTurnNextAbortAnnouncement?.();
1442
1450
  deps.abortCurrentTurn();
1443
1451
  deps.updateStatus();
1444
1452
  return;
@@ -1880,6 +1888,11 @@ export function createTelegramCommandHandlerTargetRuntime<
1880
1888
  updateStatus: deps.updateStatus,
1881
1889
  isContextActive: deps.isContextActive,
1882
1890
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
1891
+ requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
1892
+ markActiveTurnNextAbortAnnouncement:
1893
+ deps.markActiveTurnNextAbortAnnouncement,
1894
+ cancelNextTransitionAnnouncements:
1895
+ deps.cancelNextTransitionAnnouncements,
1883
1896
  startTypingLoop: deps.startTypingLoop,
1884
1897
  stopTypingLoop: deps.stopTypingLoop,
1885
1898
  enqueueContinueTurn: deps.enqueueContinueTurn,
@@ -2037,6 +2050,8 @@ async function handleTelegramCommandRuntime<
2037
2050
  await handleTelegramStopCommand({
2038
2051
  hasAbortHandler: deps.hasAbortHandler,
2039
2052
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
2053
+ cancelNextTransitionAnnouncements:
2054
+ deps.cancelNextTransitionAnnouncements,
2040
2055
  clearQueuedTelegramItems: () =>
2041
2056
  deps.clearQueuedTelegramItems(commandCtx),
2042
2057
  setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
@@ -2113,6 +2128,8 @@ async function handleTelegramCommandRuntime<
2113
2128
  hasAbortHandler: deps.hasAbortHandler,
2114
2129
  hasActiveTelegramTurn: deps.hasActiveTelegramTurn,
2115
2130
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
2131
+ cancelNextTransitionAnnouncements:
2132
+ deps.cancelNextTransitionAnnouncements,
2116
2133
  abortCurrentTurn: deps.abortCurrentTurn,
2117
2134
  setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
2118
2135
  updateStatus: updateStatusFor(commandCtx),
@@ -2129,6 +2146,8 @@ async function handleTelegramCommandRuntime<
2129
2146
  dispatchNextQueuedTurn: () =>
2130
2147
  deps.dispatchNextQueuedTelegramTurn(commandCtx),
2131
2148
  requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
2149
+ markActiveTurnNextAbortAnnouncement:
2150
+ deps.markActiveTurnNextAbortAnnouncement,
2132
2151
  clearFoldForDispatch: () =>
2133
2152
  deps.setFoldQueuedPromptsIntoHistory(false),
2134
2153
  updateStatus: updateStatusFor(commandCtx),
@@ -820,6 +820,7 @@ export default function (pi: Pi.ExtensionAPI) {
820
820
  mutation: queueMutationRuntime,
821
821
  dispatchNext: dispatchNextQueuedTelegramTurn,
822
822
  requestNextDispatchAnnouncement,
823
+ cancelNextDispatchAnnouncement,
823
824
  watchdog: queueDispatchWatchdogRuntime,
824
825
  } = Bindings.createTelegramQueueBindingRuntime({
825
826
  store: telegramQueueStore,
@@ -835,6 +836,13 @@ export default function (pi: Pi.ExtensionAPI) {
835
836
  updateStatus,
836
837
  sendTextReply,
837
838
  sendUserMessage,
839
+ reconcileNextDispatchAnnouncementReplyOwnership(item) {
840
+ Replies.preserveTransportReplyDedupOnNextReset(
841
+ item.chatId,
842
+ item.replyToMessageId,
843
+ item.target,
844
+ );
845
+ },
838
846
  recordRuntimeEvent,
839
847
  });
840
848
  const { finalizeMarkdownPreview, preparePreviewDelivery } =
@@ -1093,6 +1101,7 @@ export default function (pi: Pi.ExtensionAPI) {
1093
1101
  isContextActive: telegramSessionContextStore.isCurrent,
1094
1102
  dispatchNextQueuedTelegramTurn,
1095
1103
  requestNextDispatchAnnouncement,
1104
+ cancelNextDispatchAnnouncement,
1096
1105
  requestDeferredDispatchNextQueuedTelegramTurn:
1097
1106
  deferredQueueDispatchRuntime.request,
1098
1107
  hasDeferredDispatchContext: deferredQueueDispatchRuntime.isBound,
@@ -126,6 +126,8 @@ export interface PendingTelegramTurn extends TelegramQueueItemBase {
126
126
  historyText: string;
127
127
  priorityEmoji?: string;
128
128
  reactionSuppressionEmoji?: string;
129
+ /** Emit the explicit aborted-turn notice when /next settles this active turn. */
130
+ announceNextAbortOnEnd?: boolean;
129
131
 
130
132
  /** Turn should preferably be delivered as voice (mirror mode + user sent voice) */
131
133
  voiceReplyPreferred?: boolean;
@@ -235,6 +237,8 @@ export interface TelegramActiveTurnStore<
235
237
  has: () => boolean;
236
238
  set: (turn: TTurn) => void;
237
239
  clear: () => void;
240
+ markNextAbortAnnouncement: () => boolean;
241
+ clearNextAbortAnnouncement: () => boolean;
238
242
  getChatId: () => number | undefined;
239
243
  getTarget: () => TelegramQueueTarget | undefined;
240
244
  getReplyToMessageId: () => number | undefined;
@@ -461,6 +465,16 @@ export function createTelegramActiveTurnStore<
461
465
  clear: () => {
462
466
  activeTurn = undefined;
463
467
  },
468
+ markNextAbortAnnouncement: () => {
469
+ if (!activeTurn) return false;
470
+ activeTurn.announceNextAbortOnEnd = true;
471
+ return true;
472
+ },
473
+ clearNextAbortAnnouncement: () => {
474
+ if (!activeTurn?.announceNextAbortOnEnd) return false;
475
+ delete activeTurn.announceNextAbortOnEnd;
476
+ return true;
477
+ },
464
478
  getChatId: () => activeTurn?.chatId,
465
479
  getTarget: () =>
466
480
  activeTurn?.target ? { ...activeTurn.target } : undefined,
@@ -1488,6 +1502,7 @@ export interface TelegramAgentEndPlan {
1488
1502
  kind: "no-turn" | "aborted" | "error" | "text" | "attachments-only" | "empty";
1489
1503
  shouldClearPreview: boolean;
1490
1504
  shouldDispatchNext: boolean;
1505
+ shouldSendAbortMessage: boolean;
1491
1506
  shouldSendErrorMessage: boolean;
1492
1507
  shouldSendAttachmentNotice: boolean;
1493
1508
  }
@@ -1680,6 +1695,7 @@ export function buildTelegramAgentEndPlan(options: {
1680
1695
  hasFinalText: boolean;
1681
1696
  hasQueuedAttachments: boolean;
1682
1697
  foldQueuedPromptsIntoHistory: boolean;
1698
+ announceNextAbortOnEnd?: boolean;
1683
1699
  }): TelegramAgentEndPlan {
1684
1700
  const shouldDispatchNext = shouldDispatchAfterTelegramAgentEnd({
1685
1701
  hasTurn: options.hasTurn,
@@ -1691,6 +1707,7 @@ export function buildTelegramAgentEndPlan(options: {
1691
1707
  kind: "no-turn",
1692
1708
  shouldClearPreview: false,
1693
1709
  shouldDispatchNext,
1710
+ shouldSendAbortMessage: false,
1694
1711
  shouldSendErrorMessage: false,
1695
1712
  shouldSendAttachmentNotice: false,
1696
1713
  };
@@ -1700,6 +1717,7 @@ export function buildTelegramAgentEndPlan(options: {
1700
1717
  kind: "aborted",
1701
1718
  shouldClearPreview: true,
1702
1719
  shouldDispatchNext,
1720
+ shouldSendAbortMessage: options.announceNextAbortOnEnd === true,
1703
1721
  shouldSendErrorMessage: false,
1704
1722
  shouldSendAttachmentNotice: false,
1705
1723
  };
@@ -1709,6 +1727,7 @@ export function buildTelegramAgentEndPlan(options: {
1709
1727
  kind: "error",
1710
1728
  shouldClearPreview: true,
1711
1729
  shouldDispatchNext,
1730
+ shouldSendAbortMessage: false,
1712
1731
  shouldSendErrorMessage: true,
1713
1732
  shouldSendAttachmentNotice: false,
1714
1733
  };
@@ -1718,6 +1737,7 @@ export function buildTelegramAgentEndPlan(options: {
1718
1737
  kind: "text",
1719
1738
  shouldClearPreview: false,
1720
1739
  shouldDispatchNext,
1740
+ shouldSendAbortMessage: false,
1721
1741
  shouldSendErrorMessage: false,
1722
1742
  shouldSendAttachmentNotice: false,
1723
1743
  };
@@ -1727,6 +1747,7 @@ export function buildTelegramAgentEndPlan(options: {
1727
1747
  kind: "attachments-only",
1728
1748
  shouldClearPreview: true,
1729
1749
  shouldDispatchNext,
1750
+ shouldSendAbortMessage: false,
1730
1751
  shouldSendErrorMessage: false,
1731
1752
  shouldSendAttachmentNotice: true,
1732
1753
  };
@@ -1735,6 +1756,7 @@ export function buildTelegramAgentEndPlan(options: {
1735
1756
  kind: "empty",
1736
1757
  shouldClearPreview: true,
1737
1758
  shouldDispatchNext,
1759
+ shouldSendAbortMessage: false,
1738
1760
  shouldSendErrorMessage: false,
1739
1761
  shouldSendAttachmentNotice: false,
1740
1762
  };
@@ -1891,6 +1913,7 @@ export async function handleTelegramAgentEndRuntime<
1891
1913
  hasFinalText: !!finalText || hasOutboundArtifacts,
1892
1914
  hasQueuedAttachments: (turn?.queuedAttachments.length ?? 0) > 0,
1893
1915
  foldQueuedPromptsIntoHistory: deps.foldQueuedPromptsIntoHistory,
1916
+ announceNextAbortOnEnd: turn?.announceNextAbortOnEnd === true,
1894
1917
  });
1895
1918
  if (!turn) {
1896
1919
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
@@ -2017,22 +2040,29 @@ export async function handleTelegramAgentEndRuntime<
2017
2040
  await clearTurnPreview();
2018
2041
  if (!isDeliveryActive()) return;
2019
2042
  }
2020
- if (endPlan.shouldSendErrorMessage) {
2043
+ if (endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage) {
2021
2044
  const errorMessage = assistant.errorMessage ||
2022
2045
  "Telegram bridge: Pi failed while processing the request.";
2023
- const isOperationAborted = errorMessage.trim().replace(/\.$/, "") ===
2024
- "This operation was aborted";
2025
- await deps.sendTextReply(
2026
- turn.chatId,
2027
- turn.replyToMessageId,
2028
- isOperationAborted
2029
- ? "<b>⏹️ This operation was aborted.</b>"
2030
- : errorMessage,
2031
- {
2032
- target: turn.target,
2033
- ...(isOperationAborted ? { parseMode: "HTML" as const } : {}),
2034
- },
2035
- );
2046
+ const isOperationAborted = endPlan.shouldSendAbortMessage ||
2047
+ errorMessage.trim().replace(/\.$/, "") === "This operation was aborted";
2048
+ try {
2049
+ await deps.sendTextReply(
2050
+ turn.chatId,
2051
+ turn.replyToMessageId,
2052
+ isOperationAborted
2053
+ ? "<b>⏹️ This operation was aborted.</b>"
2054
+ : errorMessage,
2055
+ {
2056
+ target: turn.target,
2057
+ ...(isOperationAborted ? { parseMode: "HTML" as const } : {}),
2058
+ },
2059
+ );
2060
+ } catch (error) {
2061
+ if (!isOperationAborted) throw error;
2062
+ deps.recordRuntimeEvent?.("dispatch", error, {
2063
+ phase: "next-abort-announcement",
2064
+ });
2065
+ }
2036
2066
  if (!isDeliveryActive()) return;
2037
2067
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
2038
2068
  return;
@@ -2180,7 +2210,7 @@ export async function handleTelegramAgentEndRuntime<
2180
2210
  };
2181
2211
  if (
2182
2212
  deps.scheduleActiveTurnDelivery &&
2183
- (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)
2213
+ (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)
2184
2214
  ) {
2185
2215
  deps.scheduleActiveTurnDelivery(deliverActiveTurn);
2186
2216
  return;
@@ -3092,6 +3122,9 @@ export interface TelegramQueueDispatchControllerDeps<
3092
3122
  ) => boolean;
3093
3123
  sendUserMessage: TelegramDispatchRuntimeDeps<TContext>["sendUserMessage"];
3094
3124
  onPromptDispatchFailure: (ctx: TContext, message: string) => void;
3125
+ reconcileNextDispatchAnnouncementReplyOwnership?: (
3126
+ item: PendingTelegramTurn,
3127
+ ) => void;
3095
3128
  isQueueItemTransportActive?: (item: TelegramQueueItem<TContext>) => boolean;
3096
3129
  hasPendingInboundQueueMutationForItem?: (
3097
3130
  item: TelegramQueueItem<TContext>,
@@ -3107,6 +3140,7 @@ export interface TelegramQueueDispatchControllerDeps<
3107
3140
  export interface TelegramQueueDispatchController<TContext = unknown> {
3108
3141
  dispatchNext: (ctx: TContext) => void;
3109
3142
  requestNextDispatchAnnouncement: () => void;
3143
+ cancelNextDispatchAnnouncement: () => void;
3110
3144
  }
3111
3145
 
3112
3146
  export function executeTelegramQueueDispatchPlan<TContext = unknown>(
@@ -3161,6 +3195,8 @@ export function createTelegramQueueDispatchRuntime<TContext = unknown>(
3161
3195
  commitPromptDispatch: deps.commitPromptDispatch,
3162
3196
  sendUserMessage: deps.sendUserMessage,
3163
3197
  onPromptDispatchFailure: deps.onPromptDispatchFailure,
3198
+ reconcileNextDispatchAnnouncementReplyOwnership:
3199
+ deps.reconcileNextDispatchAnnouncementReplyOwnership,
3164
3200
  isQueueItemTransportActive: deps.isQueueItemTransportActive,
3165
3201
  hasPendingInboundQueueMutationForItem:
3166
3202
  deps.hasPendingInboundQueueMutationForItem,
@@ -3176,12 +3212,19 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3176
3212
  ): TelegramQueueDispatchController<TContext> {
3177
3213
  let controlDispatchPending = false;
3178
3214
  let nextDispatchAnnouncementRequested = false;
3215
+ let nextDispatchAnnouncementGeneration = 0;
3179
3216
  let nextDispatchAnnouncementAnchor: TelegramQueueItem<TContext> | undefined;
3180
3217
  const controller: TelegramQueueDispatchController<TContext> = {
3181
3218
  requestNextDispatchAnnouncement: () => {
3219
+ nextDispatchAnnouncementGeneration += 1;
3182
3220
  nextDispatchAnnouncementRequested = true;
3183
3221
  nextDispatchAnnouncementAnchor = undefined;
3184
3222
  },
3223
+ cancelNextDispatchAnnouncement: () => {
3224
+ nextDispatchAnnouncementGeneration += 1;
3225
+ nextDispatchAnnouncementRequested = false;
3226
+ nextDispatchAnnouncementAnchor = undefined;
3227
+ },
3185
3228
  dispatchNext: (ctx) => {
3186
3229
  if (deps.hasDispatchContext && !deps.hasDispatchContext()) return;
3187
3230
  if (controlDispatchPending) {
@@ -3393,6 +3436,7 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3393
3436
  if (dispatchPlan.kind === "prompt" && nextDispatchAnnouncementRequested) {
3394
3437
  nextDispatchAnnouncementRequested = false;
3395
3438
  controlDispatchPending = true;
3439
+ const announcementGeneration = nextDispatchAnnouncementGeneration;
3396
3440
  const dispatchGeneration = deps.getDispatchGeneration?.();
3397
3441
  deps.updateStatus(ctx);
3398
3442
  void deps.sendTextReply(
@@ -3401,8 +3445,19 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3401
3445
  "<b>⏩ Dispatching next queued turn.</b>",
3402
3446
  { target: dispatchPlan.item.target },
3403
3447
  ).catch((error) => {
3404
- deps.recordRuntimeEvent?.("dispatch", error, { phase: "next-announcement" });
3448
+ deps.recordRuntimeEvent?.("dispatch", error, {
3449
+ phase: "next-announcement",
3450
+ });
3405
3451
  }).finally(() => {
3452
+ try {
3453
+ deps.reconcileNextDispatchAnnouncementReplyOwnership?.(
3454
+ dispatchPlan.item,
3455
+ );
3456
+ } catch (error) {
3457
+ deps.recordRuntimeEvent?.("dispatch", error, {
3458
+ phase: "next-announcement-reply-ownership",
3459
+ });
3460
+ }
3406
3461
  controlDispatchPending = false;
3407
3462
  if (deps.hasDispatchContext && !deps.hasDispatchContext()) return;
3408
3463
  if (
@@ -3410,6 +3465,10 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3410
3465
  deps.isDispatchGenerationActive &&
3411
3466
  !deps.isDispatchGenerationActive(dispatchGeneration)
3412
3467
  ) return;
3468
+ if (announcementGeneration !== nextDispatchAnnouncementGeneration) {
3469
+ if (nextDispatchAnnouncementRequested) controller.dispatchNext(ctx);
3470
+ return;
3471
+ }
3413
3472
  executePlan();
3414
3473
  });
3415
3474
  return;
@@ -68,6 +68,7 @@ export function createReplyDedupRuntime(): ReplyDedupRuntime {
68
68
  // --- Transport-level dedup ---
69
69
 
70
70
  const lastRepliedToMessageIdByTarget = new Map<string, number>();
71
+ const replyDedupPreservedOnNextReset = new Map<string, number>();
71
72
  let replyDedupGeneration = 0;
72
73
 
73
74
  function getReplyDedupTargetKey(
@@ -83,6 +84,23 @@ function getReplyDedupTargetKey(
83
84
  export function resetTransportReplyDedup(): void {
84
85
  replyDedupGeneration += 1;
85
86
  lastRepliedToMessageIdByTarget.clear();
87
+ for (const [key, messageId] of replyDedupPreservedOnNextReset) {
88
+ lastRepliedToMessageIdByTarget.set(key, messageId);
89
+ }
90
+ replyDedupPreservedOnNextReset.clear();
91
+ }
92
+
93
+ /** Keeps a successfully published transition notice as the first reply of the
94
+ * next agent turn. The following agent-start reset consumes this one-shot
95
+ * preservation, so later messages in that turn do not repeat the reply header. */
96
+ export function preserveTransportReplyDedupOnNextReset(
97
+ chatId: number,
98
+ messageId: number,
99
+ target?: TelegramTarget,
100
+ ): void {
101
+ const key = getReplyDedupTargetKey(chatId, target);
102
+ if (lastRepliedToMessageIdByTarget.get(key) !== messageId) return;
103
+ replyDedupPreservedOnNextReset.set(key, messageId);
86
104
  }
87
105
 
88
106
  export function buildTelegramReplyParameters(
@@ -627,6 +627,7 @@ export interface TelegramInboundRouteRuntimeDeps<
627
627
  isContextActive?: (ctx: TContext) => boolean;
628
628
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
629
629
  requestNextDispatchAnnouncement?: () => void;
630
+ cancelNextDispatchAnnouncement?: () => void;
630
631
  requestDeferredDispatchNextQueuedTelegramTurn?: (
631
632
  dispatch: (ctx: TContext) => void,
632
633
  ) => void;
@@ -2222,6 +2223,10 @@ export function createTelegramInboundRouteRuntime<
2222
2223
  isContextActive: deps.isContextActive,
2223
2224
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
2224
2225
  requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
2226
+ cancelNextTransitionAnnouncements: () => {
2227
+ deps.activeTurnRuntime.clearNextAbortAnnouncement();
2228
+ deps.cancelNextDispatchAnnouncement?.();
2229
+ },
2225
2230
  requestDeferredDispatchNextQueuedTelegramTurn:
2226
2231
  deps.requestDeferredDispatchNextQueuedTelegramTurn,
2227
2232
  startTypingLoop: deps.startTypingLoop,
@@ -2297,6 +2302,8 @@ export function createTelegramInboundRouteRuntime<
2297
2302
  },
2298
2303
  getPromptTemplateCommands,
2299
2304
  sendTextReply: deps.sendTextReply,
2305
+ markActiveTurnNextAbortAnnouncement:
2306
+ deps.activeTurnRuntime.markNextAbortAnnouncement,
2300
2307
  getActiveTurnReply: () => {
2301
2308
  const activeTurn = deps.activeTurnRuntime.get();
2302
2309
  if (!activeTurn) return undefined;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.51.0",
3
+ "version": "0.51.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -45,7 +45,7 @@
45
45
  "@llblab/pi-codex-usage": "0.10.0",
46
46
  "@llblab/pi-grow-loop": "0.8.1",
47
47
  "@llblab/pi-state-flow": "0.18.0",
48
- "@llblab/pi-telegram": "0.51.0",
48
+ "@llblab/pi-telegram": "0.51.1",
49
49
  "@llblab/skills": "1.15.0"
50
50
  },
51
51
  "bundledDependencies": [