@llblab/pi-telegram 0.50.0 → 0.51.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 (73) hide show
  1. package/AGENTS.md +9 -7
  2. package/BACKLOG.md +13 -32
  3. package/CHANGELOG.md +18 -0
  4. package/README.md +5 -5
  5. package/dist/lib/bindings.d.ts +3 -5
  6. package/dist/lib/bindings.js +4 -11
  7. package/dist/lib/bus-follower.d.ts +1 -1
  8. package/dist/lib/bus-follower.js +93 -9
  9. package/dist/lib/bus-leader.d.ts +9 -3
  10. package/dist/lib/bus-leader.js +231 -71
  11. package/dist/lib/bus.d.ts +4 -0
  12. package/dist/lib/bus.js +34 -7
  13. package/dist/lib/commands.d.ts +1 -4
  14. package/dist/lib/commands.js +13 -33
  15. package/dist/lib/extension.js +64 -6
  16. package/dist/lib/journal.d.ts +3 -0
  17. package/dist/lib/journal.js +13 -0
  18. package/dist/lib/lifecycle.d.ts +2 -0
  19. package/dist/lib/lifecycle.js +25 -4
  20. package/dist/lib/locks.d.ts +1 -0
  21. package/dist/lib/locks.js +32 -12
  22. package/dist/lib/menu-queue.d.ts +1 -0
  23. package/dist/lib/menu-queue.js +72 -1
  24. package/dist/lib/paths.d.ts +10 -0
  25. package/dist/lib/paths.js +22 -1
  26. package/dist/lib/polling.js +2 -2
  27. package/dist/lib/queue.d.ts +8 -1
  28. package/dist/lib/queue.js +28 -2
  29. package/dist/lib/replies.d.ts +3 -0
  30. package/dist/lib/replies.js +17 -0
  31. package/dist/lib/sync.d.ts +9 -0
  32. package/dist/lib/sync.js +49 -2
  33. package/dist/lib/telegram-api.d.ts +9 -1
  34. package/dist/lib/telegram-api.js +23 -3
  35. package/dist/lib/thread-cleanup-manager.js +7 -2
  36. package/dist/lib/threads.d.ts +2 -0
  37. package/dist/lib/threads.js +68 -37
  38. package/dist/lib/updates.d.ts +6 -4
  39. package/dist/lib/updates.js +134 -61
  40. package/dist/lib/workspace-admission.d.ts +9 -0
  41. package/dist/lib/workspace-admission.js +49 -3
  42. package/dist/lib/workspace-retirement.d.ts +77 -6
  43. package/dist/lib/workspace-retirement.js +384 -8
  44. package/dist/lib/workspace-slots.d.ts +3 -0
  45. package/dist/lib/workspace-slots.js +6 -0
  46. package/dist/package.json +1 -1
  47. package/docs/architecture.md +23 -14
  48. package/docs/multi-instance-bus.md +32 -11
  49. package/docs/public-api.md +1 -1
  50. package/docs/updates.md +2 -0
  51. package/lib/bindings.ts +5 -17
  52. package/lib/bus-follower.ts +69 -12
  53. package/lib/bus-leader.ts +236 -94
  54. package/lib/bus.ts +39 -9
  55. package/lib/commands.ts +18 -41
  56. package/lib/extension.ts +70 -10
  57. package/lib/journal.ts +14 -0
  58. package/lib/lifecycle.ts +23 -4
  59. package/lib/locks.ts +32 -13
  60. package/lib/menu-queue.ts +126 -1
  61. package/lib/paths.ts +29 -1
  62. package/lib/polling.ts +2 -2
  63. package/lib/queue.ts +48 -3
  64. package/lib/replies.ts +19 -0
  65. package/lib/sync.ts +53 -2
  66. package/lib/telegram-api.ts +30 -2
  67. package/lib/thread-cleanup-manager.ts +11 -3
  68. package/lib/threads.ts +73 -36
  69. package/lib/updates.ts +145 -78
  70. package/lib/workspace-admission.ts +67 -4
  71. package/lib/workspace-retirement.ts +465 -7
  72. package/lib/workspace-slots.ts +7 -0
  73. package/package.json +1 -1
package/dist/lib/paths.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
11
  import { homedir } from "node:os";
12
- import { join, resolve } from "node:path";
12
+ import { isAbsolute, join, resolve } from "node:path";
13
13
  export const TELEGRAM_DEFAULT_PROFILE_NAME = "default";
14
14
  /**
15
15
  * Resolve the agent data directory for the current Pi-compatible runtime.
@@ -33,6 +33,19 @@ export function resolveAgentDir(input = {}) {
33
33
  }
34
34
  return join(homedir(), ".pi", "agent");
35
35
  }
36
+ /**
37
+ * Pure reference preflight against an independently approved resource path.
38
+ * Reject aliases; never repair relative historical references or redirect storage.
39
+ * Equality proves spelling only, not file identity, consumer closure or migration readiness.
40
+ */
41
+ export function requireTelegramStoragePathReference(path, expectedPath) {
42
+ if (typeof path !== "string" || typeof expectedPath !== "string" ||
43
+ !isAbsolute(path) || resolve(path) !== path ||
44
+ !isAbsolute(expectedPath) || resolve(expectedPath) !== expectedPath || path !== expectedPath) {
45
+ throw new Error("Telegram storage reference does not match its approved absolute path.");
46
+ }
47
+ return path;
48
+ }
36
49
  /** Telegram bridge configuration file (<agentDir>/telegram.json). */
