@llblab/pi-kit 0.36.0 → 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 (78) hide show
  1. package/BACKLOG.md +0 -4
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +5 -5
  4. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +4 -0
  5. package/node_modules/@llblab/pi-claude-usage/README.md +2 -2
  6. package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +2 -3
  7. package/node_modules/@llblab/pi-claude-usage/package.json +1 -1
  8. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +5 -0
  9. package/node_modules/@llblab/pi-codex-usage/README.md +1 -1
  10. package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +2 -3
  11. package/node_modules/@llblab/pi-codex-usage/package.json +1 -1
  12. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +4 -0
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +5 -4
  14. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  15. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +1 -1
  16. package/node_modules/@llblab/pi-state-flow/docs/usage.md +1 -0
  17. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +5 -4
  18. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  19. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  20. package/node_modules/@llblab/pi-telegram/BACKLOG.md +17 -5
  21. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +23 -0
  22. package/node_modules/@llblab/pi-telegram/README.md +4 -2
  23. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +17 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +12 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +17 -6
  26. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +1 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +1 -0
  28. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +42 -10
  29. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +1 -0
  30. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.js +9 -0
  31. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -1
  32. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +3 -2
  33. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -2
  34. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +6 -3
  35. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +50 -9
  36. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +119 -14
  37. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -0
  38. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +9 -0
  39. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +39 -3
  40. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +144 -16
  41. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +1 -1
  42. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +15 -8
  43. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +14 -1
  44. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +122 -8
  45. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +15 -1
  46. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +22 -6
  47. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +4 -3
  48. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +3 -3
  49. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +1 -0
  50. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +52 -2
  51. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +17 -0
  52. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +25 -0
  53. package/node_modules/@llblab/pi-telegram/dist/package.json +2 -1
  54. package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
  55. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +9 -3
  56. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -0
  57. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  58. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +24 -1
  59. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +16 -0
  60. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -5
  61. package/node_modules/@llblab/pi-telegram/lib/bus.ts +3 -0
  62. package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -11
  63. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +12 -0
  64. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +2 -0
  65. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +3 -2
  66. package/node_modules/@llblab/pi-telegram/lib/pi.ts +2 -0
  67. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +79 -16
  68. package/node_modules/@llblab/pi-telegram/lib/routing.ts +137 -24
  69. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +14 -0
  70. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +173 -17
  71. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +14 -8
  72. package/node_modules/@llblab/pi-telegram/lib/threads.ts +166 -10
  73. package/node_modules/@llblab/pi-telegram/lib/updates.ts +33 -6
  74. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +8 -7
  75. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +56 -2
  76. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +44 -0
  77. package/node_modules/@llblab/pi-telegram/package.json +2 -1
  78. package/package.json +5 -5
@@ -454,17 +454,32 @@ export default function (pi) {
454
454
  return WorkspaceRetirement.isCurrentTelegramWorkspaceBinding(threadStore, binding);
455
455
  },
456
456
  });
