@llblab/pi-kit 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +5 -5
  3. package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-actors/README.md +1 -1
  6. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
  7. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
  8. package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
  9. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
  10. package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
  11. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
  12. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
  13. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
  14. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
  15. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
  16. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
  17. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
  18. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
  19. package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
  20. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
  21. package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
  22. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
  23. package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
  24. package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
  25. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
  26. package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
  27. package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
  28. package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
  29. package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
  30. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
  31. package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
  32. package/node_modules/@llblab/pi-actors/package.json +1 -1
  33. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
  34. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +2 -2
  35. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
  36. package/node_modules/@llblab/pi-grow-loop/README.md +6 -6
  37. package/node_modules/@llblab/pi-grow-loop/index.ts +6 -3
  38. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  39. package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
  40. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
  41. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
  42. package/node_modules/@llblab/pi-state-flow/README.md +203 -107
  43. package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
  44. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
  45. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
  46. package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
  47. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
  48. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
  49. package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
  50. package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
  51. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
  52. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
  53. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
  54. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
  55. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
  56. package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
  57. package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
  58. package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
  59. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
  60. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
  61. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
  62. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
  63. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
  64. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
  65. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
  66. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
  67. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
  68. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
  69. package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
  70. package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
  71. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
  72. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
  73. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
  74. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
  75. package/node_modules/@llblab/pi-state-flow/package.json +8 -2
  76. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
  77. package/node_modules/@llblab/pi-telegram/AGENTS.md +14 -9
  78. package/node_modules/@llblab/pi-telegram/BACKLOG.md +20 -4
  79. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +22 -5
  80. package/node_modules/@llblab/pi-telegram/README.md +13 -9
  81. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
  82. package/node_modules/@llblab/pi-telegram/docs/architecture.md +220 -18
  83. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  84. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
  85. package/node_modules/@llblab/pi-telegram/docs/outbound.md +13 -7
  86. package/node_modules/@llblab/pi-telegram/docs/public-api.md +13 -5
  87. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
  88. package/node_modules/@llblab/pi-telegram/index.ts +4 -1415
  89. package/node_modules/@llblab/pi-telegram/lib/activity.ts +19 -5
  90. package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
  91. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +37 -2
  92. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
  93. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
  94. package/node_modules/@llblab/pi-telegram/lib/bus.ts +350 -26
  95. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +544 -0
  96. package/node_modules/@llblab/pi-telegram/lib/commands.ts +234 -11
  97. package/node_modules/@llblab/pi-telegram/lib/config.ts +178 -25
  98. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +18 -18
  99. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1792 -0
  100. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
  101. package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
  102. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +7 -1
  103. package/node_modules/@llblab/pi-telegram/lib/locks.ts +38 -1
  104. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +154 -15
  105. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +74 -40
  106. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +28 -42
  107. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +18 -14
  108. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
  109. package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
  110. package/node_modules/@llblab/pi-telegram/lib/preview.ts +115 -70
  111. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +5 -2
  112. package/node_modules/@llblab/pi-telegram/lib/queue.ts +66 -22
  113. package/node_modules/@llblab/pi-telegram/lib/replies.ts +47 -39
  114. package/node_modules/@llblab/pi-telegram/lib/routing.ts +305 -112
  115. package/node_modules/@llblab/pi-telegram/lib/status.ts +10 -0
  116. package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
  117. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +315 -7
  118. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
  119. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
  120. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
  121. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
  122. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
  123. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
  124. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
  125. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
  126. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  127. package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
  128. package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
  129. package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
  130. package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
  131. package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
  132. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
  133. package/node_modules/@llblab/skills/abcd-context/AGENTS.md +1 -0
  134. package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +6 -2
  135. package/node_modules/@llblab/skills/abcd-context/SKILL.md +1 -1
  136. package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +11 -5
  137. package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +61 -0
  138. package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +67 -0
  139. package/node_modules/@llblab/skills/package.json +2 -3
  140. package/node_modules/@llblab/skills/release-flow/SKILL.md +2 -4
  141. package/package.json +7 -6
@@ -37,9 +37,15 @@ export function setResetTransportReplyDedup(fn: () => void): void {
37
37
 
38
38
  export function createAgentStartDedupHook(
39
39
  inner: (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>,
40
+ schedulePublication?: (task: () => Promise<void>) => void,
40
41
  ): (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void> {
41
42
  return async (event, ctx) => {
42
- if (resetTransportReplyDedupFn) resetTransportReplyDedupFn();
43
+ const reset = resetTransportReplyDedupFn;
44
+ if (reset) {
45
+ // A new turn must not erase the anchor of a final still ahead in the FIFO.
46
+ if (schedulePublication) schedulePublication(async () => { reset(); });
47
+ else reset();
48
+ }
43
49
  return inner(event, ctx);
44
50
  };
45
51
  }
@@ -1108,6 +1108,7 @@ export function createTelegramDirectDeliveryOwnershipChecker<
1108
1108
  export interface TelegramLockedPollingStartOptions {
1109
1109
  force?: boolean;
1110
1110
  forceFreshLeaderThread?: boolean;
1111
+ requestedThreadName?: string;
1111
1112
  election?: { expectedOwner?: TelegramLockEntry };
1112
1113
  onAcquired?: () => Promise<void> | void;
1113
1114
  }
@@ -1131,6 +1132,10 @@ export interface TelegramLockedPollingRuntime<
1131
1132
  ctx: TContext,
1132
1133
  owner: TelegramLockEntry,
1133
1134
  ) => boolean | undefined | Promise<boolean | undefined>;
1135
+ restoreFollowerWithOwner?: (
1136
+ ctx: TContext,
1137
+ owner: TelegramLockEntry,
1138
+ ) => boolean | undefined | Promise<boolean | undefined>;
1134
1139
  stopFollowerRegistration?: () => void;
1135
1140
  }
1136
1141
 
@@ -1151,6 +1156,10 @@ export interface TelegramLockedPollingRuntimeDeps<
1151
1156
  ctx: TContext,
1152
1157
  owner: TelegramLockEntry,
1153
1158
  ) => boolean | undefined | Promise<boolean | undefined>;
