@llblab/pi-kit 0.35.0 → 0.35.2

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 (68) hide show
  1. package/BACKLOG.md +2 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +1 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -2
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -6
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +11 -3
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +22 -0
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +75 -16
  12. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +1 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +11 -1
  14. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +30 -19
  15. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
  17. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +61 -46
  18. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +36 -0
  21. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +80 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
  24. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +2 -1
  25. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +18 -3
  26. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +13 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +33 -12
  28. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
  29. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
  30. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
  31. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
  32. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
  33. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
  34. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +11 -0
  35. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +31 -8
  36. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +12 -5
  37. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +21 -3
  38. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +5 -1
  39. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +5 -1
  40. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +26 -13
  41. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +6 -2
  42. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +57 -12
  43. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  44. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
  45. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +6 -2
  46. package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
  47. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +13 -2
  48. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
  49. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +111 -15
  50. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -0
  51. package/node_modules/@llblab/pi-telegram/lib/bus.ts +30 -21
  52. package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
  53. package/node_modules/@llblab/pi-telegram/lib/extension.ts +64 -56
  54. package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
  55. package/node_modules/@llblab/pi-telegram/lib/logging.ts +122 -0
  56. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
  57. package/node_modules/@llblab/pi-telegram/lib/paths.ts +22 -3
  58. package/node_modules/@llblab/pi-telegram/lib/polling.ts +44 -10
  59. package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
  60. package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
  61. package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
  62. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +40 -7
  63. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -10
  64. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +6 -1
  65. package/node_modules/@llblab/pi-telegram/lib/threads.ts +37 -20
  66. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +69 -17
  67. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  68. package/package.json +2 -2
@@ -3741,6 +3741,86 @@ export function isTelegramThreadCleanupJournalNamespaceClear(
3741
3741
  );
3742
3742
  }
3743
3743
 