457
- const inactiveThreadCleanupReviewRuntime = ThreadCleanupManager.createTelegramInactiveThreadCleanupReviewRuntime({
458
- getProfileName() {
459
- return configStore.getActiveProfileName() ?? "default";
457
+ const inactiveThreadCleanupRuntime = ThreadCleanupManager.createTelegramInactiveThreadCleanupRuntime({
458
+ store: threadStore,
459
+ captureAuthority() {
460
+ const ledger = workspaceAdmissionRuntime.resolve();
461
+ const leaderEpoch = getCurrentLeaderEpoch();
462
+ const operator = configStore.getAllowedUserId();
463
+ if (!ledger || leaderEpoch === undefined || operator === undefined)
464
+ return undefined;
465
+ const stamp = telegramTransportStampRuntime.getStamp();
466
+ const generation = telegramSessionContextStore.getGeneration();
467
+ return {
468
+ profileName: configStore.getActiveProfileName() ?? "default",
469
+ leaderEpoch, ledger,
470
+ isCurrent() {
471
+ return getCurrentLeaderEpoch() === leaderEpoch &&
472
+ telegramTransportStampRuntime.isActive(stamp) &&
473
+ configStore.getAllowedUserId() === operator &&
474
+ telegramSessionContextStore.getGeneration() === generation;
475
+ },
476
+ };
460
477
  },
461
- listBindings: threadStore.listWorkspaceBindings,
462
478
  getProtection: captureWorkspaceExternalProtection,
463
- listReservations: threadStore.listReservations,
464
- listPendingProvisions: threadStore.listPendingProvisions,
465
- listPendingCleanups: threadStore.listPendingCleanups,
466
- getWorkStore() {
467
- const profileName = configStore.getActiveProfileName() ?? "default";
479
+ getProcessLiveness: ProcessIdentity.getTelegramProcessLiveness,
480
+ runExclusive: telegramWorkspaceOperationRuntime.runExclusive,
481
+ getWorkStore(authority) {
482
+ const profileName = authority.profileName;
468
483
  const botToken = configStore.getBotToken();
469
484
  if (!botToken)
470
485
  throw new Error("Telegram Thread cleanup review requires an active bot token.");
@@ -474,9 +489,16 @@ export default function (pi) {
474
489
  tokenSha256: Journal.createTelegramUpdateJournalBotIdentity({
475
490
  botToken,
476
491
  }).tokenSha256,
492
+ admissionProfileKey: authority.ledger.getProfileKey(),
477
493
  });
478
494
  },
479
495
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
496
+ deleteThread(authorize) {
497
+ return directTelegramApiRuntime.deleteWorkspaceThread(authorize);
498
+ },
499
+ onError(error) {
500
+ recordRuntimeEvent("telegram", error, { phase: "manual-inactive-thread-cleanup" });
501
+ },
480
502
  });
481
503
  const updateAdmissionRuntimeBinding = Updates.createTelegramUpdateAdmissionRuntimeBinding({
482
504
  isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
@@ -940,7 +962,8 @@ export default function (pi) {
940
962
  sendInteractiveMessage,
941
963
  answerCallbackQuery,
942
964
  ...configControls,
943
- reviewInactiveThreads: inactiveThreadCleanupReviewRuntime.review,
965
+ reviewInactiveThreads: inactiveThreadCleanupRuntime.review,
966
+ cleanInactiveThreads: inactiveThreadCleanupRuntime.clean,
944
967
  getThreadDisplayMode() {
945
968
  return threadStore.getBotState().threadMode === "enabled"
946
969
  ? Config.resolveTelegramThreadDisplayMode(configStore.get())
@@ -1377,6 +1400,15 @@ export default function (pi) {
1377
1400
  return binding.hasAuthority?.() === true;
1378
1401
  });
1379
1402
  },
1403
+ recoverRejectedRecipientTarget(error) {
1404
+ const rejected = error instanceof TelegramApi.TelegramApiFollowerNotAllowedError
1405
+ ? error.requestTarget
1406
+ : undefined;
1407
+ const current = telegramBusFollowerRegistrationState.getTarget();
1408
+ return rejected && current?.threadId !== undefined
1409
+ ? { rejected, current: { chatId: current.chatId, threadId: current.threadId } }
1410
+ : undefined;
1411
+ },
1380
1412
  prepareUpdateForExecution(update) {
1381
1413
  return BusFollower.prepareTelegramBusFollowerJournaledUpdateForExecution(update, textGroupRuntime.prepareForwardedMessage);
1382
1414
  },
@@ -159,6 +159,7 @@ export interface TelegramBindToolRegistrationDeps extends GenerativeAppRuntimeOp
159
159
  recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
160
160
  }
161
161
  export declare function resolveGenerativeAppDir(agentDir: string, app: string): string;
162
+ export declare function isGenerativeAppInstalled(agentDir: string, app: string): Promise<boolean>;
162
163
  export declare function resolveGenerativeAppModulePath(agentDir: string, app: string): string;
163
164
  export declare function invokeGenerativeApp(options: GenerativeAppRuntimeOptions & {
164
165
  argument?: unknown;
@@ -86,6 +86,15 @@ export function resolveGenerativeAppDir(agentDir, app) {
86
86
  assertAppName(app);
87
87
  return join(getAppsRoot(agentDir), app);
88
88
  }
89
+ export async function isGenerativeAppInstalled(agentDir, app) {
90
+ try {
91
+ await stat(resolveGenerativeAppModulePath(agentDir, app));
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
89
98
  export function resolveGenerativeAppModulePath(agentDir, app) {
90
99
  return join(resolveGenerativeAppDir(agentDir, app), `${app}.mjs`);
91
100
  }
@@ -5,7 +5,7 @@
5
5
  * Transport authority, durable bindings, and queue custody remain with their owners.
6
6
  */
7
7
  import * as BusFollower from "./bus-follower.ts";
8
- import type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionCompactFailedEvent, SessionShutdownEvent, SessionStartEvent, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolExecutionUpdateEvent, TurnEndEvent, TurnEndEventResult, UIPromptEndEvent, UIPromptStartEvent } from "./pi.ts";
8
+ import type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, ExtensionAPI, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionCompactFailedEvent, SessionShutdownEvent, SessionStartEvent, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolExecutionUpdateEvent, TurnEndEvent, TurnEndEventResult, UIPromptEndEvent, UIPromptStartEvent } from "./pi.ts";
9
9
  import * as Machine from "./machine.ts";
10
10
  import * as Queue from "./queue.ts";
11
11
  export declare function createAgentStartDedupHook(inner: (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>, resetReplyDedup: () => void, schedulePublication?: (task: () => Promise<void>) => void): (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>;
@@ -14,6 +14,7 @@ type TelegramBeforeAgentStartEvent = Omit<BeforeAgentStartEvent, "systemPrompt">
14
14
  };
15
15
  export interface TelegramBeforeAgentStartResult {
16
16
  systemPrompt?: string | string[];
17
+ message?: BeforeAgentStartEventResult["message"];
17
18
  }
18
19
  type TelegramBeforeAgentStartReturn = Promise<TelegramBeforeAgentStartResult | undefined> | TelegramBeforeAgentStartResult | undefined;
19
20
  type TelegramLifecycleModel = ExtensionContext["model"];
@@ -49,7 +49,7 @@ export function buildAutomaticThreadCleanupSettingsText(enabled) {
49
49
  "<code>-</code> <code>on</code> (default): delete the bound thread and release Telegram authority on graceful quit.",
50
50
  "<code>-</code> <code>off</code>: preserve the tab as a restart hint; manual <code>/telegram-disconnect</code> still confirms and deletes it.",
51
51
  "",
52
- "Review inactive tabs checks current owner and work evidence. Review never deletes tabs.",
52
+ "On the leader instance, Review inactive tabs checks current owner and work evidence, including tabs without a letter. Review never deletes tabs.",
53
53
  ].join("\n");
54
54
  }
55
55
  function buildInactiveThreadReviewText(count) {
@@ -58,7 +58,8 @@ function buildInactiveThreadReviewText(count) {
58
58
  "",
59
59
  `${count} proven inactive tab${count === 1 ? "" : "s"}.`,
60
60
  "No tabs were deleted.",
61
- "Deletion requires a separate confirmed Clean action.",
61
+ "Clean inactive tabs deletes these Telegram tabs and all their messages. Pi session history and project files are not removed.",
62
+ "Clean rechecks owner and work protection; a changed or protected tab is skipped. Choose Back to leave without deleting anything.",
62
63
  ].join("\n");
63
64
  }
64
65
  export function buildInactiveThreadReviewReplyMarkup(operationId, canCleanInactiveThreads = false) {
@@ -4,8 +4,8 @@
4
4
  * Owns direct pi SDK imports and exposes narrow bridge-facing helpers/types for the extension composition layer
5
5
  */
6
6
  import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
7
- import { type AgentEndEvent, type AgentSettledEvent, type AgentStartEvent, type BeforeAgentStartEvent, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type InputEvent, type MessageEndEvent, type SessionBeforeCompactEvent, type SessionCompactEvent, type SessionShutdownEvent, type SessionStartEvent, type SlashCommandInfo, type TurnEndEvent, type TurnEndEventResult, type UIPromptEndEvent, type UIPromptStartEvent } from "@earendil-works/pi-coding-agent";
8
- export type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, ExtensionAPI, ExtensionCommandContext, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionShutdownEvent, SessionStartEvent, SlashCommandInfo, TurnEndEvent, TurnEndEventResult, UIPromptEndEvent, UIPromptStartEvent, };
7
+ import { type AgentEndEvent, type AgentSettledEvent, type AgentStartEvent, type BeforeAgentStartEvent, type BeforeAgentStartEventResult, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type InputEvent, type MessageEndEvent, type SessionBeforeCompactEvent, type SessionCompactEvent, type SessionShutdownEvent, type SessionStartEvent, type SlashCommandInfo, type TurnEndEvent, type TurnEndEventResult, type UIPromptEndEvent, type UIPromptStartEvent } from "@earendil-works/pi-coding-agent";
8
+ export type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, ExtensionAPI, ExtensionCommandContext, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionShutdownEvent, SessionStartEvent, SlashCommandInfo, TurnEndEvent, TurnEndEventResult, UIPromptEndEvent, UIPromptStartEvent, };
9
9
  export interface SessionCompactFailedEvent {
10
10
  type: "session_compact_failed";
11
11
  reason: "manual" | "threshold" | "overflow";
@@ -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;