@llblab/pi-telegram 0.56.0 → 0.56.1

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.
@@ -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 = {
@@ -227,6 +227,7 @@ interface TelegramUpdateAdmissionBinding {
227
227
  inspectSource?: () => TelegramDeferredSourceEvidence | undefined;
228
228
  inspectSourceSnapshot?: () => TelegramDeferredSourceSnapshot | undefined;
229
229
  inspectCompletion?: () => TelegramDeferredSourceEvidence | undefined;
230
+ subscribeCompletion?: (notify: () => void) => () => void;
230
231
  isSourceUnsettled?: () => boolean;
231
232
  prepareLiveInput?: (sourceUpdateIds: readonly number[], isCurrent: () => boolean, onTransferred?: () => void) => TelegramLiveDeferredInputPreparation | undefined;
232
233
  /** Internal notification after the worker's exact donor-removal ACK; never issues completion itself. */
@@ -242,7 +243,7 @@ interface TelegramUpdateAdmissionBinding {
242
243
  export type TelegramQueueAdmissionReceiptLike = TelegramQueueAdmissionReceipt;
243
244
  export declare function bindTelegramUpdateAdmissionSource<TUpdate extends TelegramUpdateFlow & {
244
245
  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;
246
+ }>(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
247
  /**
247
248
  * Conservative read-only census: any retained entry naming this Thread, in any nested Telegram object or state,
248
249
  * is unresolved custody, except a lone button tap, which carries no input (the in-flight Cancel itself).
@@ -262,6 +263,8 @@ export declare function inspectTelegramDeferredSource(value: unknown): TelegramD
262
263
  export declare function inspectTelegramDeferredSourceSnapshot(value: unknown): TelegramDeferredSourceSnapshot | undefined;
263
264
  /** Exact warm removal ACK only; reporting, source absence and a replaced worker cannot supply it. */
264
265
  export declare function inspectTelegramDeferredSourceCompletion(value: unknown): TelegramDeferredSourceEvidence | undefined;
266
+ /** Warm ACK notification only; subscribers must independently recheck exact completion and recipient authority. */
267
+ export declare function subscribeTelegramDeferredSourceCompletion(value: unknown, notify: () => void): (() => void) | undefined;
265
268
  /** Reuse only live bound deferred originals; raw IDs or cold/adopted input cannot mint this carrier. */
266
269
  export declare function prepareTelegramLiveDeferredInput(values: readonly unknown[], isCurrent: () => boolean): TelegramLiveDeferredInputPreparation | undefined;
267
270
  /** A route-owned pre-disposition acceptance publisher; wraps only this carrier, never the shared worker binding. */
@@ -255,6 +255,16 @@ export function inspectTelegramDeferredSourceCompletion(value) {
255
255
  const source = getTelegramUpdateAdmissionBinding(value)?.inspectCompletion?.();
256
256
  return execution.isCurrent() && source ? { ...source } : undefined;
257
257
  }
258
+ /** Warm ACK notification only; subscribers must independently recheck exact completion and recipient authority. */
259
+ export function subscribeTelegramDeferredSourceCompletion(value, notify) {
260
+ const execution = getTelegramUpdateExecutionFence(value);
261
+ if (execution?.isCurrent() !== true)
262
+ return undefined;
263
+ return getTelegramUpdateAdmissionBinding(value)?.subscribeCompletion?.(() => {
264
+ if (execution.isCurrent())
265
+ notify();
266
+ });
267
+ }
258
268
  /** Reuse only live bound deferred originals; raw IDs or cold/adopted input cannot mint this carrier. */
259
269
  export function prepareTelegramLiveDeferredInput(values, isCurrent) {
260
270
  const carriers = [...values], first = carriers[0], execution = getTelegramUpdateExecutionFence(first);
@@ -5155,6 +5165,7 @@ function createTelegramUpdateSourceAdmissionHandle(deps, capture) {
5155
5165
  let outcome;
5156
5166
  let completionAttempted = false;
5157
5167
  let completedSource;
5168
+ const completionObservers = new Set();
5158
5169
  const boundUpdate = bindTelegramUpdateExecutionFence(bindTelegramUpdateAdmissionSource(update, (next) => {
5159
5170
  if (!execution.isCurrent())
5160
5171
  return;
@@ -5199,11 +5210,15 @@ function createTelegramUpdateSourceAdmissionHandle(deps, capture) {
5199
5210
  completion &&
5200
5211
  execution.isCurrent() &&
5201
5212
  completion.isCurrent() &&
5202
- isDeepStrictEqual(completion.source, expectedSource))
5203
- completedSource = {
5204
- ...completion,
5205
- source: { ...expectedSource },
5206
- };
5213
+ isDeepStrictEqual(completion.source, expectedSource)) {
5214
+ completedSource = { ...completion, source: { ...expectedSource } };
5215
+ for (const notify of completionObservers) {
5216
+ try {
5217
+ notify();
5218
+ }
5219
+ catch { /* Observation cannot invalidate an exact removal ACK. */ }
5220
+ }
5221
+ }
5207
5222
  })
5208
5223
  .catch((error) => {
5209
5224
  try {
@@ -5260,6 +5275,10 @@ function createTelegramUpdateSourceAdmissionHandle(deps, capture) {
5260
5275
  signal,
5261
5276
  })
5262
5277
  : undefined,
5278
+ subscribeCompletion: (notify) => {
5279
+ completionObservers.add(notify);
5280
+ return () => { completionObservers.delete(notify); };
5281
+ },
5263
5282
  inspectCompletion: () => execution.isCurrent() && completedSource?.isCurrent()
5264
5283
  ? { ...completedSource.source }
5265
5284
  : 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.1",
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.
@@ -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
 
@@ -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.
@@ -42,7 +42,6 @@ import {
42
42
  sendTelegramBusLocalEnvelope,
43
43
  TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
44
44
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY,
45
- TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET,
46
45
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE,
47
46
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE,
48
47
  TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION,
@@ -52,6 +51,7 @@ import {
52
51
  TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE,
53
52
  TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
54
53
  TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES,
54
+ getTelegramBusHeldCommandCapabilities,
55
55
  TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES,
56
56
  TelegramBusLocalAuthorityError,
57
57
  type TelegramBusAgentMessage,
@@ -178,6 +178,7 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
178
178
  target?: TelegramTarget;
179
179
  previousInstanceId?: string;
180
180
  restoreWorkspace?: boolean;
181
+ recoverConnection?: boolean;
181
182
  },
182
183
  ) => Promise<boolean>;
183
184
  setContext: (ctx: TContext) => void | Promise<void>;
@@ -2930,7 +2931,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
2930
2931
  deps.setActiveAuthSecret?.(undefined);
2931
2932
  await deps.stopReceiving?.();
2932
2933
  };
2933
- const pendingHandoff = options
2934
+ const pendingHandoff = options && !options.recoverConnection
2934
2935
  ? undefined
2935
2936
  : getTelegramFollowerSessionHandoff();
2936
2937
  const pendingHandoffOptions = isTelegramFollowerSessionHandoffFresh(
@@ -2946,8 +2947,9 @@ export function createTelegramBusFollowerRegistrationRuntime<
2946
2947
  target?: TelegramTarget;
2947
2948
  previousInstanceId?: string;
2948
2949
  restoreWorkspace?: boolean;
2950
+ recoverConnection?: boolean;
2949
2951
  }
2950
- | undefined = options ?? pendingHandoffOptions;
2952
+ | undefined = options ? { ...pendingHandoffOptions, ...options } : pendingHandoffOptions;
2951
2953
  const leaderSocketPath =
2952
2954
  leader.busSocketPath ??
2953
2955
  deps.getLeaderSocketPath?.() ??
@@ -2968,6 +2970,8 @@ export function createTelegramBusFollowerRegistrationRuntime<
2968
2970
  : "follower.register",
2969
2971
  requestId: registrationGeneration,
2970
2972
  auth: activeAuthSecret,
2973
+ ...(registrationOptions?.recoverConnection && !registrationOptions.restoreWorkspace
2974
+ ? { recoverConnection: true as const } : {}),
2971
2975
  registration: {
2972
2976
  instanceId: deps.instanceId,
2973
2977
  ...(registrationOptions?.previousInstanceId
@@ -4546,8 +4550,7 @@ export function createTelegramBusForwardedUpdateReceiverRuntime<TContext>(
4546
4550
  ? [
4547
4551
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY,
4548
4552
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE,
4549
- TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET,
4550
- TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY,
4553
+ ...getTelegramBusHeldCommandCapabilities(commandInput.name),
4551
4554
  ]
4552
4555
  : []),
4553
4556
  ];