3744
+ /** Demand-driven recovery only; caller holds temporary-cleanup admission. Never a deletion grant. */
3745
+ export function recoverTelegramThreadCleanupDeadQueueOwners(
3746
+ input: Parameters<typeof isTelegramThreadCleanupJournalNamespaceClear>[0] & {
3747
+ recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
3748
+ isCurrent: () => boolean;
3749
+ withSourceSerialization: NonNullable<TelegramUpdateJournalStoreOptions["withSourceSerialization"]>;
3750
+ getQueueProcessLiveness?: TelegramUpdateJournalStoreOptions["getQueueProcessLiveness"];
3751
+ },
3752
+ ): number {
3753
+ return input.withSourceSerialization(() => {
3754
+ const assertCurrent = () => {
3755
+ if (!input.isCurrent()) throw new Error("Temporary cleanup queue recovery lost authority.");
3756
+ };
3757
+ assertCurrent();
3758
+ if (!input.cleanup) return 0;
3759
+ // Validate exact references and complete namespace coverage before considering any mutation.
3760
+ if (isTelegramThreadCleanupJournalNamespaceClear(input)) return 0;
3761
+ const namespace = inspectTelegramSessionJournalNamespace(input);
3762
+ if (namespace.retainedInputs?.some(value => value.state !== "committed") ||
3763
+ input.requiredJournalBindingKeys.some(key => !namespace.sources.some(source =>
3764
+ source.path === getTelegramUpdateJournalBindingPath(key) && source.evidence.kind === "present"))) return 0;
3765
+ const plans: Array<{ path: string; recovery: TelegramUpdateJournalDeadQueueOwnerRecoveryInput }> = [];
3766
+ for (const source of namespace.sources) {
3767
+ if (source.evidence.kind !== "present") continue;
3768
+ const file = source.evidence.file;
3769
+ if (file.version !== TELEGRAM_UPDATE_JOURNAL_VERSION) continue;
3770
+ const groups = new Map<string, TelegramUpdateJournalEntry[]>();
3771
+ for (const entry of file.entries) {
3772
+ if (!entry.queueReceiptId) continue;
3773
+ const group = groups.get(entry.queueReceiptId) ?? [];
3774
+ group.push(entry);
3775
+ groups.set(entry.queueReceiptId, group);
3776
+ }
3777
+ for (const group of groups.values()) {
3778
+ const entry = group[0]!;
3779
+ if (entry.state !== "queued" || !entry.queueReceiptId || !entry.queueOwner ||
3780
+ !entry.queueKind || entry.queueHandoff || entry.inputClaim) continue;
3781
+ if (group.some(value => value.state !== "queued" || value.queueHandoff || value.inputClaim ||
3782
+ value.queueKind !== entry.queueKind || !isDeepStrictEqual(value.queueOwner, entry.queueOwner))) continue;
3783
+ plans.push({ path: source.path, recovery: {
3784
+ queueKind: entry.queueKind, receiptId: entry.queueReceiptId,
3785
+ sourceUpdateIds: group.map(value => value.updateId).sort((a, b) => a - b),
3786
+ deadOwner: cloneJournalQueueOwner(entry.queueOwner), recoveryOwner: { ...input.recoveryOwner },
3787
+ } });
3788
+ }
3789
+ }
3790
+ const liveness = input.getQueueProcessLiveness ?? getTelegramProcessLiveness;
3791
+ const dead = plans.filter(plan => {
3792
+ assertCurrent();
3793
+ const result = liveness(plan.recovery.deadOwner);
3794
+ assertCurrent();
3795
+ return result === "dead";
3796
+ });
3797
+ let removed = 0;
3798
+ for (const plan of dead) {
3799
+ assertCurrent();
3800
+ removed += input.withSourceReference(plan.path, () => {
3801
+ const journal = createTelegramUpdateJournalStore({
3802
+ path: plan.path, profileName: input.profile, botIdentity: input.botIdentity,
3803
+ queueRuntimeIdentity: input.recoveryOwner,
3804
+ // This private synchronous store stays inside the cross-family serializer already held above.
3805
+ withSourceSerialization(operation) { assertCurrent(); return operation(); },
3806
+ sourceAccess: { directory: dirname(plan.path), limits: input.limits },
3807
+ getQueueProcessLiveness(owner) {
3808
+ assertCurrent();
3809
+ const result = liveness(owner);
3810
+ assertCurrent();
3811
+ return result;
3812
+ },
3813
+ withWriterAdmission(operation) { assertCurrent(); return operation(); },
3814
+ });
3815
+ // The journal transaction rechecks whole-group identity, absence of handoff and fresh process-death proof.
3816
+ return journal.recoverDeadQueueOwner(plan.recovery).recoveredUpdateIds.length;
3817
+ });
3818
+ }
3819
+ assertCurrent();
3820
+ return removed;
3821
+ });
3822
+ }
3823
+
3744
3824
  function cloneEntry(
3745
3825
  entry: TelegramUpdateJournalEntry,
3746
3826
  ): TelegramUpdateJournalEntry {
@@ -23,6 +23,118 @@ import {
23
23
  } from "./paths.ts";
24
24
  import * as Status from "./status.ts";
25
25
 
26
+ /** Sink shape shared with the runtime event recorder this domain already owns. */
27
+ export type TelegramFailureRecordSink = (
28
+ category: string,
29
+ errorOrMessage: unknown,
30
+ details: Record<string, unknown>,
31
+ ) => void;
32
+
33
+ export interface TelegramFailureRunSummary {
34
+ signature: string;
35
+ failedAttempts: number;
36
+ suppressedFailures: number;
37
+ failedForMs: number;
38
+ }
39
+
40
+ export interface TelegramFailureRunLog {
41
+ /** Records one failure; a repeated identical failure only extends the current run and returns false. */
42
+ record: (error: unknown, details?: Record<string, unknown>) => boolean;
43
+ /** Closes the run after a success, emitting its single outcome record. */
44
+ settled: () => TelegramFailureRunSummary | undefined;
45
+ /** Ends the run without an outcome record, for a stop or replacement that reports its own outcome. */
46
+ reset: () => void;
47
+ }
48
+
49
+ export interface TelegramFailureRunLogInput {
50
+ category: string;
51
+ failurePhase: string;
52
+ outcomePhase: string;
53
+ /** Human-readable subject for the outcome record, e.g. `follower heartbeat`. */
54
+ label: string;
55
+ record?: TelegramFailureRecordSink;
56
+ /** Static context reapplied to every record of this phase, e.g. a session or profile key. */
57
+ context?: () => Record<string, unknown>;
58
+ getNowMs?: () => number;
59
+ }
60
+
61
+ /** Stable identity of one failure kind: errno when present, otherwise the failure text. */
62
+ export function getTelegramFailureSignature(error: unknown): string {
63
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
64
+ if (typeof code === "string" && code) return code;
65
+ return error instanceof Error ? error.message : String(error);
66
+ }
67
+
68
+ /**
69
+ * Consecutive identical failure runs for one diagnostic phase: a repeat extends the run instead of logging again,
70
+ * and a closing success or reset emits at most one outcome record. This never gates detection or recovery.
71
+ */
72
+ export function createTelegramFailureRunLog(
73
+ input: TelegramFailureRunLogInput,
74
+ ): TelegramFailureRunLog {
75
+ const getNowMs = input.getNowMs ?? Date.now;
76
+ let run: { signature: string; suppressed: number; startedAtMs: number; details: Record<string, unknown> } | undefined;
77
+ const emit = (errorOrMessage: unknown, details: Record<string, unknown>): void => {
78
+ try {
79
+ input.record?.(input.category, errorOrMessage, {
80
+ ...(input.context?.() ?? {}),
81
+ ...details,
82
+ });
83
+ } catch {
84
+ // A diagnostic sink never converts a recorded failure into another failure.
85
+ }
86
+ };
87
+ return {
88
+ record(error, details) {
89
+ const signature = getTelegramFailureSignature(error);
90
+ const admitted = { ...(details ?? {}) };
91
+ if (run?.signature === signature) {
92
+ run.suppressed += 1;
93
+ Object.assign(run.details, admitted);
94
+ return false;
95
+ }
96
+ const previous = run;
97
+ run = { signature, suppressed: 0, startedAtMs: getNowMs(), details: admitted };
98
+ emit(error, {
99
+ ...admitted,
100
+ phase: input.failurePhase,
101
+ failureSignature: signature,
102
+ ...(previous
103
+ ? {
104
+ previousFailureSignature: previous.signature,
105
+ previousFailedAttempts: previous.suppressed + 1,
106
+ previousFailedForMs: getNowMs() - previous.startedAtMs,
107
+ }
108
+ : {}),
109
+ });
110
+ return true;
111
+ },
112
+ settled() {
113
+ const closed = run;
114
+ if (!closed) return undefined;
115
+ run = undefined;
116
+ const summary: TelegramFailureRunSummary = {
117
+ signature: closed.signature,
118
+ failedAttempts: closed.suppressed + 1,
119
+ suppressedFailures: closed.suppressed,
120
+ failedForMs: getNowMs() - closed.startedAtMs,
121
+ };
122
+ emit(`Telegram ${input.label} recovered after ${summary.failedAttempts} failed attempts.`, {
123
+ ...closed.details,
124
+ phase: input.outcomePhase,
125
+ failureSignature: summary.signature,
126
+ failedAttempts: summary.failedAttempts,
127
+ suppressedFailures: summary.suppressedFailures,
128
+ failedForMs: summary.failedForMs,
129
+ });
130
+ return summary;
131
+ },
132
+ reset() {
133
+ run = undefined;
134
+ },
135
+ };
136
+ }
137
+
26
138
  type TelegramLogPathInput = string | (() => string);
27
139
 
28
140
  interface TelegramRuntimeJsonlEvent {
@@ -391,6 +503,10 @@ interface TelegramRuntimeDiagnosticsRuntime<TContext> {
391
503
  error: unknown,
392
504
  details?: Record<string, unknown>,
393
505
  ): void;
506
+ /** A phase-scoped failure log whose records travel through this runtime's own event pipeline. */
507
+ createFailureLog(
508
+ input: Omit<TelegramFailureRunLogInput, "record">,
509
+ ): TelegramFailureRunLog;
394
510
  bindStorage(ports: {
395
511
  getBotToken(): string | undefined;
396
512
  getProfileName(): string | undefined;
@@ -498,6 +614,12 @@ export function createTelegramRuntimeDiagnosticsRuntime<TContext>(
498
614
  };
499
615
  return {
500
616
  events,
617
+ createFailureLog: (input) =>
618
+ createTelegramFailureRunLog({
619
+ ...input,
620
+ record: (category, errorOrMessage, details) =>
621
+ recordRuntimeEvent(category, errorOrMessage, details),
622
+ }),
501
623
  recordRuntimeEvent,
502
624
  bindStorage(ports) {
503
625
  getBotToken = ports.getBotToken;
@@ -130,6 +130,7 @@ export interface TelegramQueuedOutboundAttachmentView {
130
130
  export interface TelegramOutboundAttachmentQueueTargetView {
131
131
  queuedAttachments: TelegramQueuedOutboundAttachmentView[];
132
132
  guestQueryId?: string;
133
+ guestInlineMessageId?: string;
133
134
  }
134
135
 
135
136
  export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
@@ -718,6 +719,11 @@ export async function queueTelegramOutboundAttachments(options: {
718
719
  maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
719
720
  statPath: options.statPath,
720
721
  });
722
+ if (options.activeTurn.guestInlineMessageId && pendingAttachments.some(
723
+ (attachment) => getTelegramGuestAttachmentTransport(attachment.path).fileField === "voice",
724
+ )) {
725
+ throw new Error("Telegram Guest Mode cannot replace an inline placeholder with a voice note; no attachment was queued. Use MP3 audio or a normal bot chat.");
726
+ }
721
727
  options.activeTurn.queuedAttachments.push(...pendingAttachments);
722
728
  const added = pendingAttachments.map((attachment) => attachment.path);
723
729
  return {
@@ -733,6 +739,13 @@ export async function queueTelegramOutboundAttachments(options: {
733
739
 
734
740
  export async function deliverTelegramGuestCachedAttachment(options: {
735
741
  guestQueryId: string;
742
+ guestInlineMessageId?: string;
743
+ editGuestInlineMessage?: (
744
+ inlineMessageId: string,
745
+ content: { media: { type: "photo" | "document" | "audio"; media: string; caption?: string } },
746
+ options: { assertAuthority: () => void },
747
+ ) => Promise<void>;
748
+ isDeliveryActive?: () => boolean;
736
749
  stagingChatId: number;
737
750
  stagingTarget?: TelegramTarget;
738
751
  attachment: TelegramQueuedOutboundAttachmentView;
@@ -750,6 +763,14 @@ export async function deliverTelegramGuestCachedAttachment(options: {
750
763
  const transport = getTelegramGuestAttachmentTransport(
751
764
  options.attachment.path,
752
765
  );
766
+ const isActive = () => options.isDeliveryActive?.() !== false;
767
+ const assertActive = () => {
768
+ if (!isActive()) throw new Error("Guest attachment delivery lost authority.");
769
+ };
770
+ if (!isActive()) return;
771
+ if (options.guestInlineMessageId && (!options.editGuestInlineMessage || transport.fileField === "voice")) {
772
+ throw new Error("Guest inline attachment editing is unavailable for this media.");
773
+ }
753
774
  let stagingMessageId: number | undefined;
754
775
  let answerAttempted = false;
755
776
  try {
@@ -764,6 +785,7 @@ export async function deliverTelegramGuestCachedAttachment(options: {
764
785
  options.attachment.fileName,
765
786
  )) as TelegramGuestStagingMessage;
766
787
  stagingMessageId = message.message_id;
788
+ if (!isActive()) return;
767
789
  const caption = options.caption
768
790
  ? Array.from(options.caption).slice(0, 1024).join("")
769
791
  : undefined;
@@ -810,17 +832,28 @@ export async function deliverTelegramGuestCachedAttachment(options: {
810
832
  ...(caption ? { caption } : {}),
811
833
  };
812
834
  }
835
+ if (!isActive()) return;
813
836
  answerAttempted = true;
814
- await options.answerGuestQuery(options.guestQueryId, result);
837
+ if (options.guestInlineMessageId && options.editGuestInlineMessage) {
838
+ if (result.type === "voice") throw new Error("Voice notes cannot replace inline placeholders.");
839
+ const media = result.type === "photo" ? result.photo_file_id
840
+ : result.type === "audio" ? result.audio_file_id : result.document_file_id;
841
+ await options.editGuestInlineMessage(options.guestInlineMessageId, {
842
+ media: { type: result.type, media, ...(caption ? { caption } : {}) },
843
+ }, { assertAuthority: assertActive });
844
+ } else {
845
+ await options.answerGuestQuery(options.guestQueryId, result);
846
+ }
815
847
  } catch (error) {
816
- if (!answerAttempted && options.answerGuestText && options.fallbackText) {
848
+ if (!isActive()) return;
849
+ if (!options.guestInlineMessageId && !answerAttempted && options.answerGuestText && options.fallbackText) {
817
850
  answerAttempted = true;
818
851
  await options.answerGuestText(options.guestQueryId, options.fallbackText);
819
852
  } else {
820
853
  throw error;
821
854
  }
822
855
  } finally {
823
- if (stagingMessageId !== undefined) {
856
+ if (stagingMessageId !== undefined && isActive()) {
824
857
  try {
825
858
  await options.deleteMessage(options.stagingChatId, stagingMessageId);
826
859
  } catch (error) {
@@ -167,13 +167,32 @@ export function resolveTelegramProfileTempFilePath(
167
167
  );
168
168
  }
169
169
 
170
- export function getTelegramDiagnosticsDisplayPaths(_profileName?: string): {
170
+ /** Shared state/log paths for every profile, under the resolved agent dir (honors `PI_CODING_AGENT_DIR`). */
171
+ export function getTelegramDiagnosticsDisplayPaths(
172
+ _profileName?: string,
173
+ agentDir = resolveAgentDir(),
174
+ ): {
171
175
  state: string;
172
176
  logs: string;
173
177
  } {
178
+ // Storage paths are canonical, so also match a canonical home (e.g. /home -> /var/home).
179
+ const homes = [homedir()];
180
+ try {
181
+ homes.push(realpathSync(homes[0]!));
182
+ } catch {
183
+ /* An unreadable home only loses the ~ abbreviation. */
184
+ }
185
+ const display = (path: string): string => {
186
+ const home = homes.find(
187
+ (candidate) => path === candidate || path.startsWith(`${candidate}${sep}`),
188
+ );
189
+ return home
190
+ ? `~${path.slice(home.length).split(sep).join("/")}`
191
+ : path;
192
+ };
174
193
  return {
175
- state: "~/.pi/agent/tmp/pi-telegram/state.json",
176
- logs: "~/.pi/agent/tmp/pi-telegram/logs.jsonl",
194
+ state: display(resolveTelegramStatePath(agentDir)),
195
+ logs: display(resolveTelegramRuntimeLogPath(agentDir)),
177
196
  };
178
197
  }
179
198
 
@@ -6,6 +6,16 @@
6
6
 
7
7
  type MaybePromise<T> = T | Promise<T>;
8
8
 
9
+ /**
10
+ * Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
11
+ * again, and the closing success records one outcome. The composition root owns the collapsed implementation.
12
+ */
13
+ export interface TelegramRepeatedFailureLogPort {
14
+ record: (error: unknown, details?: Record<string, unknown>) => void;
15
+ settled: () => void;
16
+ reset: () => void;
17
+ }
18
+
9
19
  export interface TelegramPollingConfig {
10
20
  botToken?: string;
11
21
  }
@@ -682,6 +692,8 @@ export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThread
682
692
  export interface TelegramThreadCapabilityRuntimeDeps<
683
693
  TContext,
684
694
  > extends TelegramThreadCapabilityReaderDeps {
695
+ /** Collapses one sustained probe failure run; the caller supplies its owner. */
696
+ failureLog: TelegramRepeatedFailureLogPort;
685
697
  lifecycle?: TelegramThreadCapabilityLifecycle;
686
698
  topicTargetStore: TelegramThreadCapabilityStore;
687
699
  ownsLock: (ctx: TContext) => boolean;
@@ -803,6 +815,8 @@ export interface TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner>
803
815
  extends
804
816
  TelegramThreadCapabilityReaderDeps,
805
817
  TelegramThreadAwarePollingControls<TContext, TOwner> {
818
+ /** Collapses one sustained probe failure run; the composition root supplies its owner. */
819
+ failureLog: TelegramRepeatedFailureLogPort;
806
820
  state: TelegramThreadCapabilityStateRuntime;
807
821
  topicTargetStore: TelegramThreadCapabilityStore;
808
822
  ownsLock: (ctx: TContext) => boolean;
@@ -882,6 +896,7 @@ export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
882
896
  isTopicModeUnavailableError: deps.isTopicModeUnavailableError,
883
897
  updateStatus: deps.updateStatus,
884
898
  recordEvent: deps.recordEvent,
899
+ failureLog: deps.failureLog,
885
900
  };
886
901
  return {
887
902
  monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
@@ -1269,8 +1284,34 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1269
1284
  let generation = 0;
1270
1285
  let transitionPromise: Promise<void> | undefined;
1271
1286
  let consecutiveDisabledProbes = 0;
1287
+ // An absent leader endpoint repeats one identical probe failure every interval; the port collapses the run.
1288
+ // Running it through one guard keeps a faulty diagnostics port from breaking probes or the interval callback.
1289
+ const capabilityFailureLog: TelegramRepeatedFailureLogPort = {
1290
+ record(error, details) {
1291
+ try {
1292
+ deps.failureLog.record(error, details);
1293
+ } catch {
1294
+ // Monitor diagnostics cannot create an uncaught interval exception.
1295
+ }
1296
+ },
1297
+ settled() {
1298
+ try {
1299
+ deps.failureLog.settled();
1300
+ } catch {
1301
+ // Monitor diagnostics cannot fail a completed probe.
1302
+ }
1303
+ },
1304
+ reset() {
1305
+ try {
1306
+ deps.failureLog.reset();
1307
+ } catch {
1308
+ // Monitor diagnostics cannot fail a monitor stop.
1309
+ }
1310
+ },
1311
+ };
1272
1312
  const stop = (): void => {
1273
1313
  generation += 1;
1314
+ capabilityFailureLog.reset();
1274
1315
  if (interval) clearInterval(interval);
1275
1316
  interval = undefined;
1276
1317
  deps.lifecycle?.invalidate();
@@ -1287,6 +1328,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1287
1328
  tracked = readTelegramThreadCapability(deps)
1288
1329
  .then(async (threadModeEnabled) => {
1289
1330
  if (!isCurrent()) return;
1331
+ capabilityFailureLog.settled();
1290
1332
  if (threadModeEnabled === undefined) {
1291
1333
  if (
1292
1334
  deps.topicTargetStore.getBotState().threadMode !== "enabled" &&
@@ -1357,11 +1399,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1357
1399
  })
1358
1400
  .catch((error) => {
1359
1401
  if (!isCurrent()) return;
1360
- try {
1361
- deps.recordEvent("bus", error, { phase: "capability-monitor" });
1362
- } catch {
1363
- // Monitor diagnostics cannot create an unhandled interval rejection.
1364
- }
1402
+ capabilityFailureLog.record(error);
1365
1403
  })
1366
1404
  .finally(() => {
1367
1405
  if (transitionPromise === tracked) transitionPromise = undefined;
@@ -1384,11 +1422,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1384
1422
  } catch {
1385
1423
  /* Timer shutdown must not escape the callback. */
1386
1424
  }
1387
- try {
1388
- deps.recordEvent("bus", error, { phase: "capability-monitor" });
1389
- } catch {
1390
- // Monitor diagnostics cannot create an uncaught interval exception.
1391
- }
1425
+ capabilityFailureLog.record(error);
1392
1426
  }
1393
1427
  }, intervalMs);
1394
1428
  interval.unref?.();
@@ -1983,13 +1983,18 @@ export interface TelegramAgentEndRuntimeDeps<
1983
1983
  ) => Promise<void>;
1984
1984
  sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
1985
1985
  /** Replaces the early guest ACK with the final text. */
1986
- editGuestReply?: (inlineMessageId: string, markdown: string) => Promise<void>;
1986
+ editGuestReply?: (
1987
+ inlineMessageId: string,
1988
+ markdown: string,
1989
+ options?: { assertAuthority?: () => void },
1990
+ ) => Promise<void>;
1987
1991
  /** Cancels the animated guest placeholder before the final edit. */
1988
1992
  stopGuestPlaceholder?: (inlineMessageId: string) => Promise<void>;
1989
1993
  sendGuestAttachment?: (
1990
1994
  turn: TTurn,
1991
1995
  attachment: QueuedAttachment,
1992
1996
  caption?: string,
1997
+ options?: { isDeliveryActive?: () => boolean },
1993
1998
  ) => Promise<void>;
1994
1999
  sendGuestVoiceReply?: (
1995
2000
  turn: TTurn,
@@ -2350,13 +2355,46 @@ export async function handleTelegramAgentEndRuntime<
2350
2355
  });
2351
2356
  }
2352
2357
  }
2353
- if (turn.guestInlineMessageId && deps.editGuestReply) {
2354
- const experimentText = assistant.errorMessage
2358
+ if (!isDeliveryActive()) return;
2359
+ if (turn.guestInlineMessageId) {
2360
+ let experimentText = assistant.errorMessage
2355
2361
  ? "Telegram bridge: Pi failed while processing the request."
2356
2362
  : finalText;
2363
+ const [attachment] = turn.queuedAttachments;
2364
+ if (!assistant.errorMessage && attachment) {
2365
+ try {
2366
+ if (!deps.sendGuestAttachment) throw new Error("Guest attachment delivery is unavailable.");
2367
+ await deps.sendGuestAttachment(turn, attachment, finalText || undefined, { isDeliveryActive });
2368
+ if (isDeliveryActive() && endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
2369
+ return;
2370
+ } catch (error) {
2371
+ deps.recordRuntimeEvent?.("delivery", error, {
2372
+ phase: "guest-attachment", guestQueryId: turn.guestQueryId,
2373
+ });
2374
+ if (!isDeliveryActive()) return;
2375
+ if (isTelegramApiCommitUnknownError(error)) {
2376
+ if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
2377
+ return;
2378
+ }
2379
+ experimentText = "Telegram bridge could not deliver the requested attachment. Please retry in a normal bot chat.";
2380
+ }
2381
+ } else if (!assistant.errorMessage && hasOutboundArtifacts) {
2382
+ experimentText = [finalText, "Telegram Guest Mode cannot replace an inline placeholder with a voice note. Please request MP3 audio or use a normal bot chat."].filter(Boolean).join("\n\n");
2383
+ }
2384
+ if (!deps.editGuestReply) {
2385
+ deps.recordRuntimeEvent?.("delivery", new Error("Guest inline reply editing is unavailable."), {
2386
+ phase: "guest-ack-edit", guestQueryId: turn.guestQueryId,
2387
+ });
2388
+ if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
2389
+ return;
2390
+ }
2357
2391
  if (experimentText) {
2358
2392
  try {
2359
- await deps.editGuestReply(turn.guestInlineMessageId, experimentText);
2393
+ await deps.editGuestReply(turn.guestInlineMessageId, experimentText, {
2394
+ assertAuthority() {
2395
+ if (!isDeliveryActive()) throw new Error("Guest reply edit lost authority.");
2396
+ },
2397
+ });
2360
2398
  deps.recordRuntimeEvent?.(
2361
2399
  "guest",
2362
2400
  new Error("Guest ACK edited the guest answer"),
@@ -2401,6 +2439,7 @@ export async function handleTelegramAgentEndRuntime<
2401
2439
  turn,
2402
2440
  guestAttachment,
2403
2441
  finalText || undefined,
2442
+ { isDeliveryActive },
2404
2443
  );
2405
2444
  } catch (error) {
2406
2445
  deps.recordRuntimeEvent?.("delivery", error, {
@@ -1033,13 +1033,14 @@ export function createGuestMarkdownReplyEditor(deps: {
1033
1033
  editGuestInlineMessage: (
1034
1034
  inlineMessageId: string,
1035
1035
  content: { richMessage?: TelegramInputRichMessage; text?: string },
1036
+ options?: { assertAuthority?: () => void },
1036
1037
  ) => Promise<void>;
1037
1038
  }) {
1038
- return async (inlineMessageId: string, markdown: string) => {
1039
+ return async (inlineMessageId: string, markdown: string, options?: { assertAuthority?: () => void }) => {
1039
1040
  const [richMarkdown = markdown] = splitTelegramNativeMarkdown(markdown);
1040
1041
  await deps.editGuestInlineMessage(inlineMessageId, {
1041
1042
  richMessage: { markdown: richMarkdown },
1042
- });
1043
+ }, options);
1043
1044
  };
1044
1045
  }
1045
1046