@llblab/pi-kit 0.36.1 → 0.36.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 (70) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +4 -0
  4. package/node_modules/@llblab/pi-claude-usage/README.md +2 -2
  5. package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +2 -3
  6. package/node_modules/@llblab/pi-claude-usage/package.json +1 -1
  7. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +5 -0
  8. package/node_modules/@llblab/pi-codex-usage/README.md +1 -1
  9. package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +2 -3
  10. package/node_modules/@llblab/pi-codex-usage/package.json +1 -1
  11. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  12. package/node_modules/@llblab/pi-telegram/BACKLOG.md +17 -5
  13. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +23 -0
  14. package/node_modules/@llblab/pi-telegram/README.md +4 -2
  15. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +17 -1
  16. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +12 -2
  17. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +17 -6
  18. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +1 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +1 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +42 -10
  21. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +1 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.js +9 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +3 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -2
  26. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +6 -3
  27. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +50 -9
  28. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +119 -14
  29. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -0
  30. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +9 -0
  31. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +39 -3
  32. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +144 -16
  33. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +1 -1
  34. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +15 -8
  35. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +14 -1
  36. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +122 -8
  37. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +15 -1
  38. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +22 -6
  39. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +4 -3
  40. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +3 -3
  41. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +1 -0
  42. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +52 -2
  43. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +17 -0
  44. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +25 -0
  45. package/node_modules/@llblab/pi-telegram/dist/package.json +2 -1
  46. package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
  47. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +9 -3
  48. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -0
  49. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  50. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +24 -1
  51. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +16 -0
  52. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -5
  53. package/node_modules/@llblab/pi-telegram/lib/bus.ts +3 -0
  54. package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -11
  55. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +12 -0
  56. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +2 -0
  57. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +3 -2
  58. package/node_modules/@llblab/pi-telegram/lib/pi.ts +2 -0
  59. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +79 -16
  60. package/node_modules/@llblab/pi-telegram/lib/routing.ts +137 -24
  61. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +14 -0
  62. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +173 -17
  63. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +14 -8
  64. package/node_modules/@llblab/pi-telegram/lib/threads.ts +166 -10
  65. package/node_modules/@llblab/pi-telegram/lib/updates.ts +33 -6
  66. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +8 -7
  67. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +56 -2
  68. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +44 -0
  69. package/node_modules/@llblab/pi-telegram/package.json +2 -1
  70. package/package.json +4 -4
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Telegram prompt injection helpers
3
3
  * Zones: pi agent prompts, telegram guidance
4
- * Owns Telegram-specific system prompt suffixes injected into pi agent turns
4
+ * Owns stable Telegram guidance, turn-scoped context, and compatibility fallback hooks
5
5
  */
6
- import type { BeforeAgentStartEvent } from "./pi.ts";
6
+ import type { BeforeAgentStartEvent, BeforeAgentStartEventResult } from "./pi.ts";
7
7
  export declare const TELEGRAM_CONNECTED_CONTEXT_MESSAGE = "Telegram session connected. Use Telegram features for Telegram-originated turns or explicit Telegram requests; connectivity alone is not user intent.";
8
8
  export declare const TELEGRAM_DISCONNECTED_CONTEXT_MESSAGE = "Telegram session disconnected. Do not use Telegram delivery, actions, or Telegram-specific reply features unless the user reconnects it.";
9
9
  export declare const TELEGRAM_ATTACH_PROMPT_SNIPPET = "Queue files for the active Telegram reply; outside Telegram turns, send files directly to Telegram.";
@@ -37,6 +37,9 @@ type TelegramBeforeAgentStartEvent = Omit<BeforeAgentStartEvent, "systemPrompt"
37
37
  type TelegramBeforeAgentStartResult = {
38
38
  systemPrompt: TelegramSystemPrompt;
39
39
  };
40
+ type TelegramProactiveBeforeAgentStartResult = Omit<BeforeAgentStartEventResult, "systemPrompt"> & {
41
+ systemPrompt?: TelegramSystemPrompt;
42
+ };
40
43
  type TelegramBeforeAgentStartHook = (event: TelegramBeforeAgentStartEvent) => TelegramBeforeAgentStartResult;