1159
+ restoreFollowerWithOwner?: (
1160
+ ctx: TContext,
1161
+ owner: TelegramLockEntry,
1162
+ ) => boolean | undefined | Promise<boolean | undefined>;
1154
1163
  stopFollowerRegistration?: () => void;
1155
1164
  onTransportAvailabilityChanged?: () => void;
1156
1165
  transportMonitor?: { start: (ctx: TContext) => void; stop: () => void };
@@ -1198,6 +1207,7 @@ export function createTelegramLockedPollingRuntime<
1198
1207
  stopOwnershipWatcher();
1199
1208
  if (sessionAutoStartRun) {
1200
1209
  await sessionAutoStartRun;
1210
+ deps.stopFollowerRegistration?.();
1201
1211
  }
1202
1212
  if (ownershipStop) {
1203
1213
  await ownershipStop;
@@ -1452,10 +1462,14 @@ export function createTelegramLockedPollingRuntime<
1452
1462
  const canHandoffSameProcess =
1453
1463
  state?.kind === "active-here" &&
1454
1464
  (!state.lock.cwd || state.lock.cwd === ctx.cwd);
1465
+ const canRestoreRememberedFollower =
1466
+ state?.kind === "active-elsewhere" &&
1467
+ deps.restoreFollowerWithOwner !== undefined;
1455
1468
  if (
1456
1469
  !ownsCurrentLock &&
1457
1470
  !canResumeStaleSameCwd &&
1458
- !canHandoffSameProcess
1471
+ !canHandoffSameProcess &&
1472
+ !canRestoreRememberedFollower
1459
1473
  ) {
1460
1474
  return;
1461
1475
  }
@@ -1466,11 +1480,27 @@ export function createTelegramLockedPollingRuntime<
1466
1480
  const startedAtMs = Date.now();
1467
1481
  deps.recordRuntimeEvent?.("lock", "Telegram auto-start scheduled", {
1468
1482
  phase: "auto-start-scheduled",
1483
+ mode: canRestoreRememberedFollower ? "follower-restore" : "leader",
1469
1484
  });
1470
1485
  const run = (async () => {
1471
1486
  await new Promise((resolve) => setTimeout(resolve, 0));
1472
1487
  if (ownershipStop) await ownershipStop;
1473
1488
  if (!isCurrent()) return;
1489
+ if (canRestoreRememberedFollower && state?.kind === "active-elsewhere") {
1490
+ const restored = await deps.restoreFollowerWithOwner?.(
1491
+ ctx,
1492
+ state.lock,
1493
+ );
1494
+ if (!isCurrent() || !restored) return;
1495
+ deps.onTransportAvailabilityChanged?.();
1496
+ deps.updateStatus(ctx);
1497
+ deps.recordRuntimeEvent?.(
1498
+ "bus",
1499
+ "Telegram follower auto-connect completed",
1500
+ { phase: "follower-auto-connect" },
1501
+ );
1502
+ return;
1503
+ }
1474
1504
  if (canResumeStaleSameCwd || canHandoffSameProcess) {
1475
1505
  const acquired = deps.lock.acquire(
1476
1506
  ctx,
@@ -1505,6 +1535,13 @@ export function createTelegramLockedPollingRuntime<
1505
1535
  return registered === true;
1506
1536
  }
1507
1537
  : undefined,
1538
+ restoreFollowerWithOwner: deps.restoreFollowerWithOwner
1539
+ ? async (ctx, owner) => {
1540
+ const restored = await deps.restoreFollowerWithOwner?.(ctx, owner);
1541
+ if (restored) deps.updateStatus(ctx);
1542
+ return restored === true;
1543
+ }
1544
+ : undefined,
1508
1545
  stopFollowerRegistration: deps.stopFollowerRegistration,
1509
1546
  };
1510
1547
  }
@@ -8,6 +8,7 @@ import type {
8
8
  TelegramActivityVerbosity,
9
9
  TelegramAssistantRenderingMode,
10
10
  TelegramTimeMode,
11
+ TelegramThreadDisplayMode,
11
12
  } from "./config.ts";
12
13
  import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
13
14
  import type { TelegramModelMenuState } from "./menu-model.ts";
@@ -21,6 +22,7 @@ import type { TelegramVoiceReplyMode } from "./voice.ts";
21
22
  export type TelegramSettingsMenuReplyMarkup = TelegramInlineKeyboardMarkup;
22
23
 
23
24
  export interface TelegramSettingsStateDeps {
25
+ getThreadDisplayMode?: () => TelegramThreadDisplayMode | undefined;
24
26
  areDraftPreviewsEnabled: () => boolean;
25
27
  getAssistantRenderingMode: () => TelegramAssistantRenderingMode;
26
28
  getActivityVerbosity: () => TelegramActivityVerbosity;
@@ -31,6 +33,7 @@ export interface TelegramSettingsStateDeps {
31
33
  }
32
34
 
33
35
  export interface TelegramSettingsMutationDeps extends TelegramSettingsStateDeps {
36
+ setThreadDisplayMode?: (mode: TelegramThreadDisplayMode) => Promise<void>;
34
37
  setDraftPreviewsEnabled: (enabled: boolean) => Promise<void>;
35
38
  setAssistantRenderingMode: (
36
39
  mode: TelegramAssistantRenderingMode,
@@ -43,6 +46,11 @@ export interface TelegramSettingsMutationDeps extends TelegramSettingsStateDeps
43
46
  ) => Promise<void>;
44
47
  setTimeInjectionMode: (mode: TelegramTimeMode) => Promise<void>;
45
48
  setAutomaticThreadCleanupEnabled: (enabled: boolean) => Promise<void>;
49
+ reviewInactiveThreads?: () => Promise<{ count: number; operationId?: string }>;
50
+ cleanInactiveThreads?: (operationId: string) => Promise<{
51
+ deleted: number; outcomeUnknown: number; blocked?: number;
52
+ recovery?: "commit-ready" | "deletion-outcome-unknown" | "authority-blocked";
53
+ }>;
46
54
  }
47
55
 
48
56
  export interface TelegramSettingsMenuOpenDeps<
@@ -137,6 +145,7 @@ export interface TelegramSettingsMenuRuntimeDeps<
137
145
  export const SETTINGS_MENU_TITLE = "<b>⚙️ Settings:</b>";
138
146
  export const AUTOMATIC_THREAD_CLEANUP_SETTINGS_TITLE =
139
147
  "<b>🧹 Thread cleanup:</b>";
148
+ export const INACTIVE_THREAD_REVIEW_TITLE = "<b>🔎 Inactive tabs review:</b>";
140
149
  export const DRAFT_PREVIEWS_SETTINGS_TITLE = "<b>📝 Draft previews:</b>";
141
150
  export const ASSISTANT_RENDERING_SETTINGS_TITLE =
142
151
  "<b>🧾 Assistant rendering:</b>";
@@ -145,6 +154,7 @@ export const ACTIVITY_VERBOSITY_SETTINGS_TITLE =
145
154
  export const TIME_INJECTION_MODE_SETTINGS_TITLE =
146
155
  "<b>🕒 Time injection mode:</b>";
147
156
  export const VOICE_REPLY_MODE_SETTINGS_TITLE = "<b>👄 Voice reply mode:</b>";
157
+ export const THREAD_DISPLAY_SETTINGS_TITLE = "<b>🧵 Thread display:</b>";
148
158
 
149
159
  function getVoiceReplyModeLabel(mode: TelegramVoiceReplyMode): string {
150
160
  return mode;
@@ -165,6 +175,18 @@ export function buildTelegramSettingsMenuText(): string {
165
175
  return SETTINGS_MENU_TITLE;
166
176
  }
167
177
 
178
+ export function buildThreadDisplaySettingsText(mode: TelegramThreadDisplayMode): string {
179
+ return [
180
+ `${THREAD_DISPLAY_SETTINGS_TITLE} <code>${mode}</code>`,
181
+ "",
182
+ "Choose how this bot profile labels Telegram tabs and Pi terminal status. Each slot is unique across this bot profile.",
183
+ "",
184
+ "<code>-</code> <code>letters</code> (default): show the unique slot, such as <b><i>A</i></b> or <b><i>B</i></b>.",
185
+ "<code>-</code> <code>directories</code>: show the directory, such as <b><i>extensions</i></b>; shared Workspaces keep slot suffixes, such as <b><i>extensions_a</i></b> and <b><i>extensions_c</i></b>.",
186
+ "A manual <code>/name Name</code> overrides this Thread display name until reset.",
187
+ ].join("\n");
188
+ }
189
+
168
190
  export function buildAutomaticThreadCleanupSettingsText(
169
191
  enabled: boolean,
170
192
  ): string {
@@ -175,17 +197,43 @@ export function buildAutomaticThreadCleanupSettingsText(
175
197
  "",
176
198
  "<code>-</code> <code>on</code> (default): delete the bound thread and release Telegram authority on graceful quit.",
177
199
  "<code>-</code> <code>off</code>: preserve the tab as a restart hint; manual <code>/telegram-disconnect</code> still confirms and deletes it.",
200
+ "",
201
+ "Review inactive tabs checks current owner and work evidence. Review never deletes tabs.",
178
202
  ].join("\n");
179
203
  }
180
204
 
205
+ export function buildInactiveThreadReviewText(count: number): string {
206
+ return [INACTIVE_THREAD_REVIEW_TITLE, "",
207
+ `${count} proven inactive tab${count === 1 ? "" : "s"}.`,
208
+ "No tabs were deleted.",
209
+ "Deletion requires a separate confirmed Clean action.",
210
+ ].join("\n");
211
+ }
212
+
213
+ export function buildInactiveThreadReviewReplyMarkup(
214
+ operationId?: string,
215
+ canCleanInactiveThreads = false,
216
+ ): TelegramSettingsMenuReplyMarkup {
217
+ const validOperationId = typeof operationId === "string" &&
218
+ /^thread-cleanup:[a-f0-9]{32}$/u.test(operationId);
219
+ return { inline_keyboard: [
220
+ [{ text: "⬆️ Back to Thread cleanup",
221
+ callback_data: "settings:open:automatic-thread-cleanup" }],
222
+ ...(canCleanInactiveThreads && validOperationId ? [[{
223
+ text: "🧹 Clean inactive tabs",
224
+ callback_data: `settings:clean:${operationId}`,
225
+ }]] : []),
226
+ ] };
227
+ }
228
+
181
229
  export function buildDraftPreviewsSettingsText(enabled: boolean): string {
182
230
  return [
183
231
  `${DRAFT_PREVIEWS_SETTINGS_TITLE} <code>${enabled ? "on" : "off"}</code>`,
184
232
  "",
185
233
  "Show live answer drafts while the model is answering.",
186
234
  "",
187
- "<code>-</code> <code>on</code>: stream safe Telegram Rich Draft frames before the final answer.",
188
- "<code>-</code> <code>off</code> (default): show native active status, then send one final answer.",
235
+ "<code>-</code> <code>on</code> (default): stream safe Telegram Rich Draft frames before the final answer.",
236
+ "<code>-</code> <code>off</code>: show native active status, then send one final answer.",
189
237
  ].join("\n");
190
238
  }
191
239
 
@@ -259,6 +307,7 @@ export function buildTelegramSettingsMenuReplyMarkup(
259
307
  voiceReplyModeConfigured = true,
260
308
  automaticThreadCleanupEnabled = true,
261
309
  activityVerbosity: TelegramActivityVerbosity = "quiet",
310
+ threadDisplayMode?: TelegramThreadDisplayMode,
262
311
  ): TelegramSettingsMenuReplyMarkup {
263
312
  const hasRenderingMode =
264
313
  assistantRenderingModeOrVoiceReplyMode === "rich" ||
@@ -317,6 +366,10 @@ export function buildTelegramSettingsMenuReplyMarkup(
317
366
  callback_data: "settings:open:automatic-thread-cleanup",
318
367
  },
319
368
  ];
369
+ if (threadDisplayMode) settingsButtons.push({
370
+ text: `🧵 Thread display: ${threadDisplayMode}`,
371
+ callback_data: "settings:open:thread-display",
372
+ });
320
373
  if (sectionRegistry) {
321
374
  const extensionRows = getTelegramExtensionSettingsRows(sectionRegistry);
322
375
  settingsButtons.push(
@@ -349,6 +402,7 @@ export async function openTelegramSettingsMenu<
349
402
  deps.isVoiceReplyModeConfigured(),
350
403
  deps.isAutomaticThreadCleanupEnabled(),
351
404
  deps.getActivityVerbosity(),
405
+ deps.getThreadDisplayMode?.(),
352
406
  ),
353
407
  );
354
408
  if (messageId === undefined) return;
@@ -357,8 +411,19 @@ export async function openTelegramSettingsMenu<
357
411
  deps.storeModelMenuState(state);
358
412
  }
359
413
 
414
+ export function buildThreadDisplaySettingsReplyMarkup(mode: TelegramThreadDisplayMode): TelegramSettingsMenuReplyMarkup {
415
+ return { inline_keyboard: [
416
+ [{ text: "⬆️ Back", callback_data: "settings:list" }],
417
+ ...(["letters", "directories"] as const).map((value) => [{
418
+ text: `${mode === value ? "🟢 " : ""}${value}`,
419
+ callback_data: `settings:set:thread-display:${value}`,
420
+ }]),
421
+ ] };
422
+ }
423
+
360
424
  export function buildAutomaticThreadCleanupSettingsReplyMarkup(
361
425
  enabled: boolean,
426
+ canReviewInactiveThreads = false,
362
427
  ): TelegramSettingsMenuReplyMarkup {
363
428
  return {
364
429
  inline_keyboard: [
@@ -373,6 +438,10 @@ export function buildAutomaticThreadCleanupSettingsReplyMarkup(
373
438
  callback_data: "settings:set:automatic-thread-cleanup:off",
374
439
  },
375
440
  ],
441
+ ...(canReviewInactiveThreads ? [[{
442
+ text: "🔎 Review inactive tabs",
443
+ callback_data: "settings:review:inactive-threads",
444
+ }]] : []),
376
445
  ],
377
446
  };
378
447
  }
@@ -417,21 +486,16 @@ export function buildAssistantRenderingSettingsReplyMarkup(
417
486
  export function buildActivityVerbositySettingsReplyMarkup(
418
487
  verbosity: TelegramActivityVerbosity,
419
488
  ): TelegramSettingsMenuReplyMarkup {
420
- const values: TelegramActivityVerbosity[] = [
421
- "quiet",
422
- "thinking",
423
- "tools",
424
- "verbose",
425
- ];
489
+ const button = (value: TelegramActivityVerbosity) => ({
490
+ text: `${value === verbosity ? "🟢 " : ""}${value}`,
491
+ callback_data: `settings:set:activity-verbosity:${value}`,
492
+ });
426
493
  return {
427
494
  inline_keyboard: [
428
495
  [{ text: "⬆️ Back", callback_data: "settings:list" }],
429
- ...values.map((value) => [
430
- {
431
- text: `${value === verbosity ? "🟢 " : ""}${value}`,
432
- callback_data: `settings:set:activity-verbosity:${value}`,
433
- },
434
- ]),
496
+ [button("quiet")],
497
+ [button("thinking"), button("tools")],
498
+ [button("verbose")],
435
499
  ],
436
500
  };
437
501
  }
@@ -487,6 +551,7 @@ export async function updateTelegramSettingsMenuMessage(
487
551
  deps.isVoiceReplyModeConfigured(),
488
552
  deps.isAutomaticThreadCleanupEnabled(),
489
553
  deps.getActivityVerbosity(),
554
+ deps.getThreadDisplayMode?.(),
490
555
  ),
491
556
  );
492
557
  }
@@ -497,7 +562,7 @@ export async function updateAutomaticThreadCleanupSettingsMessage(
497
562
  const enabled = deps.isAutomaticThreadCleanupEnabled();
498
563
  await deps.updateSettingsMessage(
499
564
  buildAutomaticThreadCleanupSettingsText(enabled),
500
- buildAutomaticThreadCleanupSettingsReplyMarkup(enabled),
565
+ buildAutomaticThreadCleanupSettingsReplyMarkup(enabled, !!deps.reviewInactiveThreads),
501
566
  );
502
567
  }
503
568
 
@@ -558,6 +623,36 @@ export async function handleTelegramSettingsMenuCallbackAction(
558
623
  deps: TelegramSettingsMenuCallbackDeps,
559
624
  ): Promise<boolean> {
560
625
  if (!data?.startsWith("settings:")) return false;
626
+ if (data === "settings:open:thread-display" || data.startsWith("settings:set:thread-display:")) {
627
+ if (!deps.getThreadDisplayMode?.() || !deps.setThreadDisplayMode) {
628
+ await deps.answerCallbackQuery(callbackQueryId, "Thread display requires Threaded Mode and a connected instance.");
629
+ return true;
630
+ }
631
+ if (data.startsWith("settings:set:thread-display:")) {
632
+ const mode = data.slice("settings:set:thread-display:".length);
633
+ if (mode !== "letters" && mode !== "names" && mode !== "directories") {
634
+ await deps.answerCallbackQuery(callbackQueryId, "Unknown Thread display mode.");
635
+ return true;
636
+ }
637
+ try {
638
+ await deps.setThreadDisplayMode(mode);
639
+ } catch {
640
+ await deps.answerCallbackQuery(callbackQueryId, "Thread display was not fully applied. The preference may be saved; retry after checking the leader.");
641
+ return true;
642
+ }
643
+ }
644
+ const mode = deps.getThreadDisplayMode();
645
+ if (!mode) {
646
+ await deps.answerCallbackQuery(callbackQueryId, "Threaded Mode is no longer available.");
647
+ return true;
648
+ }
649
+ await deps.updateSettingsMessage(
650
+ buildThreadDisplaySettingsText(mode),
651
+ buildThreadDisplaySettingsReplyMarkup(mode),
652
+ );
653
+ await deps.answerCallbackQuery(callbackQueryId);
654
+ return true;
655
+ }
561
656
  if (data === "settings:list") {
562
657
  await updateTelegramSettingsMenuMessage(deps, deps.sectionRegistry);
563
658
  await deps.answerCallbackQuery(callbackQueryId);
@@ -692,6 +787,44 @@ export async function handleTelegramSettingsMenuCallbackAction(
692
787
  return true;
693
788
  }
694
789
  }
790
+ if (data === "settings:review:inactive-threads") {
791
+ if (!deps.reviewInactiveThreads) {
792
+ await deps.answerCallbackQuery(callbackQueryId, "Inactive tab review is unavailable.");
793
+ return true;
794
+ }
795
+ try {
796
+ const review = await deps.reviewInactiveThreads();
797
+ if (review.count === 0) {
798
+ await deps.answerCallbackQuery(callbackQueryId, "No proven inactive tabs.");
799
+ } else {
800
+ await deps.updateSettingsMessage(buildInactiveThreadReviewText(review.count),
801
+ buildInactiveThreadReviewReplyMarkup(review.operationId, !!deps.cleanInactiveThreads));
802
+ await deps.answerCallbackQuery(callbackQueryId, "Review prepared. No tabs were deleted.");
803
+ }
804
+ } catch {
805
+ await deps.answerCallbackQuery(callbackQueryId, "Could not safely review inactive tabs.");
806
+ }
807
+ return true;
808
+ }
809
+ if (data.startsWith("settings:clean:")) {
810
+ const operationId = data.slice("settings:clean:".length);
811
+ if (!/^thread-cleanup:[a-f0-9]{32}$/u.test(operationId) || !deps.cleanInactiveThreads) {
812
+ await deps.answerCallbackQuery(callbackQueryId, "Cleanup confirmation is unavailable or stale.");
813
+ return true;
814
+ }
815
+ try {
816
+ const result = await deps.cleanInactiveThreads(operationId);
817
+ await deps.answerCallbackQuery(callbackQueryId,
818
+ `Deleted: ${result.deleted}. Outcome unknown: ${result.outcomeUnknown}.` +
819
+ (result.blocked ? ` Blocked: ${result.blocked}.` : "") +
820
+ (result.recovery === "commit-ready" ? " Recovery: safe local commit pending." :
821
+ result.recovery === "deletion-outcome-unknown" ? " Recovery: deletion outcome unknown; no retry." :
822
+ result.recovery === "authority-blocked" ? " Recovery: cleanup authority unavailable." : ""));
823
+ } catch {
824
+ await deps.answerCallbackQuery(callbackQueryId, "Cleanup could not be safely completed.");
825
+ }
826
+ return true;
827
+ }
695
828
  if (
696
829
  data === "settings:set:automatic-thread-cleanup:on" ||
697
830
  data === "settings:set:automatic-thread-cleanup:off"
@@ -729,6 +862,7 @@ export function createTelegramSettingsMenuRuntime<
729
862
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
730
863
  getTimeInjectionMode: deps.getTimeInjectionMode,
731
864
  isAutomaticThreadCleanupEnabled: deps.isAutomaticThreadCleanupEnabled,
865
+ getThreadDisplayMode: deps.getThreadDisplayMode,
732
866
  sendSettingsMenu: (state, text, replyMarkup) =>
733
867
  deps.sendInteractiveMessage(
734
868
  state.chatId,
@@ -752,6 +886,7 @@ export function createTelegramSettingsMenuRuntime<
752
886
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
753
887
  getTimeInjectionMode: deps.getTimeInjectionMode,
754
888
  isAutomaticThreadCleanupEnabled: deps.isAutomaticThreadCleanupEnabled,
889
+ getThreadDisplayMode: deps.getThreadDisplayMode,
755
890
  updateSettingsMessage: (text, replyMarkup) =>
756
891
  deps.editInteractiveMessage(
757
892
  state.chatId,
@@ -795,12 +930,16 @@ export function createTelegramSettingsMenuRuntime<
795
930
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
796
931
  getTimeInjectionMode: deps.getTimeInjectionMode,
797
932
  isAutomaticThreadCleanupEnabled: deps.isAutomaticThreadCleanupEnabled,
933
+ getThreadDisplayMode: deps.getThreadDisplayMode,
934
+ setThreadDisplayMode: deps.setThreadDisplayMode,
798
935
  setDraftPreviewsEnabled: deps.setDraftPreviewsEnabled,
799
936
  setAssistantRenderingMode: deps.setAssistantRenderingMode,
800
937
  setActivityVerbosity: deps.setActivityVerbosity,
801
938
  setVoiceReplyMode: deps.setVoiceReplyMode,
802
939
  setTimeInjectionMode: deps.setTimeInjectionMode,
803
940
  setAutomaticThreadCleanupEnabled: deps.setAutomaticThreadCleanupEnabled,
941
+ ...(deps.reviewInactiveThreads ? { reviewInactiveThreads: deps.reviewInactiveThreads } : {}),
942
+ ...(deps.cleanInactiveThreads ? { cleanInactiveThreads: deps.cleanInactiveThreads } : {}),
804
943
  updateSettingsMessage: (text, replyMarkup) =>
805
944
  deps.editInteractiveMessage(
806
945
  state.chatId,
@@ -21,7 +21,7 @@ import {
21
21
  TELEGRAM_MESSAGE_PROMPT_SNIPPET,
22
22
  } from "./prompts.ts";
23
23
  import {
24
- buildTelegramMultipartReplyParameters,
24
+ withTelegramReplyParameters,
25
25
  normalizeTelegramNativeMarkdown,
26
26
  } from "./replies.ts";
27
27
  import {
@@ -99,6 +99,11 @@ export interface TelegramOutboundMessageToolRegistrationDeps extends TelegramOut
99
99
  markdown: string,
100
100
  options?: { replyMarkup?: unknown; target?: TelegramTarget },
101
101
  ) => Promise<number | undefined>;
102
+ sendChannelMarkdownMessage?: (
103
+ channel: number | string,
104
+ markdown: string,
105
+ options: { operationId: string; replyMarkup?: unknown },
106
+ ) => Promise<number | undefined>;
102
107
  }
103
108
 
104
109
  export interface TelegramQueuedOutboundAttachmentView {
@@ -177,7 +182,7 @@ export function planTelegramRichOutboundAttachment(options: {
177
182
  if (!mediaType) return undefined;
178
183
  const mediaId = "artifact";
179
184
  const richMessage = {
180
- markdown: `${normalizeTelegramNativeMarkdown(options.markdown)}\n\n![](tg://${mediaType}?id=${mediaId})`,
185
+ markdown: `![](tg://${mediaType}?id=${mediaId})\n\n${normalizeTelegramNativeMarkdown(options.markdown)}`,
181
186
  media: [
182
187
  {
183
188
  id: mediaId,
@@ -187,20 +192,11 @@ export function planTelegramRichOutboundAttachment(options: {
187
192
  },
188
193
  },
189
194
  ],
190
- skip_entity_detection: true,
191
195
  };
192
- const replyParameters =
193
- options.turn.replyToMessageId > 0
194
- ? JSON.stringify({
195
- message_id: options.turn.replyToMessageId,
196
- allow_sending_without_reply: true,
197
- })
198
- : undefined;
199
196
  return {
200
197
  method: "sendRichMessage",
201
198
  fields: {
202
199
  chat_id: String(options.turn.chatId),
203
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
204
200
  ...getTelegramMultipartTargetFields(options.turn.target),
205
201
  rich_message: JSON.stringify(richMessage),
206
202
  ...(options.replyMarkup
@@ -230,12 +226,15 @@ export function createTelegramRichOutboundAttachmentSender(
230
226
  });
231
227
  if (!plan) return false;
232
228
  try {
233
- const result = await deps.sendMultipart(
234
- plan.method,
235
- plan.fields,
236
- plan.fileField,
237
- plan.filePath,
238
- plan.fileName,
229
+ const result = await withTelegramReplyParameters(
230
+ turn.chatId, turn.replyToMessageId, turn.target,
231
+ (replyParameters) => deps.sendMultipart(
232
+ plan.method,
233
+ { ...plan.fields, ...(replyParameters ? { reply_parameters: JSON.stringify(replyParameters) } : {}) },
234
+ plan.fileField,
235
+ plan.filePath,
236
+ plan.fileName,
237
+ ),
239
238
  );
240
239
  const messageId =
241
240
  result && typeof result === "object" &&
@@ -352,7 +351,7 @@ function formatTelegramOutboundAttachmentToolResultText(
352
351
  return ["", `${verb} ${count} Telegram attachment(s).`].join("\n");
353
352
  }
354
353
 
355
- function formatTelegramOutboundMessageToolResultText(chatId: number): string {
354
+ function formatTelegramOutboundMessageToolResultText(chatId: number | string): string {
356
355
  return ["", `Sent Telegram message to ${chatId}.`].join("\n");
357
356
  }
358
357
 
@@ -518,19 +517,25 @@ export function registerTelegramOutboundMessageTool(
518
517
  name: "telegram_message",
519
518
  label: "Telegram Message",
520
519
  description:
521
- "Send a Markdown text message directly to the paired/default Telegram chat or an explicit chat_id. Hidden telegram_button comments in the text become attached inline prompt buttons.",
520
+ "Send Markdown text directly to the paired/default Telegram chat, an exact channel chat_id, or an explicit live target. Channel posting requires Telegram-granted bot permission. Hidden telegram_button comments become inline prompt buttons.",
522
521
  promptSnippet: TELEGRAM_MESSAGE_PROMPT_SNIPPET,
523
522
  promptGuidelines: [...TELEGRAM_MESSAGE_PROMPT_GUIDELINES],
524
523
  parameters: Type.Object({
525
524
  text: Type.String({ description: "Message text to send" }),
526
525
  chat_id: Type.Optional(
527
- Type.Number({ description: "Optional Telegram chat id" }),
526
+ Type.Union([
527
+ Type.Number(),
528
+ Type.String({ pattern: "^@[A-Za-z0-9_]{5,32}$" }),
529
+ ], { description: "Optional exact Telegram chat id or public channel @username" }),
528
530
  ),
529
531
  thread_id: Type.Optional(
530
532
  Type.Number({
531
533
  description: "Optional Telegram topic thread id with chat_id",
532
534
  }),
533
535
  ),
536
+ channel: Type.Optional(Type.Boolean({
537
+ description: "Set true only when a numeric chat_id is explicitly intended as a channel",
538
+ })),
534
539
  thread: Type.Optional(
535
540
  Type.Union([Type.String(), Type.Number()], {
536
541
  description:
@@ -538,10 +543,12 @@ export function registerTelegramOutboundMessageTool(
538
543
  }),
539
544
  ),
540
545
  }),
541
- async execute(_toolCallId, params) {
546
+ async execute(toolCallId, params) {
542
547
  try {
543
548
  return await sendTelegramOutboundMessage({
544
549
  text: params.text,
550
+ operationId: toolCallId,
551
+ channel: params.channel,
545
552
  chatId: params.chat_id,
546
553
  threadId: params.thread_id,
547
554
  agentThread: params.thread,
@@ -553,10 +560,14 @@ export function registerTelegramOutboundMessageTool(
553
560
  canSendDirect: deps.canSendDirect,
554
561
  planMessage: deps.planMessage,
555
562
  sendMarkdownMessage: deps.sendMarkdownMessage,
563
+ sendChannelMarkdownMessage: deps.sendChannelMarkdownMessage,
556
564
  });
557
565
  } catch (error) {
558
- deps.recordRuntimeEvent?.("message", error, { phase: "direct" });
559
- throw formatTelegramOutboundToolError(error);
566
+ const reportableError = typeof params.chat_id === "string" || params.channel === true
567
+ ? new Error("Telegram channel publication failed; inspect the retained local record before retrying.")
568
+ : error;
569
+ deps.recordRuntimeEvent?.("message", reportableError, { phase: "direct" });
570
+ throw formatTelegramOutboundToolError(reportableError);
560
571
  }
561
572
  },
562
573
  });
@@ -760,7 +771,9 @@ export async function deliverTelegramGuestCachedAttachment(options: {
760
771
 
761
772
  export async function sendTelegramOutboundMessage(options: {
762
773
  text: string;
763
- chatId?: number;
774
+ operationId?: string;
775
+ channel?: boolean;
776
+ chatId?: number | string;
764
777
  threadId?: number;
765
778
  agentThread?: string | number;
766
779
  target?: TelegramTarget;
@@ -780,12 +793,35 @@ export async function sendTelegramOutboundMessage(options: {
780
793
  markdown: string,
781
794
  options?: { replyMarkup?: unknown; target?: TelegramTarget },
782
795
  ) => Promise<number | undefined>;
796
+ sendChannelMarkdownMessage?: (
797
+ channel: number | string,
798
+ markdown: string,
799
+ options: { operationId: string; replyMarkup?: unknown },
800
+ ) => Promise<number | undefined>;
783
801
  }): Promise<{
784
802
  content: Array<{ type: "text"; text: string }>;
785
- details: { chatId: number; messageId?: number };
803
+ details: { chatId: number | string; messageId?: number };
786
804
  }> {
787
805
  assertTelegramDirectDeliveryAllowed(options.canSendDirect);
788
806
  const activeTurn = options.getActiveTurn?.();
807
+ if (typeof options.chatId === "string" || options.channel === true) {
808
+ if (!((typeof options.chatId === "string" && /^@[A-Za-z0-9_]{5,32}$/u.test(options.chatId)) ||
809
+ (typeof options.chatId === "number" && Number.isSafeInteger(options.chatId) && options.chatId < 0)) ||
810
+ options.threadId !== undefined ||
811
+ options.target !== undefined || options.agentThread !== undefined) {
812
+ throw new Error("Telegram channel delivery requires one exact @username or negative numeric channel ID without a thread target.");
813
+ }
814
+ if (!options.sendChannelMarkdownMessage || !options.operationId) {
815
+ throw new Error("Telegram channel delivery requires direct leader transport ownership and operation identity.");
816
+ }
817
+ const plan = options.planMessage(options.text);
818
+ const messageId = await options.sendChannelMarkdownMessage(
819
+ options.chatId, plan.markdown, { operationId: options.operationId,
820
+ replyMarkup: plan.replyMarkup });
821
+ return { content: [{ type: "text",
822
+ text: formatTelegramOutboundMessageToolResultText(options.chatId) }],
823
+ details: { chatId: options.chatId, messageId } };
824
+ }
789
825
  const requestedAgentSelector: TelegramBusAgentTargetSelector | undefined =
790
826
  options.agentThread !== undefined
791
827
  ? typeof options.agentThread === "number"
@@ -964,21 +1000,19 @@ export async function sendQueuedTelegramOutboundAttachments(
964
1000
  const isPhoto = isTelegramOutboundPhotoAttachmentPath(attachment.path);
965
1001
  const method = isPhoto ? "sendPhoto" : "sendDocument";
966
1002
  const fieldName = isPhoto ? "photo" : "document";
967
- const replyParameters = buildTelegramMultipartReplyParameters(
968
- turn.chatId,
969
- turn.replyToMessageId,
970
- turn.target,
971
- );
972
- await deps.sendMultipart(
973
- method,
974
- {
975
- chat_id: String(turn.chatId),
976
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
977
- ...getTelegramMultipartTargetFields(turn.target),
978
- },
979
- fieldName,
980
- attachment.path,
981
- attachment.fileName,
1003
+ await withTelegramReplyParameters(
1004
+ turn.chatId, turn.replyToMessageId, turn.target,
1005
+ (replyParameters) => deps.sendMultipart(
1006
+ method,
1007
+ {
1008
+ chat_id: String(turn.chatId),
1009
+ ...(replyParameters ? { reply_parameters: JSON.stringify(replyParameters) } : {}),
1010
+ ...getTelegramMultipartTargetFields(turn.target),
1011
+ },
1012
+ fieldName,
1013
+ attachment.path,
1014
+ attachment.fileName,
1015
+ ),
982
1016
  );
983
1017
  } catch (error) {
984
1018
  if (deps.isDeliveryActive?.() === false) return;