@llblab/pi-telegram 0.56.0 → 0.56.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.
@@ -113,6 +113,8 @@ export declare function createTelegramLiveRebindCoordinator(input: {
113
113
  operationId: string;
114
114
  /** Body-free copy of the authenticated saved command reference: correlation only, never authority, an ACK or a cleanup permit. */
115
115
  selectedCommandReference(): TelegramLiveRebindSelectedCommandReference | undefined;
116
+ /** Observe only an already-issued leader command; never prepares, relocates or executes input. */
117
+ observeCommandCompletion(): "unknown" | "released" | undefined;
116
118
  advance(): Promise<"protected" | "unknown" | "released">;
117
119
  };
118
120
  /** One admitted Restore attempt through recipient readiness, never source dispatch or cleanup. */
@@ -212,6 +214,8 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
212
214
  /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
213
215
  temporaryThreadCleanupDelayMs?: number;
214
216
  /** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
217
+ /** Bounded ACK observation inside the original chooser admission, independent from menu/reply delivery. */
218
+ liveRebindCommandCompletionTimeoutMs?: number;
215
219
  liveRebindCleanupSchedule?: {
216
220
  delaysMs: readonly number[];
217
221
  intervalMs: number;
@@ -584,6 +584,20 @@ export function createTelegramLiveRebindCoordinator(input) {
584
584
  }
585
585
  : undefined;
586
586
  },
587
+ /** Observe only an already-issued leader command; never prepares, relocates or executes input. */
588
+ observeCommandCompletion() {
589
+ if (!continuationIssued || !leader?.complete)
590
+ return undefined;
591
+ if (!current())
592
+ return "unknown";
593
+ try {
594
+ return observeCommandCompletion();
595
+ }
596
+ catch (error) {
597
+ input.recordRuntimeEvent?.("telegram", error, { phase: "live-rebind-command-observation" });
598
+ return "unknown";
599
+ }
600
+ },
587
601
  async advance() {
588
602
  if (advancing || !current())
589
603
  return "protected";
@@ -3100,7 +3114,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3100
3114
  Bus.isSameTelegramBusFollowerRegistration(live, follower) &&
3101
3115
  [
3102
3116
  ...Bus.TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES,
3103
- ...(heldSelected ? Bus.TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES : []),
3117
+ ...(heldSelected ? Bus.getTelegramBusHeldCommandCapabilities(heldSelected.name) : []),
3104
3118
  ].every((cap) => Bus.hasTelegramBusCapability(live.protocol, cap)) &&
3105
3119
  !!live.target &&
3106
3120
  live.target.chatId === binding.target.chatId &&
@@ -3653,6 +3667,7 @@ export function createTelegramInboundRouteRuntime(deps) {
3653
3667
  return {
3654
3668
  operationId: request.operationId,
3655
3669
  selectedCommandReference: () => coordinator?.selectedCommandReference(),
3670
+ observeCommandCompletion: () => coordinator?.observeCommandCompletion(),
3656
3671
  async advance() {
3657
3672
  if (!coordinator) {
3658
3673
  if (!current() || !getRecipient())
@@ -6184,6 +6199,52 @@ export function createTelegramInboundRouteRuntime(deps) {
6184
6199
  const operationId = selection.coordinator.operationId;
6185
6200
  if (current())
6186
6201
  outcome = await selection.coordinator.advance();
6202
+ const completionTimeoutMs = deps.liveRebindCommandCompletionTimeoutMs ?? 0;
6203
+ if (outcome === "unknown" && !selection.commandCompletionWaited && selection.record.owner?.kind === "leader" &&
6204
+ (selection.helpCommand || selection.menuCommand || selection.abortCommand || selection.nextCommand || selection.stopCommand) &&
6205
+ Number.isFinite(completionTimeoutMs) && completionTimeoutMs > 0 &&
6206
+ selection.coordinator.observeCommandCompletion() !== undefined) {
6207
+ selection.commandCompletionWaited = true;
6208
+ const coordinator = selection.coordinator;
6209
+ const signal = Updates.getTelegramUpdateExecutionFence(query)?.signal;
6210
+ outcome = await new Promise((resolve) => {
6211
+ let settled = false;
6212
+ const subscriptions = [];
6213
+ const finish = (result) => {
6214
+ if (settled)
6215
+ return;
6216
+ settled = true;
6217
+ clearTimeout(timer);
6218
+ for (const unsubscribe of subscriptions)
6219
+ unsubscribe();
6220
+ signal?.removeEventListener("abort", abort);
6221
+ resolve(result);
6222
+ };
6223
+ const abort = () => finish("unknown");
6224
+ const observe = () => {
6225
+ if (settled)
6226
+ return;
6227
+ if (!current()) {
6228
+ finish("unknown");
6229
+ return;
6230
+ }
6231
+ if (coordinator.observeCommandCompletion() === "released")
6232
+ finish("released");
6233
+ };
6234
+ const timer = setTimeout(abort, completionTimeoutMs);
6235
+ timer.unref?.();
6236
+ for (const message of selection.messages) {
6237
+ const unsubscribe = Updates.subscribeTelegramDeferredSourceCompletion(message, observe);
6238
+ if (unsubscribe)
6239
+ subscriptions.push(unsubscribe);
6240
+ }
6241
+ signal?.addEventListener("abort", abort, { once: true });
6242
+ if (signal?.aborted || !current())
6243
+ abort();
6244
+ else if (coordinator.observeCommandCompletion() === "released")
6245
+ finish("released");
6246
+ });
6247
+ }
6187
6248
  // Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
6188
6249
  // still be pending while every attempt samples current work afresh.
6189
6250
  const row = outcome === "protected"
package/dist/lib/sync.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import { getTelegramTargetKey } from "./target.js";
7
7
  import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError, } from "./telegram-api.js";
8
8
  import * as ThreadReconciler from "./thread-reconciler.js";
9
- import { commitTelegramWorkspaceProvisionBinding, createTelegramCleanupTargetProtection, getTelegramTargetFromApiBody, getTelegramThreadOwnerKey, isSameTelegramProcessInstance, isTelegramTopicTargetStaleError, provisionOwnBusTopic, } from "./threads.js";
9
+ import { commitTelegramWorkspaceProvisionBinding, createTelegramCleanupTargetProtection, getTelegramTargetFromApiBody, getTelegramThreadOwnerKey, isSameTelegramProcessInstance, isTelegramTopicTargetStaleError, provisionOwnBusTopic, resetTelegramConnectionProvisioning, } from "./threads.js";
10
10
  import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
11
11
  import { normalizeTelegramWorkspacePath } from "./workspace-identity.js";
12
12
  import { TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.js";
@@ -463,11 +463,21 @@ export async function ensureTelegramLeaderThreadBinding(deps) {
463
463
  ...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
464
464
  };
465
465
  const leaderProfileKey = getTelegramThreadOwnerKey(leaderOwner);
466
+ if (deps.forceFreshUnnamed) {
467
+ await resetTelegramConnectionProvisioning({
468
+ store: deps.topicTargetStore, owner: "leader", instanceId: deps.instanceId,
469
+ profileKey: leaderProfileKey, isCurrent: () => {
470
+ assertLeaderEpoch("connect-recovery");
471
+ return true;
472
+ }, recordEvent: deps.recordEvent,
473
+ });
474
+ }
466
475
  const legacyLeaderRecord = deps.topicTargetStore.getByProfileKey(leaderProfileKey);
467
476
  let capacityUnavailable = false;
468
477
  const workspaceIdentity = normalizedLeaderCwd
469
478
  ? deps.topicTargetStore.claimWorkspaceIdentity(normalizedLeaderCwd, deps.instanceId, legacyLeaderRecord?.instanceId, {
470
479
  sessionId: deps.sessionId,
480
+ provisionProfileKey: leaderProfileKey,
471
481
  onCapacityUnavailable() {
472
482
  capacityUnavailable = true;
473
483
  },
@@ -495,6 +495,7 @@ export interface TelegramTopicTargetStore {
495
495
  } | undefined;
496
496
  listReservations: () => TelegramThreadReservation[];
497
497
  listPendingProvisions: () => TelegramThreadPendingProvision[];
498
+ resetPendingProvisionForConnect: (expected: TelegramThreadPendingProvision, isCurrent: () => boolean) => Promise<boolean>;
498
499
  listPendingCleanups: () => TelegramThreadCleanupIntent[];
499
500
  listSyncObservations: () => TelegramTopicSyncObservation[];
500
501
  reserveThread: (reservation: TelegramThreadReservation) => void;
@@ -559,6 +560,7 @@ export interface TelegramTopicTargetStore {
559
560
  claimWorkspaceIdentity: (cwd: string, instanceId: string, previousInstanceId?: string, options?: {
560
561
  existingBindingOnly?: boolean;
561
562
  sessionId?: string;
563
+ provisionProfileKey?: string;
562
564
  onCapacityUnavailable?: () => void;
563
565
  }) => TelegramWorkspaceBindingIdentity | undefined;
564
566
  releaseWorkspaceClaim: (instanceId: string) => boolean;
@@ -957,4 +959,13 @@ export declare function getTelegramTargetFromApiBody(body: unknown): (TelegramTa
957
959
  export declare function isTelegramTopicTargetStaleError(error: unknown): boolean;
958
960
  export declare function isTelegramTopicModeUnavailableError(error: unknown): boolean;
959
961
  export declare function createTelegramTopicTargetRenamer(deps: TelegramTopicTargetRenamerDeps): (request: TelegramTopicTargetRenameRequest) => Promise<TelegramTopicTargetRecord | undefined>;
962
+ /** Caller holds profile Workspace admission; only an explicit connect grants a fresh creation attempt. */
963
+ export declare function resetTelegramConnectionProvisioning(input: {
964
+ store: TelegramTopicTargetStore;
965
+ owner: TelegramThreadPendingProvision["owner"];
966
+ instanceId: string;
967
+ profileKey: string;
968
+ isCurrent: () => boolean;
969
+ recordEvent: (category: string, message: unknown, details?: Record<string, unknown>) => void;
970
+ }): Promise<void>;
960
971
  export declare function createTelegramTopicTargetProvisioner(deps: TelegramTopicTargetProvisionerDeps): (request: TelegramTopicTargetProvisionRequest) => Promise<TelegramTopicTargetProvisionResult>;
@@ -2304,7 +2304,7 @@ export function createTelegramTopicTargetStore(options) {
2304
2304
  }
2305
2305
  return false;
2306
2306
  };
2307
- const persistSnapshot = (transition, isCurrentPublication) => {
2307
+ const persistSnapshot = (transition, isCurrentPublication, resetProvisions) => {
2308
2308
  const submittedPath = getPath(), submittedProfile = activeProfile();
2309
2309
  const publishWorkspace = workspaceIO?.capturePublication();
2310
2310
  const persist = persistQueue.then(async () => {
@@ -2437,6 +2437,12 @@ export function createTelegramTopicTargetStore(options) {
2437
2437
  : readStoreSnapshot(path, transition?.kind === "relocate" ||
2438
2438
  transition?.kind === "live-rebind" ||
2439
2439
  !!expectedRestore));
2440
+ if (resetProvisions) {
2441
+ const recoveries = readProvisionRecoveries(path, true);
2442
+ if (mutationRevision !== persistedRevision || resetProvisions.some(expected => expected.target || Object.hasOwn(recoveries, expected.id) ||
2443
+ !isDeepStrictEqual(disk.pendingProvisions?.find(value => value.id === expected.id), expected)))
2444
+ throw new Error("Telegram connection recovery provisioning evidence changed.");
2445
+ }
2440
2446
  if (!isDeepStrictEqual(disk.workspaceRestore, expectedRestore))
2441
2447
  throw new Error("Workspace Restore evidence changed before publication.");
2442
2448
  if (!manualNameMatches(disk) ||
@@ -2471,7 +2477,7 @@ export function createTelegramTopicTargetStore(options) {
2471
2477
  }
2472
2478
  : {}),
2473
2479
  reservations: reservations.map((reservation) => ({ ...reservation })),
2474
- pendingProvisions: pendingProvisions.map((provision) => ({
2480
+ pendingProvisions: pendingProvisions.filter(provision => !resetProvisions?.some(expected => expected.id === provision.id)).map((provision) => ({
2475
2481
  ...provision,
2476
2482
  ...(provision.target ? { target: { ...provision.target } } : {}),
2477
2483
  })),
@@ -2626,6 +2632,10 @@ export function createTelegramTopicTargetStore(options) {
2626
2632
  loadedProfile = submittedProfile;
2627
2633
  loaded = true;
2628
2634
  observedWorkspaceSemantic = getTelegramStateSemanticSnapshot(wire);
2635
+ if (resetProvisions) {
2636
+ pendingProvisions = file.pendingProvisions;
2637
+ mutationRevision += 1;
2638
+ }
2629
2639
  if (transition) {
2630
2640
  records = new Map(file.threads.map((record) => {
2631
2641
  const normalized = normalizeRecord(record);
@@ -2765,6 +2775,11 @@ export function createTelegramTopicTargetStore(options) {
2765
2775
  return false;
2766
2776
  loadedPath = path;
2767
2777
  loaded = true;
2778
+ if (resetProvisions) {
2779
+ if (mutationRevision !== persistedRevision)
2780
+ return false;
2781
+ pendingProvisions = file.pendingProvisions;
2782
+ }
2768
2783
  if (mutationRevision === persistedRevision)
2769
2784
  dirty = false;
2770
2785
  return true;
@@ -4209,6 +4224,12 @@ export function createTelegramTopicTargetStore(options) {
4209
4224
  current.status = "ambiguous";
4210
4225
  return true;
4211
4226
  },
4227
+ resetPendingProvisionForConnect(expected, isCurrent) {
4228
+ // Stage no in-memory deletion: refusal or a stale publication must keep its protective hold.
4229
+ if (expected.target || !isCurrent())
4230
+ return Promise.resolve(false);
4231
+ return persistSnapshot(undefined, isCurrent, [structuredClone(expected)]);
4232
+ },
4212
4233
  removePendingProvision(id) {
4213
4234
  const before = pendingProvisions.length;
4214
4235
  pendingProvisions = pendingProvisions.filter((provision) => provision.id !== id);
@@ -4865,8 +4886,14 @@ export function createTelegramTopicTargetStore(options) {
4865
4886
  liveRecord.instanceId !== effectivePreviousInstanceId) {
4866
4887
  return undefined;
4867
4888
  }
4868
- const retainedTarget = binding?.target ?? legacyRecord?.target;
4869
- const retainedSlot = binding?.slot ?? legacyRecord?.slot;
4889
+ const ownedProvisions = pendingProvisions.filter(value => value.workspaceBindingKey === identity.bindingKey &&
4890
+ (value.instanceId === instanceId || value.instanceId === effectivePreviousInstanceId ||
4891
+ (options?.provisionProfileKey !== undefined && value.profileKey === options.provisionProfileKey)));
4892
+ if (ownedProvisions.length > 1)
4893
+ return undefined;
4894
+ const ownProvision = ownedProvisions[0];
4895
+ const retainedTarget = ownProvision?.target ?? binding?.target ?? legacyRecord?.target;
4896
+ const retainedSlot = ownProvision?.slot ?? binding?.slot ?? legacyRecord?.slot;
4870
4897
  const otherBindings = Array.from(workspaceBindings.values())
4871
4898
  .filter((other) => other.bindingKey !== identity.bindingKey && other.slot)
4872
4899
  .map((other) => ({
@@ -4886,7 +4913,8 @@ export function createTelegramTopicTargetStore(options) {
4886
4913
  reservation.expiresAtMs > nowMs)
4887
4914
  .map((reservation) => reservation.slot),
4888
4915
  ...pendingProvisions
4889
- .filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
4916
+ .filter((provision) => provision !== ownProvision &&
4917
+ isPendingProvisionLiveOrTargeted(provision, nowMs))
4890
4918
  .map((provision) => provision.slot),
4891
4919
  ...externalReservedSlots,
4892
4920
  ]
@@ -6349,6 +6377,29 @@ export function createTelegramTopicTargetRenamer(deps) {
6349
6377
  return renamed;
6350
6378
  };
6351
6379
  }
6380
+ /** Caller holds profile Workspace admission; only an explicit connect grants a fresh creation attempt. */
6381
+ export async function resetTelegramConnectionProvisioning(input) {
6382
+ if (!input.isCurrent())
6383
+ throw new Error("Telegram connection recovery authority changed.");
6384
+ const removed = [];
6385
+ for (const pending of input.store.listPendingProvisions()) {
6386
+ if (pending.owner !== input.owner || pending.target ||
6387
+ (pending.profileKey !== input.profileKey && pending.instanceId !== input.instanceId))
6388
+ continue;
6389
+ if (!input.isCurrent())
6390
+ throw new Error("Telegram connection recovery authority changed.");
6391
+ if (!await input.store.resetPendingProvisionForConnect(pending, input.isCurrent))
6392
+ throw new Error("Telegram connection recovery lost publication authority.");
6393
+ if (!input.isCurrent())
6394
+ throw new Error("Telegram connection recovery authority changed.");
6395
+ removed.push(pending);
6396
+ }
6397
+ if (removed.length === 0)
6398
+ return;
6399
+ input.recordEvent("connection", "Explicit connect reset unfinished Thread creation; an unconfirmed Telegram Thread may remain.", {
6400
+ phase: "connect-provision-reset", count: removed.length, owner: input.owner,
6401
+ });
6402
+ }
6352
6403
  export function createTelegramTopicTargetProvisioner(deps) {
6353
6404
  const getNowMs = deps.getNowMs ?? (() => 0);
6354
6405
  const getRandom = deps.getRandom;
@@ -6554,7 +6605,7 @@ export function createTelegramTopicTargetProvisioner(deps) {
6554
6605
  }
6555
6606
  displayTitle = buildTelegramTopicName({ ...request, threadName: projectedTitle }, "{threadName}", slot);
6556
6607
  }
6557
- const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`;
6608
+ const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}:${randomUUID()}`;
6558
6609
  const pendingOwner = request.owner?.kind === "leader" ? "leader" : "manual-follower";
6559
6610
  assertLeaderEpoch("before-pending-intent");
6560
6611
  const pendingBase = {
@@ -22,14 +22,6 @@ export declare const TELEGRAM_PRIORITY_REACTIONS: readonly [{
22
22
  readonly id: 10;
23
23
  readonly name: "like";
24
24
  readonly emoji: "👍";
25
- }, {
26
- readonly id: 12;
27
- readonly name: "heart";
28
- readonly emoji: "❤";
29
- }, {
30
- readonly id: 13;
31
- readonly name: "dove";
32
- readonly emoji: "🕊";
33
25
  }];
34
26
  export declare const TELEGRAM_STEER_REACTIONS: readonly [{
35
27
  readonly id: 14;
@@ -40,25 +32,9 @@ export declare const TELEGRAM_REMOVAL_REACTIONS: readonly [{
40
32
  readonly id: 20;
41
33
  readonly name: "dislike";
42
34
  readonly emoji: "👎";
43
- }, {
44
- readonly id: 21;
45
- readonly name: "ghost";
46
- readonly emoji: "👻";
47
- }, {
48
- readonly id: 22;
49
- readonly name: "broken-heart";
50
- readonly emoji: "💔";
51
- }, {
52
- readonly id: 23;
53
- readonly name: "poop";
54
- readonly emoji: "💩";
55
- }, {
56
- readonly id: 24;
57
- readonly name: "wastebasket";
58
- readonly emoji: "🗑";
59
35
  }];
60
- export declare const TELEGRAM_PRIORITY_REACTION_EMOJIS: ("👍" | "❤" | "🕊")[];
61
- export declare const TELEGRAM_REMOVAL_REACTION_EMOJIS: ("👎" | "👻" | "💔" | "💩" | "🗑")[];
36
+ export declare const TELEGRAM_PRIORITY_REACTION_EMOJIS: "👍"[];
37
+ export declare const TELEGRAM_REMOVAL_REACTION_EMOJIS: "👎"[];
62
38
  export interface TelegramUpdateDeletion {
63
39
  deleted_business_messages?: {
64
40
  message_ids?: unknown;
@@ -227,6 +203,7 @@ interface TelegramUpdateAdmissionBinding {
227
203
  inspectSource?: () => TelegramDeferredSourceEvidence | undefined;
228
204
  inspectSourceSnapshot?: () => TelegramDeferredSourceSnapshot | undefined;
229
205
  inspectCompletion?: () => TelegramDeferredSourceEvidence | undefined;
206
+ subscribeCompletion?: (notify: () => void) => () => void;
230
207
  isSourceUnsettled?: () => boolean;
231
208
  prepareLiveInput?: (sourceUpdateIds: readonly number[], isCurrent: () => boolean, onTransferred?: () => void) => TelegramLiveDeferredInputPreparation | undefined;
232
209
  /** Internal notification after the worker's exact donor-removal ACK; never issues completion itself. */
@@ -242,7 +219,7 @@ interface TelegramUpdateAdmissionBinding {
242
219
  export type TelegramQueueAdmissionReceiptLike = TelegramQueueAdmissionReceipt;
243
220
  export declare function bindTelegramUpdateAdmissionSource<TUpdate extends TelegramUpdateFlow & {
244
221
  update_id: number;
245
- }>(update: TUpdate, report: TelegramUpdateAdmissionBinding["report"], controls?: Pick<TelegramUpdateAdmissionBinding, "abandon" | "armRoutingInput" | "getRoutingInput" | "acquireRouting" | "inspectSource" | "inspectSourceSnapshot" | "inspectCompletion" | "isSourceUnsettled" | "acknowledgeTransferred" | "prepareLiveInput" | "prepareQueueAdmission" | "prepareSourceCompletion" | "supportsAbandonment" | "inspectAbandoning" | "inspectHistorical" | "isHistorical" | "isHistoricalReviewHeld">): TUpdate;
222
+ }>(update: TUpdate, report: TelegramUpdateAdmissionBinding["report"], controls?: Pick<TelegramUpdateAdmissionBinding, "abandon" | "armRoutingInput" | "getRoutingInput" | "acquireRouting" | "inspectSource" | "inspectSourceSnapshot" | "inspectCompletion" | "subscribeCompletion" | "isSourceUnsettled" | "acknowledgeTransferred" | "prepareLiveInput" | "prepareQueueAdmission" | "prepareSourceCompletion" | "supportsAbandonment" | "inspectAbandoning" | "inspectHistorical" | "isHistorical" | "isHistoricalReviewHeld">): TUpdate;
246
223
  /**
247
224
  * Conservative read-only census: any retained entry naming this Thread, in any nested Telegram object or state,
248
225
  * is unresolved custody, except a lone button tap, which carries no input (the in-flight Cancel itself).
@@ -262,6 +239,8 @@ export declare function inspectTelegramDeferredSource(value: unknown): TelegramD
262
239
  export declare function inspectTelegramDeferredSourceSnapshot(value: unknown): TelegramDeferredSourceSnapshot | undefined;
263
240
  /** Exact warm removal ACK only; reporting, source absence and a replaced worker cannot supply it. */
264
241
  export declare function inspectTelegramDeferredSourceCompletion(value: unknown): TelegramDeferredSourceEvidence | undefined;
242
+ /** Warm ACK notification only; subscribers must independently recheck exact completion and recipient authority. */
243
+ export declare function subscribeTelegramDeferredSourceCompletion(value: unknown, notify: () => void): (() => void) | undefined;
265
244
  /** Reuse only live bound deferred originals; raw IDs or cold/adopted input cannot mint this carrier. */
266
245
  export declare function prepareTelegramLiveDeferredInput(values: readonly unknown[], isCurrent: () => boolean): TelegramLiveDeferredInputPreparation | undefined;
267
246
  /** A route-owned pre-disposition acceptance publisher; wraps only this carrier, never the shared worker binding. */
@@ -14,16 +14,10 @@ import { createTelegramPrivateTarget, createTelegramThreadTarget, } from "./targ
14
14
  import { isWireRecord as isTelegramUpdateAdmissionRecord, isNonEmptyWireString as isTelegramUpdateAdmissionString, } from "./wire.js";
15
15
  export const TELEGRAM_PRIORITY_REACTIONS = [
16
16
  { id: 10, name: "like", emoji: "👍" },
17
- { id: 12, name: "heart", emoji: "❤" },
18
- { id: 13, name: "dove", emoji: "🕊" },
19
17
  ];
20
18
  export const TELEGRAM_STEER_REACTIONS = [{ id: 14, name: "fire", emoji: "🔥" }];
21
19
  export const TELEGRAM_REMOVAL_REACTIONS = [
22
20
  { id: 20, name: "dislike", emoji: "👎" },
23
- { id: 21, name: "ghost", emoji: "👻" },
24
- { id: 22, name: "broken-heart", emoji: "💔" },
25
- { id: 23, name: "poop", emoji: "💩" },
26
- { id: 24, name: "wastebasket", emoji: "🗑" },
27
21
  ];
28
22
  export const TELEGRAM_PRIORITY_REACTION_EMOJIS = TELEGRAM_PRIORITY_REACTIONS.map((reaction) => reaction.emoji);
29
23
  export const TELEGRAM_REMOVAL_REACTION_EMOJIS = TELEGRAM_REMOVAL_REACTIONS.map((reaction) => reaction.emoji);
@@ -46,7 +40,7 @@ function getTelegramReactionEmoji(emojis, candidates) {
46
40
  function getTelegramQueueReactionTransition(oldReactions, newReactions) {
47
41
  const oldEmojis = collectTelegramReactionEmojis(oldReactions);
48
42
  const newEmojis = collectTelegramReactionEmojis(newReactions);
49
- // Fire is effective Steer intent; weaker positive aliases under it do not reorder the lane.
43
+ // Fire selects Steer; thumbs-up beneath it does not reorder the lane. Other emoji carry no queue intent.
50
44
  const steerEmoji = TELEGRAM_STEER_REACTIONS[0].emoji;
51
45
  const oldPriorityEmoji = oldEmojis.has(steerEmoji) ? steerEmoji : getTelegramReactionEmoji(oldEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
52
46
  const newPriorityEmoji = newEmojis.has(steerEmoji) ? steerEmoji : getTelegramReactionEmoji(newEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
@@ -255,6 +249,16 @@ export function inspectTelegramDeferredSourceCompletion(value) {
255
249
  const source = getTelegramUpdateAdmissionBinding(value)?.inspectCompletion?.();
256
250
  return execution.isCurrent() && source ? { ...source } : undefined;
257
251
  }
252
+ /** Warm ACK notification only; subscribers must independently recheck exact completion and recipient authority. */
253
+ export function subscribeTelegramDeferredSourceCompletion(value, notify) {
254
+ const execution = getTelegramUpdateExecutionFence(value);
255
+ if (execution?.isCurrent() !== true)
256
+ return undefined;
257
+ return getTelegramUpdateAdmissionBinding(value)?.subscribeCompletion?.(() => {
258
+ if (execution.isCurrent())
259
+ notify();
260
+ });
261
+ }
258
262
  /** Reuse only live bound deferred originals; raw IDs or cold/adopted input cannot mint this carrier. */
259
263
  export function prepareTelegramLiveDeferredInput(values, isCurrent) {
260
264
  const carriers = [...values], first = carriers[0], execution = getTelegramUpdateExecutionFence(first);
@@ -5155,6 +5159,7 @@ function createTelegramUpdateSourceAdmissionHandle(deps, capture) {
5155
5159
  let outcome;
5156
5160
  let completionAttempted = false;
5157
5161
  let completedSource;
5162
+ const completionObservers = new Set();
5158
5163
  const boundUpdate = bindTelegramUpdateExecutionFence(bindTelegramUpdateAdmissionSource(update, (next) => {
5159
5164
  if (!execution.isCurrent())
5160
5165
  return;
@@ -5199,11 +5204,15 @@ function createTelegramUpdateSourceAdmissionHandle(deps, capture) {
5199
5204
  completion &&
5200
5205
  execution.isCurrent() &&
5201
5206
  completion.isCurrent() &&
5202
- isDeepStrictEqual(completion.source, expectedSource))
5203
- completedSource = {
5204
- ...completion,
5205
- source: { ...expectedSource },
5206
- };
5207
+ isDeepStrictEqual(completion.source, expectedSource)) {
5208
+ completedSource = { ...completion, source: { ...expectedSource } };
5209
+ for (const notify of completionObservers) {
5210
+ try {
5211
+ notify();
5212
+ }
5213
+ catch { /* Observation cannot invalidate an exact removal ACK. */ }
5214
+ }
5215
+ }
5207
5216
  })
5208
5217
  .catch((error) => {
5209
5218
  try {
@@ -5260,6 +5269,10 @@ function createTelegramUpdateSourceAdmissionHandle(deps, capture) {
5260
5269
  signal,
5261
5270
  })
5262
5271
  : undefined,
5272
+ subscribeCompletion: (notify) => {
5273
+ completionObservers.add(notify);
5274
+ return () => { completionObservers.delete(notify); };
5275
+ },
5263
5276
  inspectCompletion: () => execution.isCurrent() && completedSource?.isCurrent()
5264
5277
  ? { ...completedSource.source }
5265
5278
  : undefined,
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.56.0",
3
+ "version": "0.56.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -61,6 +61,8 @@ The repository uses a **Flat Domain DAG**:
61
61
 
62
62
  ### Live-Rebind Module Map
63
63
 
64
+ For an already-issued leader menu/control command whose semantics finish before delivery, the original chooser may observe its exact warm source-removal ACK for one window of up to eight seconds before answering. Later taps only reobserve; they do not restart that wait. Naming/confirmation/extension producers whose source completion can depend on later publication keep their independent completion path. Updates notifies subscribers only after its carrier retains that ACK, not at the earlier journal-completion hook. Routing rechecks source, recipient, canonical binding and execution generation; observation cannot issue a second command or turn source absence into confirmation. Menu/reply delivery is not awaited. Timeout, abort or authority loss removes subscriptions and retains uncertainty without replay; old-Thread cleanup remains an independent fenced operation.
65
+
64
66
  Release `0.53.0` adds the [live-rebind channel](./multi-instance-bus.md#live-rebind-channel) and bounded old-Thread cleanup. It advertises live save/apply/settle, command-set and selected-menu-delivery through bus protocol v3, retaining Workspace Restore for legacy continuity. A loaded 0.53.0 build prefers the live path; an instance still running an older build keeps Restore until it is reloaded. Windows runs the same channel and strict reads, exercised by the CI matrix; there is no platform-specific alternate protocol.
65
67
 
66
68
  The channel reuses existing owners rather than adding a parallel command bus, queue or recovery store:
@@ -575,6 +577,14 @@ Polling and inbound-worker diagnostics remain separate so an executing, deferred
575
577
 
576
578
  Long-text split recovery remains conservative: only human text at or above the near-limit threshold opens its debounce window. Forward annotation has two semantic layers: the forward owns its source text/caption/media, while an optional separate owner-authored annotation normally precedes it. A bounded one-second pairing window joins that annotation and adjacent forward in either transport order, including a media-only forward without source caption text; the matching opposite-kind message flushes immediately. Same-kind rapid messages, commands, bots, ordinary non-forward captions, media groups, different senders/targets, reversed ids, and distant message ids do not enter this pairing path. Prompt construction always places the owner annotation first, followed by `[forward|from:...]` with the forward's own source text/caption, then source-attributed forwarded attachments, regardless of arrival order.
577
579
 
580
+ ### Explicit Connection Recovery
581
+
582
+ An explicit `/telegram-connect` is permission for one fresh connection attempt, not permission to take over a live leader or clear accepted input. Under the existing profile-wide Workspace admission and mutation gate, the leader resets only matching unfinished creation records without a known Thread target before claiming Workspace identity. The reset verifies the exact retained record and refuses sidecar target-recovery evidence; durable publication and current ownership are required before another create. Known targets and other owners' pending records remain intact. A late response is isolated by a fresh provisioning ID, independent of wall-clock resolution.
583
+
584
+ If Telegram committed the abandoned create without returning its Thread ID, a spare tab may remain. This is the operator-approved availability trade-off: no unknown tab is deleted, and no journal, receipt, live binding or queued prompt is erased. A new API outage can still fail the new attempt; the command does not promise connectivity without a working Telegram transport.
585
+
586
+ Followers carry this explicit intent only on authenticated `follower.register`, gated by `explicit-connect-recovery-v1` at caller and leader. Automatic heartbeat recovery and `follower.restoreWorkspace` do not reset creation evidence. A matching targeted pending provision contributes its original Workspace key/slot when the exact session is claimed again, so retry can complete known publication rather than allocate a conflicting identity. Cross-session targets never become recovery grants.
587
+
578
588
  ### Queue And Dispatch Safety
579
589
 
580
590
  The bridge keeps its own Telegram queue. The Pi status bar projects executable waiting work across Normal, Priority, Steer and Control into one warning-yellow `+N` suffix, omitted when zero. It uses Queue's executability filter, so Skip and frozen attempts never contribute; current agent work from Telegram, terminal, or autonomous sources never contributes either. The dispatched Telegram head is subtracted while retained pending `agent_start` consumption.
@@ -600,7 +610,7 @@ Bridge-owned Steer uses the existing public Pi SDK. Busy-run operator smoke cove
600
610
 
601
611
  A prompt is one queue object with exactly one active lane membership and one current position. It has no duplicate, shadow entry, or reserved return slot in the other lane. Each prompt admitted directly to a lane joins that lane's tail. A `Normal → Priority` transition removes it from Normal and appends that same object to the Priority tail; a later `Priority → Normal` transition removes it from Priority and appends it to the current Normal tail rather than restoring any historical position. The immutable `queueOrder` records original admission identity only and is never a return address; `laneOrder` records the current destination-lane position. Keep/Skip changes and emoji changes within the same reaction category preserve both lane and lane position exactly.
602
612
 
603
- Telegram reactions are shortcut controls over those dimensions. Fire selects Steer and dominates other positives; thumbs-up/heart/dove select Priority and recognized negatives control Skip. Lightning is now a Control marker only, not a reaction shortcut; the emoji sets live in the [queue reaction registry](./ui-style.md#queue-reaction-shortcuts). The runtime compares effective intent from complete old and new reaction sets and mutates only dimensions that changed. Weaker positives under fire and decorative changes cannot reset a menu lane choice; so adding or removing a negative reaction cannot silently change Priority, and changing a positive reaction cannot silently change Skip. The registry order selects the retained display emoji when several recognized emoji from one category coexist; it does not let one category override the other.
613
+ Telegram reactions are shortcut controls over those dimensions. Only fire selects Steer, thumbs-up selects Priority and thumbs-down selects Skip. Fire dominates thumbs-up; all other reactions are ignored, including former heart/dove and removal aliases. Lightning is now a Control marker only, not a reaction shortcut; the emoji sets live in the [queue reaction registry](./ui-style.md#queue-reaction-shortcuts). The runtime compares effective intent from complete old and new reaction sets and mutates only dimensions that changed. Weaker positives under fire and decorative changes cannot reset a menu lane choice; so adding or removing a negative reaction cannot silently change Priority, and changing a positive reaction cannot silently change Skip. Ignored emoji cannot retain a lane or Skip when its supported reaction is removed. Existing explicit queue state and receipts are not rewritten during upgrade; menu badges project that state as thumbs-up, fire or thumbs-down rather than reviving retired aliases.
604
614
 
605
615
  Steer or Priority and Skip may coexist when a prompt carries both positive and negative reactions. The prompt remains at its current lane position while waiting, and its durable journal receipts remain intact so Keep stays reversible and exact live handoff remains possible. Skip wins when dispatch reaches the prompt: the dispatcher first settles those receipts durably, then drops it without a model turn and continues; settlement failure retains the skipped head instead of allowing replay ambiguity. The prompt stays visible with only its negative emoji, is excluded immediately from the executable queue count shared by the Pi status bar and Telegram main menu, and keeps a struck-through physical ordinal. Returning it to Keep restores its contribution to the count without moving it. Graceful session shutdown discards all remaining queue receipts before clearing session-local memory, and startup discards receipts only after proving their former process dead, so a new or unrelated session never inherits queued work. Queue item detail exposes horizontal Normal/Priority/Steer and Keep/Skip selectors instead of irreversible Delete. Legacy published Priority callbacks retain explicit lane intent. Dispatched/frozen items expose inspection only; an oversized idle Steer item stays editable with a Priority/Normal escape and never splits its source group.
606
616
 
@@ -608,7 +618,7 @@ Menu and reaction controls share the same canonical queue state. A menu Keep can
608
618
 
609
619
  Guest Mode is the deliberate exception to deferred Keep/Skip semantics. A guest prompt has no source message reaction surface and may share zero-valued chat/message placeholders with other guest prompts, so queue-menu callbacks address it by immutable `queueOrder` and expose an immediate `Skip`. The leader settles its durable admission, removes it while other agent work may continue, stops placeholder animation, and replaces the inline globe placeholder with non-printing content. Bot API inline messages have no delete method, so this visual clear is the supported deletion equivalent. A successfully skipped waiting guest prompt never reaches a model turn. Once dispatched, its retained head is inspection-only until `agent_start` consumes that exact reply owner; stale Guest Skip callbacks refuse without receipt disposal or placeholder dismissal. Later waiting guests remain independently skippable.
610
620
 
611
- A standalone `/continue` is a source-addressed control-lane prompt, not a Normal/Priority prompt. An authorized negative reaction to its original chat/message immediately settles the exact waiting continuation's receipts before removing it, even while unrelated work is active. Failed or unknown settlement retains it for the existing reaction retry path; duplicate reactions cannot settle twice. Positive reactions and removal of the negative reaction neither change its control lane nor reconstruct cancelled work. The dispatched head awaiting `agent_start` and already-started work are never cancelled; appends and reorders preserve that Pi-owned head. Synthetic model-switch continuations have no source message IDs and are not reaction-addressable by their fallback reply anchor. Reaction authorization and chat/message namespace isolation remain unchanged.
621
+ A standalone `/continue` is a source-addressed control-lane prompt, not a Normal/Priority prompt. An authorized thumbs-down reaction to its original chat/message immediately settles the exact waiting continuation's receipts before removing it, even while unrelated work is active. Failed or unknown settlement retains it for the existing reaction retry path; duplicate reactions cannot settle twice. Positive reactions and removal of the negative reaction neither change its control lane nor reconstruct cancelled work. The dispatched head awaiting `agent_start` and already-started work are never cancelled; appends and reorders preserve that Pi-owned head. Synthetic model-switch continuations have no source message IDs and are not reaction-addressable by their fallback reply anchor. Reaction authorization and chat/message namespace isolation remain unchanged.
612
622
 
613
623
  Admission and planning validate lane contracts. Invalid lane/kind pairings fail predictably instead of being silently coerced.
614
624
 
@@ -85,6 +85,8 @@ tmp/pi-telegram/state.json / profiles.<profile>.transport -> bus leader identity
85
85
 
86
86
  Followers do not poll. They register with the leader and receive routed inbound updates from it. Followers still own their local queue, active-turn state, previews, final delivery planning, model switches, and Pi lifecycle. The leader owns only Telegram transport and update fanout. Same-session context refresh preserves bus membership; a changed Pi session ID suspends the old receiver/heartbeat and requires acknowledged successor registration before readiness, without an explicit Thread disconnect. A new follower process also attempts a restore-only registration at session startup when the selected profile's local state contains a Workspace binding for the exact `cwd` and session ID. If an active retained manual-follower record blocks that binding, the leader may transfer its claim only for one exact eligible predecessor: no registered peer owns its runtime or target, and an OS probe proves the actual runtime PID absent. A live, inaccessible, missing or malformed PID proof and ambiguous candidates remain protective; neither elapsed time nor an empty registry alone proves death. This is only claim continuity, not receipt settlement, deletion or a fabricated immediate session handoff. The same binding, non-default instance slot, acknowledged title and journal history survive without provisioning a new Thread. The recent-event log distinguishes missing session bindings, disabled Threaded Mode, leader-side restore refusal, and a restore that returned without connecting; none of these refusals silently provisions a new Thread. The Telegram bus belongs to the local set of cooperating visible Pi instances rather than to the first terminal session forever: if the visible terminal leader exits, a live registered follower can take over leadership.
87
87
 
88
+ Explicit connection recovery is capability-gated by `explicit-connect-recovery-v1`. Only a manual `follower.register` may carry `recoverConnection: true`; malformed values and restore-only requests reject it. The leader resets matching untargeted creation metadata under ordinary profile admission before allocation, accepting a possible orphan tab without touching accepted input. Automatic registration never carries this grant. See [Explicit Connection Recovery](./architecture.md#explicit-connection-recovery).
89
+
88
90
  ### Session-Owned Journal Storage
89
91
 
90
92
  Runtime storage lives under `<agent-dir>/tmp/pi-telegram`. The pre-0.52.0 `tmp/telegram` tree is neither written, migrated nor deleted. A live fresh owner there blocks competing polling as read-only evidence; its endpoint and secret grant no current authority.
@@ -305,7 +307,7 @@ After the recipient confirms the applied rebind and before the source is release
305
307
 
306
308
  Follower live apply, held-work observation and release-readiness checks use the exact read-only live-rebind Workspace snapshot, including with consolidated storage and no follower publication authority. The captured store/reader identity stays fenced across awaits; a missing or replaced reader refuses without falling back to the publication-backed legacy Restore observer. Profile admission, exact canonical intent/binding and source/custody checks still apply. Observation changes only the follower's local target registration, never leader-owned Workspace evidence; journal reads and receipt settlement remain separate owners.
307
309
 
308
- Every participating peer must negotiate the required capabilities and retain current authentication, registration generation, Pi context/session, journal binding and exact recipient authority. The wire validates syntax; Commands' captured registry decides availability before hold/append. Supported built-ins and registered prompt templates reuse one plan set across compatible leader selections and held followers; extension commands/groups/legacy registrations do not gain follower execution. Continue keeps its control lane, while expanded templates are ordinary default-lane prompts even when their text starts with a slash.
310
+ Every participating peer must negotiate the required capabilities and retain current authentication, registration generation, Pi context/session, journal binding and exact recipient authority. The wire validates syntax; Commands' captured registry decides availability before hold/append. Supported built-ins and registered prompt templates reuse one plan set across compatible leader selections and held followers. Both peers additionally negotiate `held-menu-aliases-v1` for follower `/start` and `/help`: these already-authenticated selections open the restricted main menu using the status plan, never pair a user, bootstrap a Thread or synchronize BotFather commands through ordinary follower transport; extension commands/groups/legacy registrations do not gain follower execution. Continue keeps its control lane, while expanded templates are ordinary default-lane prompts even when their text starts with a slash.
309
311
 
310
312
  Semantic reports, queued receipts, completion observation and delivery remain separate. Native exact ACK and donor CAS still own source disposition; lost or mismatched evidence retains uncertainty without handler replay, another queue or fallback admission. This section owns the unified command-channel policy; [architecture](./architecture.md#live-rebind-module-map) owns the module map. Existing Workspace Restore receivers/readers and queue-handoff retain their custody contracts; the new loaded root prefers live rebinding rather than using legacy Restore as an uncertain-input fallback. Source-enabled bounded cleanup is not proof of an actual deletion or client acceptance. Supported leader/follower prompts, commands and templates use `saved → binding applied → cleanup decision → input issuance → native admission/execution`. The chooser holds its existing profile admission through the unchanged whole first delay and one fresh cleanup attempt, without nested leader admission or another queue. A negotiated follower reports work through `observe-held` before release; binding readiness is not execution permission. `inputRelease: held/issued/accepted` is independent of cleanup: an issued input is never replayed after a lost admission reply, and a finished cleanup with held input remains active binding/source protection. Confirmed, failed or unknown terminal deletion permits the one native input attempt only on the confirmed binding. Issued and unknown old-target evidence remains canonically protected against reuse and generic cleanup, including response/publication interruption; unknown evidence survives input acceptance and is never capacity-evicted or reissued. If earlier work prevents issuance, input proceeds on the confirmed new target and the existing paced scheduler owns deferred cleanup; no old-target clearance is invented. Detached command producers accept their one input grant independently from exact source ACKs and reply publication; menu/reply authority tolerates only that issued-to-accepted progress, never changed binding, cleanup, recipient or generation. Expanded templates retain their captured content/default lane and Continue its control lane. A session-replacing `/new` producer with earlier protected work records positively unissued cleanup plus durable `oldTargetProtected`; it cannot erase that old-target protection or transfer a delete grant into the successor. Actual replacement still uses the existing post-removal Pi gateway and confirmation, not Restore readiness. Follower command/control completion retains the exact prepared-source/native ACK before donor CAS; queued admission is not completion and cannot dispose the donor. Unsupported producers do not gain arbitrary follower execution. Older peers keep the preceding supported channel. In deferred cleanup, old-tab cleanup waits for the recipient session to be idle, which the moved input itself may keep busy; its paced retries keep a whole first delay so a detached command reply still sees the released, cleanup-free row, but after that a leader's `agent_settled` or drained output barrier wakes a waiting retry at once. Publication/command-work, Activity handlers, per-target companion queues and target/chat API counters capture lifetime-fenced notifications when work starts and emit only after their pending observation clears. The session adapter defers the hint without awaiting cleanup and rechecks its captured context generation, transport/profile and leader epoch/operator authority. Streaming technical events can drain the publication queue without ending the agent run. A delivery hint while SDK idle is false, unavailable or throwing is retained in the existing chain but does not clear its timer or acquire Workspace admission. `agent_settled` remains an unconditional recheck hint; after settlement, actual remaining delivery drains can wake the retry. A running attempt retains one coalesced hint without a concurrent check, then re-enters the same idle filter after releasing its admission. A fresh timer/settlement attempt consumes the previously deferred hint; signals arriving during that await are not lost. Timer retries, including busy-session samples, are unchanged. Terminal outcomes discard hints, including unknown issued deletion. The initial one-second delay, retry/window fallback, follower pacing and negotiated IPC contracts remain unchanged. Each chain also retains its original generation/profile-store/epoch/operator lifetime rather than rebasing to a reused context. The wake only re-samples every gate through fresh admission; it never grants cleanup, clears unconfirmed work, occupies the publication tail or holds Pi settlement for deletion. Windows runs the same strict paths, exercised by the CI matrix; actual Pi/client acceptance and release are not inferred from supplied IPC fixtures.
311
313
 
@@ -466,8 +468,8 @@ Threaded Mode should make follower threads behave like normal Telegram instance
466
468
  | Surface | Leader behavior | Follower requirement | Routing/ownership invariant | Regression evidence |
467
469
  | --- | --- | --- | --- | --- |
468
470
  | Prompt intake | Thread prompt queues locally | Thread prompt is forwarded and queued by the owning follower | Target ownership routes by `{ chatId, threadId }` before local handling | Routing tests for foreign target message forwarding |
469
- | Queued-message removal reactions | 👎/👻/💔/💩/🗑 marks a pending prompt/media turn for deletion when it reaches dispatch | Same reaction on a queued follower prompt marks that follower's pending turn for deletion before model dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
470
- | Queue priority reactions | 👍/⚡/❤/🕊/🔥 prioritizes queued prompts | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
471
+ | Queued-message removal reactions | Only 👎 selects Skip for a pending prompt/media turn; disposition occurs at dispatch | Same reaction on a queued follower prompt marks that follower's pending turn for deletion before model dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
472
+ | Queue priority reactions | 👍 selects Priority; 🔥 selects Steer; all other positive reactions are decorative | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
471
473
  | Message edits | Edits update matching queued prompt text | Edits in a follower thread update that follower's queued prompt | Message target ownership forwards edits to the owning instance; stored message ownership is the fallback when Telegram edit payloads omit thread id | Update routing tests for foreign target and message-owned edited-message forwarding |
472
474
  | Callbacks/buttons/menus | Callback handled by the owning instance/menu state | Follower callbacks are forwarded to the owning follower; follower menu sends/edits/deletes route through leader transport | Leader records ownership for follower-sent Bot API messages so callbacks can route by message id even when Telegram omits thread id; Bot API edit/delete lacks thread id, so follower bus allows validated same-chat message operations | Callback forwarding, generated-button target, bus follower-sent ownership, and bus edit/delete allowlist tests |
473
475
  | Replies/finals | Final replies land in the same thread | Follower finals go through leader transport into follower thread | Outbound calls carry target and inject `message_thread_id` | Reply delivery and bus API tests |
@@ -559,7 +561,7 @@ All files containing routing, chat ids, thread ids, or process details use priva
559
561
  - Each unfinished preservation retains one admission operation ID across sequential attempts, so a lost acquisition acknowledgement reuses and releases the exact lease instead of leaking another. A rejected pre-rename write leaves the owner intact; a committed write with lost acknowledgement is recognized without rewriting first inactivity. A liveness interruption is retryable, not successful settlement. Every attempt reacquires profile admission and the shared mutation gate, rechecks authority at rename, and preserves accepted work. Cancellation or scope loss never guesses away an unresolved admission lease; existing exact lease/dead-owner recovery remains authoritative.
560
562
  - Followers treat rejected/missing heartbeat acknowledgements as registration loss: retain the last known target locally, clear registered truth, show `reconnecting` instead of a healthy `follower` status, try to re-register with the current leader, wait a short leader-reload grace window, and retry. They promote only after the exact leader lease becomes stale or inactive; a live owner with an unreachable endpoint leaves the follower disconnected/retrying rather than creating a competing poller.
561
563
  - Persisted current manual-follower bindings survive abrupt process absence as restoration hints when Thread cleanup is disabled. When enabled, graceful Pi quit requests exact-generation teardown before lifecycle suspension; if that envelope is missed, stale pruning may delete only after the leader's OS confirms the exact registered PID has exited.
562
- - Fresh registration sends one compact connected notice in the assigned thread. Reusing a retained exact-session target resolves its acknowledged Workspace display title, even if the provisional claim uses another instance slot; a generated dictionary name is only fallback when no acknowledged title exists. An exact immediate session handoff uses a target-scoped `sendChatAction` as its synchronous visibility probe, avoiding a duplicate notice while retaining stale/ambiguous recovery; other cross-session restoration keeps the connected notice as its probe.
564
+ - Fresh registration sends one compact connected notice in the assigned thread. Reusing a retained exact-session target resolves its acknowledged Workspace display title, even if the provisional claim uses another instance slot; a generated dictionary name is only fallback when no acknowledged title exists. Cross-process/session restoration, including an exact immediate handoff, uses that connected message as its visibility probe before publishing registration. Typing is not proof that a retained tab accepts messages. A proven stale target enters existing replacement provisioning; an ambiguous send refuses registration without inventing deletion or retry authority.
563
565
  - Registration requires a present generation, and explicit disconnect requires that same exact live generation. Leader-side registration and disconnect mutations serialize per durable follower profile across old and replacement runtime instance IDs, so a replacement registration cannot overtake awaited destructive cleanup and an old disconnect cannot remove its successor's routing authority.
564
566
  - Successful forwarded updates and follower-originated API calls refresh liveness, so active followers are not pruned only because the interval heartbeat tick lagged. Each follower lifecycle owns at most one in-flight heartbeat for its exact registration generation; stop/replacement makes late settlement inert, and recovery or diagnostic failure cannot escape as an unhandled interval Promise.
565
567
  - Destructive follower thread teardown belongs to confirmed `/telegram-disconnect`, graceful Pi quit, or confirmed reconciliation actions, not generic heartbeat pruning. Manual disconnect retains its destructive confirmation and clears restart ownership; quit deletes the tab without prompting when Thread cleanup is enabled (default) but preserves the owner slot independently so a same-directory restart can reclaim leadership. Confirmed leader/follower teardown first persists an exact target/runtime-generation cleanup intent. The active leader attempts deletion under its current epoch; interruption preserves the intent so that leader or a successor can replay it under current authority, and confirmed deletion removes the binding plus intent in the same persisted state transition. If the graceful request is missed, stale heartbeat plus OS-confirmed absence of the exact registered PID may authorize the same cleanup while enabled; this action serializes ahead of replacement registration. Disabled cleanup, silence, heartbeat expiry alone, IPC/auth failure, and live or unknown process liveness remain non-destructive. Incomplete cleanup preserves durable intent for retry. A promoted leader uses its current owned leader epoch even when the inherited record still carries a historical `manual-follower` owner label.