41
44
  export declare function buildTelegramBridgeSystemPrompt(options: {
42
45
  prompt?: string;
@@ -55,5 +58,5 @@ export interface TelegramProactivePromptHookDeps<TContext> {
55
58
  reconcileAvailability?: () => void;
56
59
  isAvailable: (ctx: TContext) => boolean;
57
60
  }
58
- export declare function createTelegramProactiveBeforeAgentStartHook<TContext>(deps: TelegramProactivePromptHookDeps<TContext>): (event: TelegramBeforeAgentStartEvent, ctx: TContext) => Promise<TelegramBeforeAgentStartResult>;
61
+ export declare function createTelegramProactiveBeforeAgentStartHook<TContext>(deps: TelegramProactivePromptHookDeps<TContext>): (event: TelegramBeforeAgentStartEvent, ctx: TContext) => Promise<TelegramProactiveBeforeAgentStartResult | undefined>;
59
62
  export {};
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Telegram prompt injection helpers
3
3
  * Zones: pi agent prompts, telegram guidance
4
- * Owns Telegram-specific system prompt suffixes injected into pi agent turns
4
+ * Owns stable Telegram guidance, turn-scoped context, and compatibility fallback hooks
5
5
  */
6
6
  import { TELEGRAM_PREFIX } from "./turns.js";
7
7
  export const TELEGRAM_CONNECTED_CONTEXT_MESSAGE = "Telegram session connected. Use Telegram features for Telegram-originated turns or explicit Telegram requests; connectivity alone is not user intent.";
@@ -107,8 +107,17 @@ export function createTelegramModelContextAvailabilityRuntime(deps) {
107
107
  },
108
108
  };
109
109
  }