37
50
  export function resolveTelegramConfigPath() {
38
51
  return join(resolveAgentDir(), "telegram.json");
@@ -65,6 +78,10 @@ export function getTelegramDiagnosticsDisplayPaths(profileName) {
65
78
  export function resolveTelegramWorkspaceAdmissionPath(agentDir = resolveAgentDir(), profileName) {
66
79
  return resolveTelegramProfileTempFilePath("workspace-admission", "json", agentDir, profileName);
67
80
  }
81
+ /** Profile-only callback shape; binds storage to the configured agent directory. */
82
+ export function resolveTelegramWorkspaceAdmissionPathForProfile(profileName) {
83
+ return resolveTelegramWorkspaceAdmissionPath(resolveAgentDir(), profileName);
84
+ }
68
85
  /** Durable inactive Thread cleanup work-set journal. */
69
86
  export function resolveTelegramThreadCleanupWorkPath(agentDir = resolveAgentDir(), profileName) {
70
87
  return resolveTelegramProfileTempFilePath("thread-cleanup", "json", agentDir, profileName);
@@ -77,6 +94,10 @@ export function resolveTelegramChannelPostJournalPath(agentDir = resolveAgentDir
77
94
  export function resolveTelegramUpdateJournalPath(agentDir = resolveAgentDir(), profileName) {
78
95
  return resolveTelegramProfileTempFilePath("inbox", "json", agentDir, profileName);
79
96
  }
97
+ /** Profile-only callback shape; binds storage to the configured agent directory. */
98
+ export function resolveTelegramUpdateJournalPathForProfile(profileName) {
99
+ return resolveTelegramUpdateJournalPath(resolveAgentDir(), profileName);
100
+ }
80
101
  /** Durable follower delivery journal, isolated by stable recipient binding. */
81
102
  export function resolveTelegramFollowerJournalPath(recipientBindingKey, agentDir = resolveAgentDir(), profileName) {
82
103
  if (!recipientBindingKey) {
@@ -16,8 +16,8 @@ const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
16
16
  const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
17
17
  const TELEGRAM_POLLING_RETRY_MS = 3_000;
18
18
  export const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
19
- // Standard Telegram DM polling does not expose ordinary message-deletion events,
20
- // so queue removal stays reaction-driven while delete-like business updates remain defensive-only.
19
+ // Standard Telegram DM polling does not expose ordinary message-deletion events.
20
+ // Business deletions belong to a separate namespace and default routing ignores them.
21
21
  export const TELEGRAM_ALLOWED_UPDATES = [
22
22
  "message",
23
23
  "edited_message",
@@ -242,6 +242,11 @@ export declare function removeTelegramQueueItemsByMessageIds<TContext = unknown>
242
242
  removedItems: TelegramQueueItem<TContext>[];
243
243
  removedCount: number;
244
244
  };
245
+ export declare function removeTelegramQueuedGuestPromptByOrder<TContext = unknown>(items: TelegramQueueItem<TContext>[], queueOrder: number): {
246
+ items: TelegramQueueItem<TContext>[];
247
+ removedItems: PendingTelegramTurn[];
248
+ removedCount: number;
249
+ };
245
250
  export declare function applyTelegramQueuePromptReactionDisposition<TContext = unknown>(items: TelegramQueueItem<TContext>[], messageId: number, disposition: TelegramQueueReactionDisposition, destinationLaneOrder?: number, scope?: TelegramQueueMessageScope): {
246
251
  items: TelegramQueueItem<TContext>[];
247
252
  changed: boolean;
@@ -469,7 +474,7 @@ export interface TelegramAgentEndHookRuntimeDeps<TTurn extends PendingTelegramTu
469
474
  getActiveTurn: () => TTurn | undefined;
470
475
  loadConfig?: () => Promise<void>;
471
476
  extractAssistant: (messages: readonly TMessage[]) => TelegramAgentEndAssistantResult;
472
- isRecoveredAssistantAlreadyPublished?: (assistant: TelegramAgentEndAssistantResult) => boolean;
477
+ isAssistantAlreadyPublished?: (assistant: TelegramAgentEndAssistantResult) => boolean;
473
478
  getFoldQueuedPromptsIntoHistory: () => boolean;
474
479
  resetRuntimeState: () => void;
475
480
  isSessionActive?: (ctx: TContext) => boolean;
@@ -624,6 +629,7 @@ export interface TelegramQueueMutationController<TContext> {
624
629
  reorder: (ctx: TContext) => void;
625
630
  clear: (ctx: TContext) => number;
626
631
  removeByMessageIds: (messageIds: number[], ctx: TContext, scope?: TelegramQueueMessageScope) => number;
632
+ removeGuestPromptByQueueOrder?: (queueOrder: number, ctx: TContext) => boolean;
627
633
  applyReactionByMessageId: (messageId: number, disposition: TelegramQueueReactionDisposition, ctx: TContext, scope?: TelegramQueueMessageScope) => boolean;
628
634
  }
629
635
  export interface TelegramControlQueueControllerDeps<TContext> {
@@ -673,6 +679,7 @@ export declare function createTelegramQueueMutationController<TContext>(deps: Te
673
679
  export declare function reorderTelegramQueueItemsRuntime<TContext>(deps: TelegramQueueMutationRuntimeDeps<TContext>): void;
674
680
  export declare function clearTelegramQueueItemsRuntime<TContext>(deps: TelegramQueueMutationRuntimeDeps<TContext>): number;
675
681
  export declare function removeTelegramQueueItemsByMessageIdsRuntime<TContext>(messageIds: number[], deps: TelegramQueueMutationRuntimeDeps<TContext>, scope?: TelegramQueueMessageScope): number;
682
+ export declare function removeTelegramQueuedGuestPromptByOrderRuntime<TContext>(queueOrder: number, deps: TelegramQueueMutationRuntimeDeps<TContext>): boolean;
676
683
  export declare function applyTelegramQueuePromptReactionDispositionRuntime<TContext>(messageId: number, disposition: TelegramQueueReactionDisposition, deps: TelegramQueueMutationRuntimeDeps<TContext>, scope?: TelegramQueueMessageScope): boolean;
677
684
  export declare function enqueueTelegramPromptTurnRuntime<TMessage, TContext = unknown>(messages: TMessage[], deps: TelegramPromptEnqueueRuntimeDeps<TMessage, TContext>): Promise<PendingTelegramTurn>;
678
685
  export declare function createTelegramPromptEnqueueController<TMessage, TContext = unknown>(deps: TelegramPromptEnqueueControllerDeps<TMessage, TContext>): TelegramPromptEnqueueController<TMessage, TContext>;
package/dist/lib/queue.js CHANGED
@@ -491,6 +491,21 @@ export function removeTelegramQueueItemsByMessageIds(items, messageIds, scope) {
491
491
  removedCount: removedItems.length,
492
492
  };
493
493
  }
494
+ export function removeTelegramQueuedGuestPromptByOrder(items, queueOrder) {
495
+ const index = items.findIndex((item) => {
496
+ return isPendingTelegramTurn(item) &&
497
+ item.guestQueryId !== undefined &&
498
+ item.queueOrder === queueOrder;
499
+ });
500
+ if (index < 0)
501
+ return { items, removedItems: [], removedCount: 0 };
502
+ const removedItem = items[index];
503
+ return {
504
+ items: [...items.slice(0, index), ...items.slice(index + 1)],
505
+ removedItems: [removedItem],
506
+ removedCount: 1,
507
+ };
508
+ }
494
509
  export function applyTelegramQueuePromptReactionDisposition(items, messageId, disposition, destinationLaneOrder, scope) {
495
510
  let nextItems = items;
496
511
  for (const [index, item] of items.entries()) {
@@ -854,8 +869,7 @@ export function createTelegramAgentEndHook(deps) {
854
869
  return;
855
870
  const turn = deps.getActiveTurn();
856
871
  const extractedAssistant = assistantOverride ?? (turn ? deps.extractAssistant(event.messages) : {});
857
- const assistant = extractedAssistant.recoveredFromEarlier &&
858
- deps.isRecoveredAssistantAlreadyPublished?.(extractedAssistant)
872
+ const assistant = deps.isAssistantAlreadyPublished?.(extractedAssistant)
859
873
  ? { stopReason: extractedAssistant.stopReason }
860
874
  : extractedAssistant;
861
875
  const hasPublication = !!assistant.text || assistant.stopReason === "error" || !!turn?.queuedAttachments.length;
@@ -1440,6 +1454,7 @@ export function createTelegramQueueMutationController(deps) {
1440
1454
  reorder: (ctx) => reorderTelegramQueueItemsRuntime(buildRuntimeDeps(ctx)),
1441
1455
  clear: (ctx) => clearTelegramQueueItemsRuntime(buildRuntimeDeps(ctx)),
1442
1456
  removeByMessageIds: (messageIds, ctx, scope) => removeTelegramQueueItemsByMessageIdsRuntime(messageIds, buildRuntimeDeps(ctx), scope),
1457
+ removeGuestPromptByQueueOrder: (queueOrder, ctx) => removeTelegramQueuedGuestPromptByOrderRuntime(queueOrder, buildRuntimeDeps(ctx)),
1443
1458
  applyReactionByMessageId: (messageId, disposition, ctx, scope) => applyTelegramQueuePromptReactionDispositionRuntime(messageId, disposition, buildRuntimeDeps(ctx), scope),
1444
1459
  };
1445
1460
  }
@@ -1492,6 +1507,17 @@ export function removeTelegramQueueItemsByMessageIdsRuntime(messageIds, deps, sc
1492
1507
  updateTelegramQueueStatusRuntime(deps);
1493
1508
  return removedCount;
1494
1509
  }
1510
+ export function removeTelegramQueuedGuestPromptByOrderRuntime(queueOrder, deps) {
1511
+ const { items, removedItems, removedCount } = removeTelegramQueuedGuestPromptByOrder(deps.getQueuedItems(), queueOrder);
1512
+ if (removedCount === 0)
1513
+ return false;
1514
+ // A menu Skip means permanent discard, so settle durable admission before
1515
+ // removing the live item. A failed settlement keeps the prompt retryable.
1516
+ deps.onItemsDiscarded?.(removedItems, deps.ctx);
1517
+ deps.setQueuedItems(items);
1518
+ updateTelegramQueueStatusRuntime(deps);
1519
+ return true;
1520
+ }
1495
1521
  export function applyTelegramQueuePromptReactionDispositionRuntime(messageId, disposition, deps, scope) {
1496
1522
  const queuedItems = deps.getQueuedItems();
1497
1523
  const changesLane = queuedItems.some((item) => {
@@ -190,6 +190,7 @@ export declare const TELEGRAM_GUEST_PLACEHOLDER_FRAME_MS = 1000;
190
190
  export declare const TELEGRAM_GUEST_PLACEHOLDER_MIN_MS = 20000;
191
191
  export declare const TELEGRAM_GUEST_PLACEHOLDER_MAX_MS = 26000;
192
192
  export declare const TELEGRAM_GUEST_PLACEHOLDER_FRAMES: readonly ["<b>🌎 Working on it.</b>", "<b>🌍 Working on it.</b>", "<b>🌏 Working on it..</b>", "<b>🌎 Working on it..</b>", "<b>🌍 Working on it...</b>", "<b>🌏 Working on it...</b>"];
193
+ export declare const TELEGRAM_DISMISSED_GUEST_PLACEHOLDER_TEXT = "\u2063";
193
194
  export declare function buildTelegramGuestPlaceholderFrame(step: number): string;
194
195
  export interface TelegramGuestPlaceholderRuntimeDeps {
195
196
  editGuestInlineMessage: (inlineMessageId: string, content: {
@@ -211,5 +212,7 @@ export interface TelegramGuestPlaceholderRuntime {
211
212
  stop: (inlineMessageId: string) => Promise<void>;
212
213
  /** Cancels every loop without waiting for in-flight edits (session shutdown). */
213
214
  stopAll: () => void;
215
+ /** Stops rotation and visually clears the inline placeholder. */
216
+ dismiss: (inlineMessageId: string) => Promise<void>;
214
217
  }
215
218
  export declare function createTelegramGuestPlaceholderRuntime(deps: TelegramGuestPlaceholderRuntimeDeps): TelegramGuestPlaceholderRuntime;
@@ -612,6 +612,9 @@ export const TELEGRAM_GUEST_PLACEHOLDER_FRAMES = [
612
612
  "<b>🌍 Working on it...</b>",
613
613
  "<b>🌏 Working on it...</b>",
614
614
  ];
615
+ // Telegram does not expose deletion for inline messages. This non-printing,
616
+ // non-empty edit visually clears a guest placeholder after its query is skipped.
617
+ export const TELEGRAM_DISMISSED_GUEST_PLACEHOLDER_TEXT = "\u2063";
615
618
  export function buildTelegramGuestPlaceholderFrame(step) {
616
619
  const frames = TELEGRAM_GUEST_PLACEHOLDER_FRAMES;
617
620
  const index = ((step % frames.length) + frames.length) % frames.length;
@@ -734,5 +737,19 @@ export function createTelegramGuestPlaceholderRuntime(deps) {
734
737
  }
735
738
  sessions.clear();
736
739
  },
740
+ async dismiss(inlineMessageId) {
741
+ await stop(inlineMessageId);
742
+ try {
743
+ await deps.editGuestInlineMessage(inlineMessageId, {
744
+ text: TELEGRAM_DISMISSED_GUEST_PLACEHOLDER_TEXT,
745
+ parseMode: "HTML",
746
+ });
747
+ }
748
+ catch (error) {
749
+ deps.recordRuntimeEvent?.("guest", error, {
750
+ phase: "guest-placeholder-dismiss",
751
+ });
752
+ }
753
+ },
737
754
  };
738
755
  }
@@ -97,6 +97,15 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
97
97
  export declare function markTelegramConfigSyncChange<TSyncState extends TelegramSyncState>(state: TSyncState, action: string, options?: {
98
98
  nowMs?: number;
99
99
  }): TSyncState;
100
+ export declare function createTelegramPreservedLeaderQuitHandler(deps: {
101
+ instanceId: string;
102
+ topicTargetStore: Pick<TelegramTopicTargetStore, "load" | "list" | "listPendingCleanups" | "detachTargetOwner">;
103
+ getCurrentLeaderEpoch: () => number | string | undefined;
104
+ getProfileName: () => string | undefined;
105
+ isPollingSuspended: () => boolean;
106
+ resolveAutomaticThreadCleanupEnabled: () => boolean | Promise<boolean>;
107
+ runWorkspaceOperation: TelegramSyncWorkspaceOperationRunner;
108
+ }): (isSessionCurrent: () => boolean) => (() => Promise<void>) | undefined;
100
109
  export interface TelegramSessionRestartThreadCleanupDeps<TSyncState extends TelegramSyncState> extends Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> {
101
110
  suspendPolling: () => Promise<void>;
102
111
  }
package/dist/lib/sync.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from "./telegram-api.js";
7
7
  import { getTelegramTargetKey } from "./target.js";
8
8
  import * as ThreadReconciler from "./thread-reconciler.js";
9
+ import { TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.js";
9
10
  import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
10
11
  import { createTelegramCleanupTargetProtection, commitTelegramWorkspaceProvisionBinding, getTelegramTargetFromApiBody, getTelegramThreadOwnerKey, isSameTelegramProcessInstance, isTelegramTopicTargetStaleError, normalizeTelegramWorkspacePath, provisionOwnBusTopic, } from "./threads.js";
11
12
  export function markTelegramConfigSyncChange(state, action, options) {
@@ -24,6 +25,42 @@ export function markTelegramConfigSyncChange(state, action, options) {
24
25
  });
25
26
  return nextState;
26
27
  }
28
+ export function createTelegramPreservedLeaderQuitHandler(deps) {
29
+ return (isSessionCurrent) => {
30
+ const epoch = deps.getCurrentLeaderEpoch();
31
+ const profile = deps.getProfileName();
32
+ if (epoch === undefined || !isSessionCurrent())
33
+ return undefined;
34
+ const isCurrent = () => isSessionCurrent() && deps.getCurrentLeaderEpoch() === epoch &&
35
+ deps.getProfileName() === profile && deps.isPollingSuspended();
36
+ return async () => {
37
+ if (!isCurrent() || await deps.resolveAutomaticThreadCleanupEnabled() !== false)
38
+ return;
39
+ if (!isCurrent())
40
+ return;
41
+ await deps.runWorkspaceOperation({
42
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
43
+ operationKind: "workspace.preserve-leader-quit",
44
+ scopes: [{ kind: "profile" }],
45
+ }, async () => {
46
+ if (!isCurrent())
47
+ return;
48
+ await deps.topicTargetStore.load();
49
+ if (!isCurrent())
50
+ return;
51
+ const records = deps.topicTargetStore.list().filter((record) => record.instanceId === deps.instanceId && (record.status === "active" || record.status === "starting"));
52
+ if (records.length !== 1)
53
+ return;
54
+ const record = records[0];
55
+ if (deps.topicTargetStore.listPendingCleanups().some((intent) => intent.target.chatId === record.target.chatId && intent.target.threadId === record.target.threadId))
56
+ return;
57
+ if (!await deps.topicTargetStore.detachTargetOwner(record, isCurrent) && isCurrent()) {
58
+ throw new Error("Telegram preserved leader detachment was not committed.");
59
+ }
60
+ });
61
+ };
62
+ };
63
+ }
27
64
  export function createTelegramSessionRestartThreadCleanupHandler(deps) {
28
65
  return createTelegramManualThreadDisconnectHandler({
29
66
  ...deps,
@@ -353,10 +390,13 @@ export async function ensureTelegramLeaderThreadBinding(deps) {
353
390
  };
354
391
  const leaderProfileKey = getTelegramThreadOwnerKey(leaderOwner);
355
392
  const legacyLeaderRecord = deps.topicTargetStore.getByProfileKey(leaderProfileKey);
393
+ let capacityUnavailable = false;
356
394
  const workspaceIdentity = normalizedLeaderCwd
357
- ? deps.topicTargetStore.claimWorkspaceIdentity(normalizedLeaderCwd, deps.instanceId, legacyLeaderRecord?.instanceId, { sessionId: deps.sessionId })
395
+ ? deps.topicTargetStore.claimWorkspaceIdentity(normalizedLeaderCwd, deps.instanceId, legacyLeaderRecord?.instanceId, { sessionId: deps.sessionId, onCapacityUnavailable() { capacityUnavailable = true; } })
358
396
  : undefined;
359
397
  if (deps.cwd && !workspaceIdentity) {
398
+ if (capacityUnavailable)
399
+ throw new TelegramWorkspaceSlotUnavailableError();
360
400
  throw new Error("Telegram Workspace identity is already claimed.");
361
401
  }
362
402
  const commitWorkspaceBinding = async (result) => {
@@ -381,7 +421,14 @@ export async function ensureTelegramLeaderThreadBinding(deps) {
381
421
  assertLeaderEpoch("before-workspace-persist");
382
422
  await deps.topicTargetStore.persist();
383
423
  assertLeaderEpoch("after-workspace-persist");
384
- return { ...result, ...(committed.displayTitle ? { displayTitle: committed.displayTitle } : {}) };
424
+ const { target: _target, slot: _slot, threadName: _threadName, displayTitle: _displayTitle, ...rest } = result;
425
+ return {
426
+ ...rest,
427
+ target: { ...committed.target },
428
+ slot: committed.slot ?? result.slot,
429
+ ...(committed.threadName ? { threadName: committed.threadName } : {}),
430
+ ...(committed.displayTitle ? { displayTitle: committed.displayTitle } : {}),
431
+ };
385
432
  };
386
433
  try {
387
434
  const unavailableTargetKeys = new Set([
@@ -484,6 +484,8 @@ export declare function getTelegramApiErrorRequestTarget(error: unknown): {
484
484
  threadId: number;
485
485
  } | undefined;
486
486
  export declare function isTelegramStaleTargetHttpError(error: unknown): boolean;
487
+ /** Only a parsed Telegram rejection of this method proves a request had no effect. */
488
+ export declare function isTelegramApiRequestRejected(error: unknown, method: string): boolean;
487
489
  export declare function isTelegramMessageNotModifiedError(error: unknown): boolean;
488
490
  export declare function isTelegramApiMethodRetrySafe(method: string): boolean;
489
491
  export declare function isRetryableTelegramApiError(error: unknown): boolean;
@@ -522,13 +524,19 @@ export declare function createTelegramAssistantDraftSender(deps: {
522
524
  sendRichMessageDraft: TelegramBridgeApiRuntime["sendRichMessageDraft"];
523
525
  }): TelegramBridgeApiRuntime["sendMessageDraft"];
524
526
  export declare function buildTelegramAnswerGuestQueryBody(guestQueryId: string, text?: string, options?: TelegramAnswerGuestQueryOptions): Record<string, unknown>;
527
+ export type TelegramWorkspaceThreadDeletionTransport = (authorize: () => {
528
+ chatId: number;
529
+ threadId: number;
530
+ }) => Promise<void>;
525
531
  export declare function createDefaultTelegramBridgeApiRuntime(deps: {
526
532
  getBotToken: () => string | undefined;
527
533
  recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
528
534
  captureRequestErrorHandler?: TelegramBridgeApiRuntimeDeps["captureRequestErrorHandler"];
529
535
  targetActivity?: TelegramApiTargetActivityRuntime;
530
536
  workspaceAdmission?: TelegramApiWorkspaceAdmissionPort | (() => TelegramApiWorkspaceAdmissionPort | undefined);
531
- }): TelegramBridgeApiRuntime;
537
+ }): TelegramBridgeApiRuntime & {
538
+ deleteWorkspaceThread: TelegramWorkspaceThreadDeletionTransport;
539
+ };
532
540
  export declare function createTelegramBridgeApiRuntime(deps: TelegramBridgeApiRuntimeDeps): TelegramBridgeApiRuntime;
533
541
  /**
534
542
  * Creates a low-level Telegram Bot API client.
@@ -231,11 +231,13 @@ export function isTelegramApiCommitUnknownError(error) {
231
231
  class TelegramApiHttpError extends Error {
232
232
  status;
233
233
  retryAfterSeconds;
234
+ rejectedRequestMethod;
234
235
  requestTarget;
235
- constructor(message, status, retryAfterSeconds) {
236
+ constructor(message, status, retryAfterSeconds, rejectedRequestMethod) {
236
237
  super(message);
237
238
  this.status = status;
238
239
  this.retryAfterSeconds = retryAfterSeconds;
240
+ this.rejectedRequestMethod = rejectedRequestMethod;
239
241
  }
240
242
  }
241
243
  function attachTelegramApiRequestTarget(error, body) {
@@ -267,6 +269,11 @@ export function isTelegramStaleTargetHttpError(error) {
267
269
  return false;
268
270
  return /^Telegram API \w+ failed: HTTP 400: Bad Request: (message thread not found|thread not found|topic not found|topic deleted|topic closed|thread closed|forum topic closed|message thread closed|topic_id_invalid|topic_closed)$/i.test(error.message);
269
271
  }
272
+ /** Only a parsed Telegram rejection of this method proves a request had no effect. */
273
+ export function isTelegramApiRequestRejected(error, method) {
274
+ return error instanceof TelegramApiHttpError && error.rejectedRequestMethod !== undefined &&
275
+ error.rejectedRequestMethod === method;
276
+ }
270
277
  export function isTelegramMessageNotModifiedError(error) {
271
278
  return (error instanceof Error && error.message.includes("message is not modified"));
272
279
  }
@@ -398,7 +405,8 @@ async function parseTelegramApiResponse(response, method) {
398
405
  const retryAfterHeader = response.headers?.get("retry-after");
399
406
  const retryAfterSeconds = data?.parameters?.retry_after ??
400
407
  (retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : undefined);
401
- throw new TelegramApiHttpError(`Telegram API ${method} failed: ${status}${description}`, response.status, Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined);
408
+ throw new TelegramApiHttpError(`Telegram API ${method} failed: ${status}${description}`, response.status, Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined, data?.ok === false && data.error_code === response.status &&
409
+ [400, 401, 403, 404, 429].includes(response.status) ? method : undefined);
402
410
  }
403
411
  if (!data) {
404
412
  throw new TelegramApiMalformedSuccessError(method, "returned invalid JSON");
@@ -856,7 +864,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps) {
856
864
  },
857
865
  })
858
866
  : client;
859
- return createTelegramBridgeApiRuntime({
867
+ const runtime = createTelegramBridgeApiRuntime({
860
868
  client: deps.targetActivity
861
869
  ? createTelegramApiTargetTrackingClient(admittedClient, deps.targetActivity)
862
870
  : admittedClient,
@@ -866,6 +874,18 @@ export function createDefaultTelegramBridgeApiRuntime(deps) {
866
874
  recordRuntimeEvent: deps.recordRuntimeEvent,
867
875
  captureRequestErrorHandler: deps.captureRequestErrorHandler,
868
876
  });
877
+ return {
878
+ ...runtime,
879
+ async deleteWorkspaceThread(authorize) {
880
+ // The exclusive deletion permit replaces ordinary target admission.
881
+ const target = authorize();
882
+ const deleted = await client.call("deleteForumTopic", {
883
+ chat_id: target.chatId, message_thread_id: target.threadId,
884
+ }, { maxAttempts: 1, retrySafety: "non-idempotent" });
885
+ if (deleted !== true)
886
+ throw new Error("Telegram Workspace Thread deletion was not confirmed.");
887
+ },
888
+ };
869
889
  }
870
890
  export function createTelegramBridgeApiRuntime(deps) {
871
891
  const recoverRequestError = async (handler, error) => {
@@ -342,7 +342,9 @@ export function createTelegramThreadCleanupWorkStore(options) {
342
342
  return { confirmed: true, entry: structuredClone(entry) };
343
343
  });
344
344
  },
345
- list() { return structuredClone(read().workSets); },
345
+ list() {
346
+ return structuredClone(withTelegramFileTransaction(`${options.path}.transaction`, () => read().workSets));
347
+ },
346
348
  };
347
349
  }
348
350
  export async function commitTelegramInactiveThreadCleanup(input) {
@@ -401,7 +403,10 @@ export function createTelegramThreadCleanupPermitRuntime(deps) {
401
403
  if (!deps.canAdoptFence(fence))
402
404
  return undefined;
403
405
  try {
404
- fence = deps.ledger.adoptThreadCleanupFence(fence, { owner, leaderEpoch });
406
+ const adopted = deps.ledger.adoptThreadCleanupFence(fence, { owner, leaderEpoch });
407
+ if (adopted.destructiveKind !== "manual-thread-cleanup")
408
+ return undefined;
409
+ fence = adopted;
405
410
  }
406
411
  catch {
407
412
  return undefined;
@@ -212,6 +212,8 @@ export interface TelegramTopicTargetStore {
212
212
  refresh?: () => Promise<void>;
213
213
  persist: () => Promise<void>;
214
214
  invalidateTarget: (target: TelegramTarget, isCurrent: () => boolean, lastSyncError: string) => Promise<boolean>;
215
+ /** Caller proves owner detachment; this does not assert Telegram Thread absence. */
216
+ detachTargetOwner: (expected: TelegramTopicTargetRecord, isCurrent: () => boolean) => Promise<boolean>;
215
217
  list: () => TelegramTopicTargetRecord[];
216
218
  getFollowerRecoveryHintByTarget?: (target: TelegramTarget) => {
217
219
  slot?: string;
@@ -11,7 +11,7 @@ import { isDeepStrictEqual } from "node:util";
11
11
  import { isTelegramApiCommitUnknownError, TelegramApiCommitUnknownError, } from "./telegram-api.js";
12
12
  import { withTelegramFileTransaction } from "./locks.js";
13
13
  import * as ThreadReconciler from "./thread-reconciler.js";
14
- import { planTelegramWorkspaceSlotAllocation, TELEGRAM_WORKSPACE_SLOTS, } from "./workspace-slots.js";
14
+ import { planTelegramWorkspaceSlotAllocation, TELEGRAM_WORKSPACE_SLOTS, TelegramWorkspaceSlotUnavailableError, } from "./workspace-slots.js";
15
15
  import { resolveAgentDir, resolveTelegramProfileTempFilePath, } from "./paths.js";
16
16
  const TELEGRAM_WORKSPACE_KEY_MAX_LENGTH = 180;
17
17
  const TELEGRAM_SESSION_ID_MAX_LENGTH = 256;
@@ -1301,19 +1301,31 @@ export function createTelegramTopicTargetStore(options) {
1301
1301
  dirty = true;
1302
1302
  mutationRevision += 1;
1303
1303
  };
1304
- const persistSnapshot = (invalidation) => {
1304
+ const markWorkspaceBindingInactiveByTarget = (target, inactiveSinceMs = getNowMs()) => {
1305
+ if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0)
1306
+ return false;
1307
+ for (const [key, binding] of workspaceBindings) {
1308
+ if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined)
1309
+ continue;
1310
+ workspaceBindings.set(key, { ...binding, inactiveSinceMs });
1311
+ markDirty();
1312
+ return true;
1313
+ }
1314
+ return false;
1315
+ };
1316
+ const persistSnapshot = (transition) => {
1305
1317
  const persist = persistQueue.then(async () => {
1306
1318
  const path = getPath();
1307
1319
  if (loadedPath !== path && !dirty)
1308
1320
  resetForPath(path);
1309
1321
  if (options.canPersist && !options.canPersist()) {
1310
- if (!invalidation)
1322
+ if (!transition)
1311
1323
  await loadFromDisk();
1312
1324
  return false;
1313
1325
  }
1314
1326
  if (!dirty || !loaded)
1315
1327
  await loadFromDisk();
1316
- if (invalidation && !invalidation.isCurrent())
1328
+ if (transition && !transition.isCurrent())
1317
1329
  return false;
1318
1330
  const nowMs = getNowMs();
1319
1331
  reservations = reservations.filter((reservation) => reservation.expiresAtMs === undefined ||
@@ -1322,6 +1334,11 @@ export function createTelegramTopicTargetStore(options) {
1322
1334
  const currentRecords = Array.from(records.values())
1323
1335
  .filter(isPersistedThreadRecord)
1324
1336
  .map(cloneRecord);
1337
+ if (transition?.kind === "detach") {
1338
+ const owners = currentRecords.filter((record) => targetMatches(record.target, transition.target));
1339
+ if (owners.length !== 1 || !isDeepStrictEqual(normalizeRecord(owners[0]), transition.owner))
1340
+ return false;
1341
+ }
1325
1342
  records = new Map(currentRecords.map((record) => [
1326
1343
  getRecordOwnerKey(record),
1327
1344
  cloneRecord(record),
@@ -1358,21 +1375,34 @@ export function createTelegramTopicTargetStore(options) {
1358
1375
  return serialized;
1359
1376
  }),
1360
1377
  };
1361
- if (invalidation) {
1362
- const record = file.threads.find((record) => targetMatches(record.target, invalidation.target));
1378
+ if (transition) {
1379
+ const record = file.threads.find((record) => targetMatches(record.target, transition.target));
1363
1380
  if (!record)
1364
1381
  return false;
1382
+ if (transition.kind === "detach") {
1383
+ const bindings = file.workspaceBindings.filter((binding) => targetMatches(binding.target, record.target));
1384
+ if (bindings.length !== 1 || !/^[A-Z]$/.test(record.slot ?? "") ||
1385
+ bindings[0].slot !== record.slot)
1386
+ return false;
1387
+ }
1365
1388
  file.threads = file.threads.filter((candidate) => candidate !== record);
1366
- file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
1367
- file.syncObservations.push({
1368
- target: { ...record.target },
1369
- syncStatus: "deleted",
1370
- observedAtMs: nowMs,
1371
- ...(record.instanceId ? { instanceId: record.instanceId } : {}),
1372
- ...(record.slot ? { slot: record.slot } : {}),
1373
- lastSyncError: invalidation.lastSyncError,
1374
- lastReconcileAction: "mark-stale",
1375
- });
1389
+ for (const binding of file.workspaceBindings) {
1390
+ if (targetMatches(binding.target, transition.target) && binding.inactiveSinceMs === undefined) {
1391
+ binding.inactiveSinceMs = nowMs;
1392
+ }
1393
+ }
1394
+ if (transition.kind === "invalidate") {
1395
+ file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
1396
+ file.syncObservations.push({
1397
+ target: { ...record.target },
1398
+ syncStatus: "deleted",
1399
+ observedAtMs: nowMs,
1400
+ ...(record.instanceId ? { instanceId: record.instanceId } : {}),
1401
+ ...(record.slot ? { slot: record.slot } : {}),
1402
+ lastSyncError: transition.lastSyncError,
1403
+ lastReconcileAction: "mark-stale",
1404
+ });
1405
+ }
1376
1406
  }
1377
1407
  let persistedSemanticSnapshot;
1378
1408
  try {
@@ -1382,7 +1412,7 @@ export function createTelegramTopicTargetStore(options) {
1382
1412
  /* missing or invalid snapshots must be replaced */
1383
1413
  }
1384
1414
  // Normalize optional fields to wire JSON; object key order is not a state change.
1385
- if (isDeepStrictEqual(persistedSemanticSnapshot, getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file)))) &&
1415
+ if (!transition && isDeepStrictEqual(persistedSemanticSnapshot, getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file)))) &&
1386
1416
  mutationRevision === persistedRevision &&
1387
1417
  statusRevision === persistedStatusRevision) {
1388
1418
  loadedPath = path;
@@ -1398,18 +1428,21 @@ export function createTelegramTopicTargetStore(options) {
1398
1428
  });
1399
1429
  await chmod(tempPath, 0o600);
1400
1430
  try {
1401
- if (invalidation) {
1431
+ if (transition) {
1402
1432
  let applied = false;
1403
1433
  const commit = () => {
1404
1434
  if (getPath() !== path || mutationRevision !== persistedRevision ||
1405
- statusRevision !== persistedStatusRevision || !invalidation.isCurrent())
1435
+ statusRevision !== persistedStatusRevision || !transition.isCurrent())
1406
1436
  return;
1407
1437
  // Fence and rename share one synchronous commit boundary. No stale
1408
- // invalidation enters the live projection before durable commit.
1438
+ // transition enters the live projection before durable commit.
1409
1439
  renameSync(tempPath, path);
1410
1440
  loadedPath = path;
1411
- records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, invalidation.target)));
1441
+ records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, transition.target)));
1412
1442
  syncObservations = file.syncObservations;
1443
+ workspaceBindings = new Map(file.workspaceBindings.map((binding) => [
1444
+ getWorkspaceBindingMapKey(binding), cloneWorkspaceBinding(binding),
1445
+ ]));
1413
1446
  mutationRevision += 1;
1414
1447
  dirty = false;
1415
1448
  applied = true;
@@ -1465,7 +1498,13 @@ export function createTelegramTopicTargetStore(options) {
1465
1498
  await persistSnapshot();
1466
1499
  },
1467
1500
  invalidateTarget(target, isCurrent, lastSyncError) {
1468
- return persistSnapshot({ target, isCurrent, lastSyncError });
1501
+ return persistSnapshot({ kind: "invalidate", target, isCurrent, lastSyncError });
1502
+ },
1503
+ detachTargetOwner(expected, isCurrent) {
1504
+ const owner = normalizeRecord(expected);
1505
+ if (!owner)
1506
+ return Promise.resolve(false);
1507
+ return persistSnapshot({ kind: "detach", owner, target: owner.target, isCurrent });
1469
1508
  },
1470
1509
  list() {
1471
1510
  return Array.from(records.values()).map(cloneRecord);
@@ -1994,18 +2033,7 @@ export function createTelegramTopicTargetStore(options) {
1994
2033
  markDirty();
1995
2034
  return true;
1996
2035
  },
1997
- markWorkspaceBindingInactiveByTarget(target, inactiveSinceMs = getNowMs()) {
1998
- if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0)
1999
- return false;
2000
- for (const [key, binding] of workspaceBindings) {
2001
- if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined)
2002
- continue;
2003
- workspaceBindings.set(key, { ...binding, inactiveSinceMs });
2004
- markDirty();
2005
- return true;
2006
- }
2007
- return false;
2008
- },
2036
+ markWorkspaceBindingInactiveByTarget,
2009
2037
  markWorkspaceBindingActiveByTarget(target) {
2010
2038
  if (hasWorkspaceRetirementConflict({ target }))
2011
2039
  return false;
@@ -2419,8 +2447,11 @@ export function createTelegramTopicTargetStore(options) {
2419
2447
  const record = Array.from(records.values()).find((entry) => targetMatches(entry.target, target));
2420
2448
  const pending = syncStatus === "deleted" ? pendingProvisions.find((entry) => entry.target && targetMatches(entry.target, target)) : undefined;
2421
2449
  const source = record ?? pending;
2450
+ // Confirmed absence ends this binding's active target lifetime, even after
2451
+ // an earlier stale observation already removed its live record.
2452
+ const inactive = syncStatus === "deleted" && markWorkspaceBindingInactiveByTarget(target);
2422
2453
  if (!source?.target)
2423
- return false;
2454
+ return inactive;
2424
2455
  syncObservations = syncObservations.filter((entry) => !targetMatches(entry.target, target));
2425
2456
  syncObservations.push({
2426
2457
  target: { ...source.target }, syncStatus, observedAtMs: getNowMs(),
@@ -3576,7 +3607,7 @@ export function createTelegramTopicTargetProvisioner(deps) {
3576
3607
  if (existing && isCurrentThreadRecord(existing)) {
3577
3608
  const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
3578
3609
  if (!slot) {
3579
- throw new Error("Telegram Workspace slot reservation is unavailable.");
3610
+ throw new TelegramWorkspaceSlotUnavailableError();
3580
3611
  }
3581
3612
  const occupied = listOccupiedTelegramThreadIdentities({
3582
3613
  records: deps.store.list(),
@@ -3691,7 +3722,7 @@ export function createTelegramTopicTargetProvisioner(deps) {
3691
3722
  (candidateThreadName ? undefined : identity?.slot) ??
3692
3723
  preferredNameSlot), request.workspaceBindingKey);
3693
3724
  if (!slot) {
3694
- throw new Error("Telegram Workspace slot reservation is unavailable.");
3725
+ throw new TelegramWorkspaceSlotUnavailableError();
3695
3726
  }
3696
3727
  const uniqueCandidate = candidateThreadName &&
3697
3728
  isTelegramTopicThreadNameValidForSlot(candidateThreadName, slot) &&