110
- export function buildTelegramBridgeSystemPrompt(options) {
111
- const basePrompt = options.systemPrompt ?? "";
110
+ // Returning any systemPrompt makes Pi replace its prompt, which defeats prompt
111
+ // caching; return an override only when the computed prompt actually differs.
112
+ function overrideSystemPromptIfChanged(incoming, computed) {
113
+ const base = incoming ?? "";
114
+ const unchanged = Array.isArray(base) && Array.isArray(computed)
115
+ ? base.length === computed.length &&
116
+ base.every((block, index) => block === computed[index])
117
+ : base === computed;
118
+ return unchanged ? undefined : { systemPrompt: computed };
119
+ }
120
+ function buildTelegramTurnSuffix(options) {
112
121
  const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
113
122
  const telegramHead = telegramPrefix.endsWith("]")
114
123
  ? telegramPrefix.slice(0, -1)
@@ -116,9 +125,13 @@ export function buildTelegramBridgeSystemPrompt(options) {
116
125
  const trimmedPrompt = options.prompt?.trimStart() ?? "";
117
126
  const telegramTurn = trimmedPrompt.startsWith(`${telegramHead}]`) ||
118
127
  trimmedPrompt.startsWith(`${telegramHead}|`);
119
- const telegramSuffix = telegramTurn
128
+ return telegramTurn
120
129
  ? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
121
130
  : "";
131
+ }
132
+ export function buildTelegramBridgeSystemPrompt(options) {
133
+ const basePrompt = options.systemPrompt ?? "";
134
+ const telegramSuffix = buildTelegramTurnSuffix(options);
122
135
  return {
123
136
  systemPrompt: Array.isArray(basePrompt)
124
137
  ? [...basePrompt, options.localSystemPromptSuffix + telegramSuffix]
@@ -148,15 +161,43 @@ function stripTelegramToolMetadataFromSystemPrompt(systemPrompt) {
148
161
  ? systemPrompt.map(stripTelegramToolMetadataFromString)
149
162
  : stripTelegramToolMetadataFromString(systemPrompt);
150
163
  }
164
+ const TELEGRAM_TOOL_NAMES = ["telegram_attach", "telegram_message"];
165
+ // Sections cannot modify an opaque prompt owned by an earlier handler.
166
+ // Preserve append/strip compatibility there instead of silently losing guidance.
167
+ function applyTelegramPromptSections(event, available) {
168
+ const options = event.systemPromptOptions;
169
+ const sections = options?.sections;
170
+ if (typeof sections !== "object" || sections === null || options?.forceSystemPrompt !== undefined)
171
+ return false;
172
+ if (available) {
173
+ sections.telegram = LOCAL_SYSTEM_PROMPT_SUFFIX.trim();
174
+ return true;
175
+ }
176
+ delete sections.telegram;
177
+ for (const name of TELEGRAM_TOOL_NAMES) {
178
+ if (options?.toolSnippets)
179
+ delete options.toolSnippets[name];
180
+ if (options?.toolGuidelines)
181
+ delete options.toolGuidelines[name];
182
+ }
183
+ return true;
184
+ }
151
185
  export function createTelegramProactiveBeforeAgentStartHook(deps) {
152
186
  const baseHook = deps.baseHook ?? createTelegramBeforeAgentStartHook();
153
187
  return async (event, ctx) => {
154
188
  deps.reconcileAvailability?.();
155
- if (!deps.isAvailable(ctx)) {
156
- return {
157
- systemPrompt: stripTelegramToolMetadataFromSystemPrompt(event.systemPrompt),
158
- };
189
+ const available = deps.isAvailable(ctx);
190
+ // A caller-supplied baseHook owns its own prompt text, so it stays on the forced-prompt path.
191
+ if (!deps.baseHook && applyTelegramPromptSections(event, available)) {
192
+ const turnSuffix = available ? buildTelegramTurnSuffix({
193
+ prompt: event.messageType === "custom" ? undefined : event.prompt,
194
+ telegramTurnSystemPromptSuffix: TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX,
195
+ }).trim() : "";
196
+ return turnSuffix ? { message: { customType: "telegram-turn", content: turnSuffix, display: false } } : undefined;
197
+ }
198
+ if (!available) {
199
+ return overrideSystemPromptIfChanged(event.systemPrompt, stripTelegramToolMetadataFromSystemPrompt(event.systemPrompt));
159
200
  }
160
- return baseHook(event);
201
+ return overrideSystemPromptIfChanged(event.systemPrompt, baseHook(event).systemPrompt);
161
202
  };
162
203
  }
@@ -1052,6 +1052,8 @@ export function createTelegramInboundRouteRuntime(deps) {
1052
1052
  /** A frozen destination pauses All-command expiry until routing finishes or releases it. */
1053
1053
  const isPendingRerouteDestinationHeld = (pending) => pending.phase.kind !== "waiting" && pending.phase.kind !== "released";
1054
1054
  const pendingUnboundReroutes = new Map();
1055
+ // Body-free, bounded warm deduplication: album members can expire through the same chooser.
1056
+ const expiredRoutingViews = new Set();
1055
1057
  const implicitThreadCreations = new Map();
1056
1058
  const guidedUnboundTopicKeys = new Set();
1057
1059
  let nextUnboundRerouteId = 0;
@@ -1409,6 +1411,20 @@ export function createTelegramInboundRouteRuntime(deps) {
1409
1411
  return result;
1410
1412
  }
1411
1413
  catch (error) {
1414
+ const missingTarget = TelegramApi.getTelegramApiErrorRequestTarget(error);
1415
+ if (method === "deleteForumTopic" && missingTarget && error instanceof Error &&
1416
+ TelegramApi.isTelegramApiRequestRejected(error, method) &&
1417
+ TelegramApi.isTelegramStaleTargetHttpError(error) &&
1418
+ ThreadReconciler.isTelegramTopicDeletedErrorMessage(error.message) &&
1419
+ missingTarget?.chatId === temporaryCleanup.target?.chatId &&
1420
+ missingTarget?.threadId === temporaryCleanup.target?.threadId &&
1421
+ missingTarget?.chatId === body.chat_id && missingTarget?.threadId === body.message_thread_id) {
1422
+ // Only the client's parsed exact-target HTTP 400 absence closes the one-shot grant.
1423
+ // Closed/denied/foreign/forged responses remain uncertain and never license another request.
1424
+ assertExecutionCurrent?.();
1425
+ temporaryDeleteConfirmed = true;
1426
+ return true;
1427
+ }
1412
1428
  temporaryTransportUncertain = true;
1413
1429
  throw error;
1414
1430
  }
@@ -5524,7 +5540,8 @@ export function createTelegramInboundRouteRuntime(deps) {
5524
5540
  return formatTelegramRoutingInputPreview(message);
5525
5541
  };
5526
5542
  const expireRoutingInput = async (source, ctx, signal) => {
5527
- const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
5543
+ const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.(), generation = deps.getSessionGeneration?.(), instanceId = deps.getCurrentInstanceId?.(), scope = deps.getAdmissionScope?.();
5544
+ const sendTextReply = deps.sendTextReply, editInteractiveMessage = deps.editInteractiveMessage;
5528
5545
  const message = source.original.update.message;
5529
5546
  const runtimeCurrent = () => !signal.aborted &&
5530
5547
  operator !== undefined &&
@@ -5532,6 +5549,9 @@ export function createTelegramInboundRouteRuntime(deps) {
5532
5549
  deps.isContextActive?.(ctx) === true &&
5533
5550
  deps.configStore.getAllowedUserId() === operator &&
5534
5551
  deps.getCurrentLeaderEpoch?.() === epoch &&
5552
+ deps.getSessionGeneration?.() === generation &&
5553
+ deps.getCurrentInstanceId?.() === instanceId &&
5554
+ deps.getAdmissionScope?.() === scope &&
5535
5555
  deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
5536
5556
  source.original.routingInput?.operatorUserId === operator &&
5537
5557
  message?.from?.id === operator &&
@@ -5559,27 +5579,73 @@ export function createTelegramInboundRouteRuntime(deps) {
5559
5579
  return;
5560
5580
  // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
5561
5581
  let movedIntoTab = false;
5582
+ let unissued = source.original.routingInput?.phase === "waiting" &&
5583
+ (!temporary || temporary.forwardProtocol === "one-shot-v1") &&
5584
+ ![...(temporary?.forwardedInputs ?? []), ...(temporary?.completedInputs ?? [])].some(input => input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId));
5585
+ const views = new Map();
5586
+ const chooser = source.original.routingInput?.chooser;
5587
+ if (chooser)
5588
+ views.set(`${chooser.chatId}:${chooser.messageId}`, { ...chooser });
5562
5589
  for (const [id, pending] of pendingUnboundReroutes) {
5563
5590
  if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
5564
5591
  continue;
5565
5592
  movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
5593
+ unissued &&= isPendingRerouteUntouched(pending) || pending.phase.kind === "released";
5594
+ if (pending.chooserMessageId !== undefined)
5595
+ views.set(`${pending.sourceTarget.chatId}:${pending.chooserMessageId}`, {
5596
+ ...pending.sourceTarget, messageId: pending.chooserMessageId,
5597
+ });
5566
5598
  removePendingReroute(id);
5567
- if (signal.aborted ||
5568
- deps.isContextActive?.(ctx) !== true ||
5569
- deps.configStore.getAllowedUserId() !== operator ||
5570
- deps.getCurrentLeaderEpoch?.() !== epoch ||
5571
- deps.getAdmissionJournalBinding?.() !== source.journalBindingKey)
5572
- continue;
5573
- if (deps.editInteractiveMessage &&
5574
- pending.chooserMessageId !== undefined) {
5599
+ }
5600
+ const notice = unissued
5601
+ ? "<b>⌛ Routing expired; input was not delivered.</b>"
5602
+ : "<b>⌛ Routing expired; delivery is unconfirmed.</b>\nPreviously accepted work may continue.";
5603
+ const noticeKey = `${source.journalBindingKey}:${source.original.routingInput?.expiresAtMs}:${chooser ? `${chooser.chatId}:${chooser.messageId}` : source.original.updateId}`;
5604
+ if (runtimeCurrent() && !expiredRoutingViews.has(noticeKey)) {
5605
+ if (expiredRoutingViews.size >= 100)
5606
+ expiredRoutingViews.delete(expiredRoutingViews.values().next().value);
5607
+ expiredRoutingViews.add(noticeKey);
5608
+ let visible = false;
5609
+ const assertNoticeAuthority = () => {
5610
+ if (!runtimeCurrent())
5611
+ throw new Error("Routing expiry notice authority changed.");
5612
+ };
5613
+ for (const view of views.values()) {
5614
+ if (!runtimeCurrent() || !editInteractiveMessage || deps.editInteractiveMessage !== editInteractiveMessage)
5615
+ break;
5616
+ if (view.chatId !== message.chat.id || !Number.isSafeInteger(view.threadId) || view.threadId <= 0)
5617
+ continue;
5575
5618
  try {
5576
- await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⌛ Routing choice expired.</b>", "html", { inline_keyboard: [] });
5619
+ await editInteractiveMessage(view.chatId, view.messageId, notice, "html", { inline_keyboard: [] }, {
5620
+ target: { chatId: view.chatId, threadId: view.threadId }, assertAuthority: assertNoticeAuthority,
5621
+ });
5622
+ visible = true;
5577
5623
  }
5578
5624
  catch (error) {
5579
- deps.recordRuntimeEvent?.("routing", error, {
5580
- phase: "routing-input-expiry-view",
5581
- });
5625
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" });
5626
+ }
5627
+ }
5628
+ // A temporary tab is about to disappear; a cold/missing chooser also needs an independent owned destination.
5629
+ const leader = instanceId ? deps.threadStore?.getActiveByInstanceId(instanceId) : undefined;
5630
+ if ((!visible || temporary?.target) && runtimeCurrent() && leader &&
5631
+ leader.target.chatId === message.chat.id && Number.isSafeInteger(leader.target.threadId) && leader.target.threadId > 0) {
5632
+ const target = { chatId: leader.target.chatId, threadId: leader.target.threadId };
5633
+ try {
5634
+ const assertFallback = () => {
5635
+ assertNoticeAuthority();
5636
+ const active = deps.threadStore?.getActiveByInstanceId(instanceId);
5637
+ if (deps.sendTextReply !== sendTextReply || active?.target.chatId !== target.chatId || active.target.threadId !== target.threadId)
5638
+ throw new Error("Routing expiry notice destination changed.");
5639
+ };
5640
+ assertFallback();
5641
+ await sendTextReply(target.chatId, 0, notice, { parseMode: "HTML", target, assertAuthority: assertFallback });
5582
5642
  }
5643
+ catch (error) {
5644
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-notice" });
5645
+ }
5646
+ }
5647
+ else if (!visible && runtimeCurrent()) {
5648
+ deps.recordRuntimeEvent?.("routing", "Routing expiry notice has no current owned Thread.", { phase: "routing-input-expiry-notice-unavailable" });
5583
5649
  }
5584
5650
  }
5585
5651
  // The scheduled stage captures only context/epoch and a tab token. It reconstructs disposition from body-free proof,
@@ -5780,7 +5846,8 @@ export function createTelegramInboundRouteRuntime(deps) {
5780
5846
  if (!recordRecoveredRerouteSource(pending, source.original.updateId, source.result))
5781
5847
  continue;
5782
5848
  try {
5783
- await retireCancellationChooser(id, pending, isCurrent);
5849
+ if (await retireCancellationChooser(id, pending, isCurrent))
5850
+ await deleteCancelledOrphanTab(pending, pending.abandonment, isCurrent);
5784
5851
  }
5785
5852
  catch (error) {
5786
5853
  deps.recordRuntimeEvent?.("routing", error, {
@@ -5809,6 +5876,43 @@ export function createTelegramInboundRouteRuntime(deps) {
5809
5876
  }
5810
5877
  return true;
5811
5878
  };
5879
+ /**
5880
+ * A cancelled input in an unbound (non-temporary) tab leaves nothing to route: once no chooser, queue, live owner,
5881
+ * Workspace/Restore record or retained journal arrival still names the tab, it is deleted like a routing tab. Any
5882
+ * missing proof or failure keeps the tab; this never retries and never blocks the Cancel outcome.
5883
+ */
5884
+ const deleteCancelledOrphanTab = async (pending, cancellation, isAuthorityCurrent) => {
5885
+ const { chatId, threadId } = pending.sourceTarget;
5886
+ if (threadId === undefined ||
5887
+ pending.temporaryThread ||
5888
+ isTemporaryTabTarget({ chatId, threadId }) ||
5889
+ hasPendingRerouteForTarget({ chatId, threadId }) ||
5890
+ !deps.inspectTemporaryThreadSources)
5891
+ return;
5892
+ const target = { chatId, threadId };
5893
+ try {
5894
+ const own = {
5895
+ journalBindingKey: cancellation.journalBindingKey,
5896
+ updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages),
5897
+ };
5898
+ const assertCurrent = () => {
5899
+ if (!isAuthorityCurrent())
5900
+ throw new Error("Unbound tab cleanup authority changed.");
5901
+ };
5902
+ assertCurrent();
5903
+ if (deps.inspectTemporaryThreadSources(target, [cancellation.journalBindingKey], [own])?.length !== 0)
5904
+ return;
5905
+ assertCurrent();
5906
+ await closeReroutedUnboundTopic(target, pending.chooserMessageId, assertCurrent);
5907
+ }
5908
+ catch (error) {
5909
+ deps.recordRuntimeEvent?.("routing", error, {
5910
+ phase: "unbound-cancel-tab-cleanup",
5911
+ chatId: target.chatId,
5912
+ threadId: target.threadId,
5913
+ });
5914
+ }
5915
+ };
5812
5916
  const handleUnboundRerouteCancelCallback = async (query, ctx) => {
5813
5917
  if (!query.data?.startsWith(TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX))
5814
5918
  return false;
@@ -5890,6 +5994,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5890
5994
  await Promise.all([
5891
5995
  deps.answerCallbackQuery(query.id, "Routing cancelled"),
5892
5996
  deleteAllTabCopies(pending),
5997
+ deleteCancelledOrphanTab(pending, cancellation, isAuthorityCurrent),
5893
5998
  ]);
5894
5999
  }
5895
6000
  catch (error) {
@@ -505,6 +505,17 @@ export declare class TelegramApiStaleTargetError extends Error {
505
505
  threadId: number;
506
506
  });
507
507
  }
508
+ /** The leader's exact refusal of a follower API call outside that follower's own Thread; carries only the requested target. */
509
+ export declare class TelegramApiFollowerNotAllowedError extends Error {
510
+ readonly requestTarget: {
511
+ chatId: number;
512
+ threadId: number;
513
+ } | undefined;
514
+ constructor(message: string, requestTarget?: {
515
+ chatId: number;
516
+ threadId: number;
517
+ });
518
+ }
508
519
  export declare function getTelegramApiErrorRequestTarget(error: unknown): {
509
520
  chatId: number;
510
521
  threadId: number;
@@ -338,6 +338,15 @@ export class TelegramApiStaleTargetError extends Error {
338
338
  this.requestTarget = { ...requestTarget };
339
339
  }
340
340
  }
341
+ /** The leader's exact refusal of a follower API call outside that follower's own Thread; carries only the requested target. */
342
+ export class TelegramApiFollowerNotAllowedError extends Error {
343
+ requestTarget;
344
+ constructor(message, requestTarget) {
345
+ super(message);
346
+ this.name = "TelegramApiFollowerNotAllowedError";
347
+ this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
348
+ }
349
+ }
341
350
  export function getTelegramApiErrorRequestTarget(error) {
342
351
  const target = error instanceof TelegramApiHttpError ||
343
352
  error instanceof TelegramApiStaleTargetError
@@ -3,7 +3,11 @@
3
3
  * Zones: telegram threads, workspace lifecycle
4
4
  * Owns fail-closed cleanup eligibility projection without persistence or Bot API effects
5
5
  */
6
- import type { TelegramWorkspaceDeletionPermit, TelegramWorkspaceDestructiveFence, TelegramWorkspaceRetirementFence } from "./workspace-admission.ts";
6
+ import type { TelegramWorkspaceDeletionPermit, TelegramWorkspaceDestructiveFence, TelegramWorkspaceRetirementFence, TelegramWorkspaceAdmissionLedger, TelegramWorkspaceAdmissionOwner } from "./workspace-admission.ts";
7
+ import type { TelegramTopicTargetStore, TelegramWorkspaceThreadBinding } from "./threads.ts";
8
+ import type { TelegramWorkspaceOperationRunner } from "./workspace-retirement.ts";
9
+ import type { TelegramWorkspaceThreadDeletionTransport } from "./telegram-api.ts";
10
+ import type { TelegramProcessLiveness } from "./process-identity.ts";
7
11
  export interface TelegramThreadCleanupBindingSnapshot {
8
12
  cwd: string;
9
13
  workspaceKey: string;
@@ -38,7 +42,8 @@ export interface TelegramThreadCleanupCandidate {
38
42
  sessionId?: string;
39
43
  sessionKey?: string;
40
44
  instanceSlot: string;
41
- slot: string;
45
+ /** Null is explicit absence in reviewed work; it never denotes a reusable letter. */
46
+ slot: string | null;
42
47
  target: {
43
48
  chatId: number;
44
49
  threadId: number;
@@ -155,6 +160,8 @@ export declare function createTelegramThreadCleanupWorkStore(options: {
155
160
  runtimeDir?: string;
156
161
  profileName: string;
157
162
  tokenSha256: string;
163
+ /** Exact captured ledger identity; standalone fixtures may use the plain profile identity. */
164
+ admissionProfileKey?: string;
158
165
  maxWorkSets?: number;
159
166
  maxBytes?: number;
160
167
  getNowMs?: () => number;
@@ -185,6 +192,7 @@ export declare function executeTelegramInactiveThreadCleanup(input: {
185
192
  }>;
186
193
  export declare function createTelegramThreadCleanupPermitRuntime(deps: {
187
194
  ledger: {
195
+ getProfileKey(): string;
188
196
  read(): {
189
197
  fence?: TelegramWorkspaceDestructiveFence;
190
198
  };
@@ -192,7 +200,7 @@ export declare function createTelegramThreadCleanupPermitRuntime(deps: {
192
200
  operationId: string;
193
201
  cleanupWorkSetId: string;
194
202
  bindingKey: string;
195
- slot: string;
203
+ slot: string | null;
196
204
  target: {
197
205
  chatId: number;
198
206
  threadId: number;
@@ -256,6 +264,34 @@ export interface TelegramInactiveThreadCleanupCoordinatorDeps<TBinding> {
256
264
  deleteWithPermit(permit: TelegramThreadCleanupDeletionPermit, candidate: TelegramThreadCleanupCandidate): Promise<void>;
257
265
  commitBinding(candidate: TelegramThreadCleanupCandidate, currentBinding?: TBinding): Promise<boolean>;
258
266
  }
267
+ export interface TelegramInactiveThreadCleanupAuthority {
268
+ profileName: string;
269
+ leaderEpoch: number | string;
270
+ ledger: TelegramWorkspaceAdmissionLedger;
271
+ isCurrent(): boolean;
272
+ }
273
+ /** Review owns ordinary admission; confirmed cleanup uses the shared gate and its exclusive manual fence instead. */
274
+ export declare function createTelegramInactiveThreadCleanupRuntime(deps: {
275
+ store: Pick<TelegramTopicTargetStore, "load" | "listWorkspaceBindings" | "listReservations" | "listPendingProvisions" | "listPendingCleanups" | "commitInactiveWorkspaceCleanup">;
276
+ captureAuthority(): TelegramInactiveThreadCleanupAuthority | undefined;
277
+ getWorkStore(authority: TelegramInactiveThreadCleanupAuthority): TelegramThreadCleanupWorkStore;
278
+ getProtection(binding: TelegramWorkspaceThreadBinding): {
279
+ liveOwner: TelegramThreadCleanupProtectionState;
280
+ acceptedWork: TelegramThreadCleanupProtectionState;
281
+ deliveryAuthority: TelegramThreadCleanupProtectionState;
282
+ };
283
+ getProcessLiveness(owner: TelegramWorkspaceAdmissionOwner): TelegramProcessLiveness;
284
+ runExclusive<T>(operation: () => Promise<T>): Promise<T>;
285
+ runWorkspaceOperation: TelegramWorkspaceOperationRunner;
286
+ deleteThread: TelegramWorkspaceThreadDeletionTransport;
287
+ onError?: (error: unknown) => void;
288
+ }): {
289
+ review(): Promise<{
290
+ count: number;
291
+ operationId?: string;
292
+ }>;
293
+ clean(operationId: string): Promise<TelegramInactiveThreadCleanupResult>;
294
+ };
259
295
  export declare function createTelegramInactiveThreadCleanupSettingsPort<TBinding>(deps: TelegramInactiveThreadCleanupCoordinatorDeps<TBinding>): (operationId: string) => Promise<TelegramInactiveThreadCleanupResult>;
260
296
  export interface TelegramInactiveThreadCleanupResult {
261
297
  deleted: number;