@llblab/pi-telegram 0.54.3 → 0.54.4

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.
@@ -302,7 +302,8 @@ function formatTelegramUnboundRerouteChooserText(command) {
302
302
  return `<b>🔀 Reroute ${formatTelegramRouteSubject(command)} to:</b>`;
303
303
  }
304
304
  function formatTelegramUnboundRerouteRestoreChooserText(command) {
305
- return `<b>🔁 Restore into this tab & send ${formatTelegramRouteSubject(command)}:</b>`;
305
+ const subject = command ? ` for ${formatTelegramRouteSubject(command)}` : "";
306
+ return `<b>🔁 Restore a Pi into this tab${subject}:</b>`;
306
307
  }
307
308
  function formatTelegramUnboundTopicGuidance() {
308
309
  return [
@@ -370,7 +371,10 @@ function classifyLiveRebindCleanupFailure(error) {
370
371
  ? "rejected"
371
372
  : "unknown";
372
373
  }
373
- /** Default live-rebind cleanup pacing: quick early attempts, then minutely, within a 15-minute window. */
374
+ /**
375
+ * Default live-rebind cleanup pacing: quick early attempts, then minutely, within a 15-minute window. The first
376
+ * delay is not cosmetic: a leader's detached command reply still needs the released, cleanup-free row.
377
+ */
374
378
  export const TELEGRAM_LIVE_REBIND_CLEANUP_SCHEDULE = Object.freeze({
375
379
  delaysMs: Object.freeze([1_000, 2_000, 5_000, 10_000, 30_000]),
376
380
  intervalMs: 60_000,
@@ -465,7 +469,7 @@ export function createTelegramLiveRebindCoordinator(input) {
465
469
  return confirmed && current();
466
470
  };
467
471
  let preparation;
468
- let saved = false, commitIssued = false, applyIssued = false, peerReleaseIssued = false, donorSettlementAttempted = false, continuationIssued = false, released = false, advancing = false;
472
+ let saved = false, commitIssued = false, applyIssued = false, retitleIssued = false, peerReleaseIssued = false, donorSettlementAttempted = false, continuationIssued = false, released = false, advancing = false;
469
473
  let preparedSource;
470
474
  const runFollower = async (mode) => {
471
475
  if (!input.follower || !recipient || !current())
@@ -687,6 +691,19 @@ export function createTelegramLiveRebindCoordinator(input) {
687
691
  }
688
692
  if (!sourceSaved() || (preparation && !preparation.confirmSaved()))
689
693
  return "protected";
694
+ if (input.retitle && !retitleIssued) {
695
+ retitleIssued = true;
696
+ try {
697
+ await input.retitle(current);
698
+ }
699
+ catch (error) {
700
+ input.recordRuntimeEvent?.("telegram", error, {
701
+ phase: "live-rebind-title",
702
+ });
703
+ }
704
+ if (!current() || !canonical(intent))
705
+ return "unknown";
706
+ }
690
707
  if (intent.phase === "rebound") {
691
708
  const expected = intent;
692
709
  input.restoreStore.advanceLiveRebind(expected, "release", recipient.kind === "leader"
@@ -1561,6 +1578,47 @@ export function createTelegramInboundRouteRuntime(deps) {
1561
1578
  };
1562
1579
  /** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
1563
1580
  const deleteAllTabCopies = (pending) => deleteAllTabMessages(pending.sourceTarget.chatId, pending.allTabCopies?.splice(0) ?? []);
1581
+ /**
1582
+ * Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
1583
+ * holds its forwarded copy. Without one it stays where the operator can still read it.
1584
+ */
1585
+ const releaseAllTabCopiesIntoSurvivingTab = (pending) => {
1586
+ if (pending.allTabCopiesForwarded)
1587
+ return deleteAllTabCopies(pending);
1588
+ pending.allTabCopies?.splice(0);
1589
+ return Promise.resolve();
1590
+ };
1591
+ /**
1592
+ * The Bot API files a threadless All input in All even when a mobile client first draws it in its new tab. One silent
1593
+ * best-effort forward moves it into the routing tab it opened, and only a confirmed copy lets the original leave All;
1594
+ * any failure keeps the original where the operator can still read it.
1595
+ */
1596
+ const moveAllTabSourceIntoTab = async (pending, target, isCurrent) => {
1597
+ const [messageId] = pending.allTabCopies ?? [];
1598
+ if (messageId === undefined || !deps.callApi || !isCurrent())
1599
+ return;
1600
+ try {
1601
+ const copy = await deps.callApi("forwardMessage", {
1602
+ chat_id: target.chatId,
1603
+ message_thread_id: target.threadId,
1604
+ from_chat_id: pending.sourceTarget.chatId,
1605
+ message_id: messageId,
1606
+ disable_notification: true,
1607
+ });
1608
+ const copyId = copy?.message_id;
1609
+ if (typeof copyId !== "number" || !Number.isSafeInteger(copyId))
1610
+ return;
1611
+ pending.allTabCopiesForwarded = true;
1612
+ await deleteAllTabCopies(pending);
1613
+ return copyId;
1614
+ }
1615
+ catch (error) {
1616
+ deps.recordRuntimeEvent?.("routing", error, {
1617
+ phase: "temporary-thread-source-forward",
1618
+ });
1619
+ return;
1620
+ }
1621
+ };
1564
1622
  const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
1565
1623
  const chatId = query.message?.chat?.id;
1566
1624
  const messageId = query.message?.message_id;
@@ -1586,6 +1644,26 @@ export function createTelegramInboundRouteRuntime(deps) {
1586
1644
  return false;
1587
1645
  }
1588
1646
  };
1647
+ /** Choosers are one-shot: a final outcome deletes the menu; only a failed delete leaves an inert, button-free notice. */
1648
+ const retireRerouteChooserMessage = async (chatId, messageId, fallbackText, assertCurrent) => {
1649
+ if (deps.deleteMessage) {
1650
+ try {
1651
+ await deps.deleteMessage(chatId, messageId);
1652
+ return;
1653
+ }
1654
+ catch (error) {
1655
+ deps.recordRuntimeEvent?.("telegram", error, {
1656
+ phase: "reroute-chooser-delete",
1657
+ chatId,
1658
+ messageId,
1659
+ });
1660
+ }
1661
+ assertCurrent?.();
1662
+ }
1663
+ await deps.editInteractiveMessage?.(chatId, messageId, fallbackText, "html", {
1664
+ inline_keyboard: [],
1665
+ });
1666
+ };
1589
1667
  const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent, ownTemporary) => {
1590
1668
  if (!target || !deps.threadStore)
1591
1669
  return true;
@@ -1708,8 +1786,9 @@ export function createTelegramInboundRouteRuntime(deps) {
1708
1786
  continue;
1709
1787
  if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
1710
1788
  continue;
1789
+ // A sibling cancelled by Restore stays visible in the restored tab, like a desktop input typed there.
1711
1790
  if (await retireCancellationChooser(id, pending, current))
1712
- await deleteAllTabCopies(pending);
1791
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
1713
1792
  }
1714
1793
  catch (error) {
1715
1794
  deps.recordRuntimeEvent?.("routing", error, {
@@ -2239,12 +2318,28 @@ export function createTelegramInboundRouteRuntime(deps) {
2239
2318
  if (!intent)
2240
2319
  continue;
2241
2320
  }
2321
+ // The chooser follows the success proof, never old-thread cleanup, which may stay protected indefinitely.
2322
+ const retireRestoreChoosers = async () => {
2323
+ for (const [id, pending] of pendingUnboundReroutes) {
2324
+ if (pending.workspaceRestore?.operationId !== operationId)
2325
+ continue;
2326
+ pending.phase = { kind: "finalizing", message: "Restored" };
2327
+ if (pending.chooserMessageId === undefined)
2328
+ continue;
2329
+ assertSettlementCurrent();
2330
+ await retireRerouteChooserMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", assertSettlementCurrent);
2331
+ assertSettlementCurrent();
2332
+ if (pendingUnboundReroutes.get(id) === pending)
2333
+ removePendingReroute(id);
2334
+ }
2335
+ };
2242
2336
  if (intent.routing?.cleanup === undefined) {
2243
2337
  if (!intent.request.source.updateIds.every((id) => intent.routing.settlements.some((value) => value.kind !== "queued" && value.updateIds.includes(id))))
2244
2338
  continue;
2245
- // Positive settlement of every selected source is the success proof; old-thread cleanup may stay protected indefinitely.
2339
+ // Positive settlement of every selected source is the success proof.
2246
2340
  await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
2247
2341
  assertSettlementCurrent();
2342
+ await retireRestoreChoosers();
2248
2343
  if (isRerouteTargetProtected(intent.request.binding.target, intent))
2249
2344
  continue;
2250
2345
  const grant = store.issueCleanup(intent, settlementAuthority);
@@ -2265,19 +2360,7 @@ export function createTelegramInboundRouteRuntime(deps) {
2265
2360
  if (!store.retire(intent, settlementAuthority))
2266
2361
  continue;
2267
2362
  retired = true;
2268
- for (const [id, pending] of pendingUnboundReroutes) {
2269
- if (pending.workspaceRestore?.operationId !== operationId)
2270
- continue;
2271
- pending.phase = { kind: "finalizing", message: "Restored" };
2272
- if (!deps.editInteractiveMessage ||
2273
- pending.chooserMessageId === undefined)
2274
- continue;
2275
- assertSettlementCurrent();
2276
- await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
2277
- assertSettlementCurrent();
2278
- if (pendingUnboundReroutes.get(id) === pending)
2279
- removePendingReroute(id);
2280
- }
2363
+ await retireRestoreChoosers();
2281
2364
  }
2282
2365
  }))
2283
2366
  .catch((error) => {
@@ -3211,6 +3294,20 @@ export function createTelegramInboundRouteRuntime(deps) {
3211
3294
  messages: selection.messages,
3212
3295
  restoreStore: store,
3213
3296
  threadStore: threads,
3297
+ // Same title as Workspace Restore: the display-mode title of the restored binding on its new tab.
3298
+ retitle: deps.callApi
3299
+ ? async (isCurrent) => {
3300
+ if (!isCurrent())
3301
+ return;
3302
+ const slot = binding.slot ?? "";
3303
+ await deps.callApi("editForumTopic", {
3304
+ chat_id: target.chatId,
3305
+ message_thread_id: target.threadId,
3306
+ name: deps.getDisplayTitle?.(target) ??
3307
+ ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot),
3308
+ });
3309
+ }
3310
+ : undefined,
3214
3311
  authority: {
3215
3312
  executor: { instanceId, leaderEpoch: String(epoch) },
3216
3313
  operatorUserId,
@@ -3729,6 +3826,8 @@ export function createTelegramInboundRouteRuntime(deps) {
3729
3826
  const issueLiveRebindFollowerCleanup = (intent, ctx) => inspectLiveRebindRecipient(intent, ctx, "follower-issue");
3730
3827
  // One pacing chain per operation: a waiting timer or a running attempt; repeated choices never start another.
3731
3828
  const liveRebindCleanupChains = new Map();
3829
+ // A leader retry waiting out its delay; a settled session wakes it at once, since its own busy run is the usual wait.
3830
+ const liveRebindCleanupWakers = new Map();
3732
3831
  /** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
3733
3832
  const terminalizeUnissuedLiveRebindCleanup = async (operationId, ctx) => {
3734
3833
  const run = deps.runWorkspaceOperation, store = deps.getWorkspaceRestoreStore?.();
@@ -3779,7 +3878,8 @@ export function createTelegramInboundRouteRuntime(deps) {
3779
3878
  liveRebindCleanupChains.delete(operationId);
3780
3879
  return;
3781
3880
  }
3782
- const timer = setTimeout(() => {
3881
+ const attemptNow = () => {
3882
+ liveRebindCleanupWakers.delete(operationId);
3783
3883
  if (liveRebindCleanupChains.get(operationId) !== timer)
3784
3884
  return;
3785
3885
  liveRebindCleanupChains.set(operationId, "running");
@@ -3814,9 +3914,25 @@ export function createTelegramInboundRouteRuntime(deps) {
3814
3914
  });
3815
3915
  restoreSettlementTasks.add(task);
3816
3916
  void task.finally(() => restoreSettlementTasks.delete(task));
3817
- }, pacing.delaysMs[attempt] ?? pacing.intervalMs);
3917
+ };
3918
+ const timer = setTimeout(attemptNow, pacing.delaysMs[attempt] ?? pacing.intervalMs);
3818
3919
  timer.unref?.();
3819
3920
  liveRebindCleanupChains.set(operationId, timer);
3921
+ // The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
3922
+ if (role === "leader" && attempt > 0)
3923
+ liveRebindCleanupWakers.set(operationId, () => {
3924
+ if (liveRebindCleanupChains.get(operationId) !== timer)
3925
+ return;
3926
+ clearTimeout(timer);
3927
+ attemptNow();
3928
+ });
3929
+ };
3930
+ /** The agent settled: every gate is re-sampled now, so an old tab goes right after the answer, not at the next tick. */
3931
+ const onSessionSettled = (ctx) => {
3932
+ if (deps.isContextActive?.(ctx) !== true)
3933
+ return;
3934
+ for (const wake of [...liveRebindCleanupWakers.values()])
3935
+ wake();
3820
3936
  };
3821
3937
  const restoreWorkspace = deps.workspaceRestoreRecipient
3822
3938
  ? async (input) => {
@@ -4203,17 +4319,25 @@ export function createTelegramInboundRouteRuntime(deps) {
4203
4319
  }
4204
4320
  await promptEnqueue(messages, ctx);
4205
4321
  };
4206
- const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query)) => {
4322
+ const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query), retainRecord = false) => {
4207
4323
  assertExecutionCurrent();
4208
4324
  const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
4209
4325
  assertExecutionCurrent();
4210
4326
  if (dismissed) {
4211
- removePendingReroute(rerouteId);
4327
+ // A retained record no longer matches any chooser, so a late duplicate tap reads as expired.
4328
+ if (retainRecord)
4329
+ pending.chooserMessageId = undefined;
4330
+ else
4331
+ removePendingReroute(rerouteId);
4212
4332
  await deps.answerCallbackQuery(query.id, successMessage);
4213
4333
  await deleteAllTabCopies(pending);
4214
4334
  return;
4215
4335
  }
4216
- pending.phase = { kind: "finalizing", message: successMessage };
4336
+ pending.phase = {
4337
+ kind: "finalizing",
4338
+ message: successMessage,
4339
+ ...(retainRecord ? { retainRecord: true } : {}),
4340
+ };
4217
4341
  await deps.answerCallbackQuery(query.id, `${successMessage}; tap again to clear the menu`);
4218
4342
  };
4219
4343
  const isTemporaryReroute = (pending) => !!pending.temporaryThread ||
@@ -4613,10 +4737,10 @@ export function createTelegramInboundRouteRuntime(deps) {
4613
4737
  const retireCancellationChooser = async (id, pending, isCurrent) => {
4614
4738
  if (!isPendingRerouteCancelled(pending) ||
4615
4739
  !isCurrent() ||
4616
- !deps.editInteractiveMessage ||
4740
+ (!deps.deleteMessage && !deps.editInteractiveMessage) ||
4617
4741
  pending.chooserMessageId === undefined)
4618
4742
  return false;
4619
- await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⛔️ Routing cancelled.</b>", "html", { inline_keyboard: [] });
4743
+ await retireRerouteChooserMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⛔️ Routing cancelled.</b>");
4620
4744
  if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
4621
4745
  return false;
4622
4746
  removePendingReroute(id);
@@ -4973,9 +5097,11 @@ export function createTelegramInboundRouteRuntime(deps) {
4973
5097
  if (!result)
4974
5098
  return;
4975
5099
  // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
5100
+ let movedIntoTab = false;
4976
5101
  for (const [id, pending] of pendingUnboundReroutes) {
4977
5102
  if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
4978
5103
  continue;
5104
+ movedIntoTab ||= pending.allTabCopiesForwarded === true;
4979
5105
  removePendingReroute(id);
4980
5106
  if (signal.aborted ||
4981
5107
  deps.isContextActive?.(ctx) !== true ||
@@ -4999,7 +5125,9 @@ export function createTelegramInboundRouteRuntime(deps) {
4999
5125
  // never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
5000
5126
  if (temporary?.target && runtimeCurrent()) {
5001
5127
  scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
5002
- await deleteAllTabMessages(temporary.target.chatId, getTelegramAllTabSourceMessageIds([message], temporary.target.chatId));
5128
+ // A moved original already left All with its forward; only an unmoved one is deleted here.
5129
+ if (!movedIntoTab)
5130
+ await deleteAllTabMessages(temporary.target.chatId, getTelegramAllTabSourceMessageIds([message], temporary.target.chatId));
5003
5131
  }
5004
5132
  });
5005
5133
  };
@@ -5448,7 +5576,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5448
5576
  return true;
5449
5577
  }
5450
5578
  if (pending.phase.kind === "finalizing") {
5451
- await finalizePendingReroute(parsed.rerouteId, pending, query, pending.phase.message, assertExecutionCurrent);
5579
+ await finalizePendingReroute(parsed.rerouteId, pending, query, pending.phase.message, assertExecutionCurrent, pending.phase.retainRecord === true);
5452
5580
  return true;
5453
5581
  }
5454
5582
  if (pending.liveRebind &&
@@ -5723,7 +5851,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5723
5851
  if (row?.phase === "released" && row.cleanup === undefined)
5724
5852
  scheduleLiveRebindCleanup(operationId, selection.record.owner?.kind === "leader" ? "leader" : "follower", ctx);
5725
5853
  if (outcome === "released")
5726
- await deleteAllTabCopies(pending);
5854
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
5727
5855
  }
5728
5856
  catch (error) {
5729
5857
  deps.recordRuntimeEvent?.("routing", error, {
@@ -5734,29 +5862,32 @@ export function createTelegramInboundRouteRuntime(deps) {
5734
5862
  selection.isCurrent = undefined;
5735
5863
  }
5736
5864
  assertExecutionCurrent();
5737
- await deps.answerCallbackQuery(query.id, outcome === "released"
5738
- ? selection.extensionKind === "generated-prompt"
5739
- ? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
5740
- : selection.extensionCommand
5741
- ? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
5742
- : selection.nameCommand
5743
- ? selection.nameCommand.args.trim()
5744
- ? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
5745
- : "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
5746
- : selection.confirmationCommand
5747
- ? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
5748
- : selection.helpCommand
5749
- ? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
5750
- : selection.stopCommand
5751
- ? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
5752
- : selection.nextCommand
5753
- ? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
5754
- : selection.menuCommand || selection.abortCommand
5755
- ? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
5756
- : selection.coordinator?.selectedCommandReference()
5757
- ? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
5758
- : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
5759
- : "Live rebind is unconfirmed; this choice never replays issued input");
5865
+ if (outcome !== "released") {
5866
+ await deps.answerCallbackQuery(query.id, "Live rebind is unconfirmed; this choice never replays issued input");
5867
+ return true;
5868
+ }
5869
+ // The tab now belongs to the rebound thread; only the one-shot chooser message goes.
5870
+ await finalizePendingReroute(parsed.rerouteId, pending, query, selection.extensionKind === "generated-prompt"
5871
+ ? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
5872
+ : selection.extensionCommand
5873
+ ? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
5874
+ : selection.nameCommand
5875
+ ? selection.nameCommand.args.trim()
5876
+ ? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
5877
+ : "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
5878
+ : selection.confirmationCommand
5879
+ ? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
5880
+ : selection.helpCommand
5881
+ ? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
5882
+ : selection.stopCommand
5883
+ ? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
5884
+ : selection.nextCommand
5885
+ ? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
5886
+ : selection.menuCommand || selection.abortCommand
5887
+ ? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
5888
+ : selection.coordinator?.selectedCommandReference()
5889
+ ? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
5890
+ : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
5760
5891
  return true;
5761
5892
  }
5762
5893
  if (!(await selectPendingDestination()))
@@ -5768,6 +5899,8 @@ export function createTelegramInboundRouteRuntime(deps) {
5768
5899
  messages: [...pending.messages],
5769
5900
  });
5770
5901
  let active = true;
5902
+ // Delivery proof for the toast only; chooser retirement still waits for the positive settlement that follows.
5903
+ let delivered = false;
5771
5904
  const ownerUserId = deps.configStore.getAllowedUserId();
5772
5905
  const leaderEpoch = deps.getCurrentLeaderEpoch?.();
5773
5906
  const admissionScope = deps.getAdmissionScope?.();
@@ -5818,12 +5951,15 @@ export function createTelegramInboundRouteRuntime(deps) {
5818
5951
  : routed;
5819
5952
  await dispatchPendingRerouteMessages(pending, messages, ctx);
5820
5953
  assertRecipientCurrent();
5821
- await deleteAllTabCopies(pending);
5954
+ delivered = true;
5955
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
5822
5956
  return true;
5823
5957
  }
5824
5958
  const forwarded = await forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
5825
- if (forwarded)
5826
- await deleteAllTabCopies(pending);
5959
+ if (forwarded) {
5960
+ delivered = true;
5961
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
5962
+ }
5827
5963
  return forwarded;
5828
5964
  },
5829
5965
  });
@@ -5834,7 +5970,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5834
5970
  assertExecutionCurrent();
5835
5971
  if (!selectionCurrent())
5836
5972
  return true;
5837
- await deps.answerCallbackQuery(query.id, "Restore unconfirmed; not resending");
5973
+ await deps.answerCallbackQuery(query.id, delivered ? "Restored" : "Restore unconfirmed; not resending");
5838
5974
  return true;
5839
5975
  }
5840
5976
  if (record.instanceId &&
@@ -6856,13 +6992,16 @@ export function createTelegramInboundRouteRuntime(deps) {
6856
6992
  }
6857
6993
  return undefined;
6858
6994
  };
6859
- /** A fresh implicit native tab whose name the All input starts with, observed just before it in the same exact scope. */
6995
+ /**
6996
+ * A fresh implicit native tab observed just before the All input in the same exact scope: mobile names it after
6997
+ * the input, while other clients may use a generic name, so the immediately preceding creation also qualifies.
6998
+ */
6860
6999
  const findImplicitCreationForAllInput = (message, updateId, cap, ctx) => {
6861
7000
  const text = (message.text ?? message.caption ?? "").trim();
6862
7001
  let match;
6863
7002
  for (const [key, implicit] of implicitThreadCreations) {
6864
- if (implicit.name &&
6865
- text.startsWith(implicit.name) &&
7003
+ if (((implicit.name && text.startsWith(implicit.name)) ||
7004
+ implicit.updateId === updateId - 1) &&
6866
7005
  implicit.updateId < updateId &&
6867
7006
  implicit.createdAtSec > 0 &&
6868
7007
  message.date !== undefined &&
@@ -6906,6 +7045,9 @@ export function createTelegramInboundRouteRuntime(deps) {
6906
7045
  .find((entry) => entry.source.journalBindingKey === journalBindingKey &&
6907
7046
  entry.source.updateId === updateId);
6908
7047
  let entry;
7048
+ // A tab opened by this call, by the bot or adopted from the client, gets the input's one forwarded copy; re-entry
7049
+ // after a restart finds its tab already prepared and never forwards again.
7050
+ let openedNow = false;
6909
7051
  await deps.runWorkspaceOperation({
6910
7052
  operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
6911
7053
  operationKind: "workspace.temporary-thread",
@@ -6931,6 +7073,7 @@ export function createTelegramInboundRouteRuntime(deps) {
6931
7073
  if (adopted && current()) {
6932
7074
  implicitThreadCreations.delete(native.key);
6933
7075
  entry = adopted;
7076
+ openedNow = true;
6934
7077
  // Adopted native tabs share the routing name; a failed rename never blocks routing.
6935
7078
  try {
6936
7079
  await deps.callApi("editForumTopic", {
@@ -6971,8 +7114,9 @@ export function createTelegramInboundRouteRuntime(deps) {
6971
7114
  !Number.isSafeInteger(threadId) ||
6972
7115
  threadId <= 0)
6973
7116
  return;
6974
- entry =
6975
- store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority) ?? entry;
7117
+ const acknowledged = store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority);
7118
+ openedNow = !!acknowledged;
7119
+ entry = acknowledged ?? entry;
6976
7120
  });
6977
7121
  if (!current() || !entry)
6978
7122
  throw new Error("Temporary Thread authority changed; the input remains retryable.");
@@ -7003,6 +7147,9 @@ export function createTelegramInboundRouteRuntime(deps) {
7003
7147
  };
7004
7148
  }
7005
7149
  pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
7150
+ const tabCopyId = openedNow
7151
+ ? await moveAllTabSourceIntoTab(pending, target, current)
7152
+ : undefined;
7006
7153
  try {
7007
7154
  if (!deps.sendInteractiveMessage)
7008
7155
  throw new Error("Temporary Thread chooser publication is unavailable.");
@@ -7010,7 +7157,9 @@ export function createTelegramInboundRouteRuntime(deps) {
7010
7157
  canRestore: true,
7011
7158
  canCancel: !!pending.abandonment,
7012
7159
  getDisplayTitle: deps.getDisplayTitle,
7013
- }), { target });
7160
+ }), tabCopyId === undefined
7161
+ ? { target }
7162
+ : { target, replyToMessageId: tabCopyId });
7014
7163
  rememberRerouteChooser(rerouteId, chooserId);
7015
7164
  }
7016
7165
  catch (error) {
@@ -7311,7 +7460,9 @@ export function createTelegramInboundRouteRuntime(deps) {
7311
7460
  chatId: lifecycle.target.chatId,
7312
7461
  threadId: lifecycle.target.threadId,
7313
7462
  },
7314
- name: "name" in created && typeof created.name === "string" ? created.name : "",
7463
+ name: "name" in created && typeof created.name === "string"
7464
+ ? created.name
7465
+ : "",
7315
7466
  createdAtSec: message.date ?? 0,
7316
7467
  });
7317
7468
  }
@@ -7623,7 +7774,35 @@ export function createTelegramInboundRouteRuntime(deps) {
7623
7774
  threadId: target.threadId,
7624
7775
  };
7625
7776
  /** Rebinds the leader record to this unbound tab, publishes its identity and handles the message there. */
7626
- const reclaimUnboundTargetForLeader = async (base, profileKey, slot, threadName, event, details) => {
7777
+ // The leader's own Workspace binding follows a reclaimed tab, keeping its slot and name; without it, the tab
7778
+ // would gain a second slot while Restore, labels and titles still pointed at the deleted target.
7779
+ const leaderWorkspaceCwd = typeof ctx.cwd === "string"
7780
+ ? ctx.cwd
7781
+ : undefined;
7782
+ const claimLeaderWorkspaceBinding = () => {
7783
+ const sessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
7784
+ if (!leaderWorkspaceCwd || !sessionId || !instanceId)
7785
+ return undefined;
7786
+ const identity = deps.threadStore.claimWorkspaceIdentity(leaderWorkspaceCwd, instanceId, undefined, { existingBindingOnly: true, sessionId });
7787
+ const binding = identity
7788
+ ? deps.threadStore.getWorkspaceBinding(identity.cwd, identity.instanceSlot, identity.sessionId)
7789
+ : undefined;
7790
+ if (!binding)
7791
+ deps.threadStore.releaseWorkspaceClaim(instanceId);
7792
+ return binding;
7793
+ };
7794
+ const reclaimUnboundTargetForLeader = async (base, profileKey, slot, threadName, event, details, leaderBinding) => {
7795
+ if (leaderBinding) {
7796
+ const { inactiveSinceMs: _inactiveSinceMs, displayTitle: _displayTitle, ...moved } = leaderBinding;
7797
+ const committed = deps.threadStore.upsertWorkspaceBinding({
7798
+ ...moved,
7799
+ target: { ...unboundTarget },
7800
+ updatedAtMs: Date.now(),
7801
+ }, instanceId);
7802
+ deps.threadStore.releaseWorkspaceClaim(instanceId);
7803
+ if (committed)
7804
+ threadName = committed.threadName ?? threadName;
7805
+ }
7627
7806
  deps.threadStore.upsert({
7628
7807
  ...base,
7629
7808
  profileKey,
@@ -7654,6 +7833,33 @@ export function createTelegramInboundRouteRuntime(deps) {
7654
7833
  slot,
7655
7834
  profileKey,
7656
7835
  });
7836
+ if (leaderBinding && deps.callApi) {
7837
+ const title = deps.getDisplayTitle?.(unboundTarget);
7838
+ if (title)
7839
+ try {
7840
+ await deps.callApi("editForumTopic", {
7841
+ chat_id: unboundTarget.chatId,
7842
+ message_thread_id: unboundTarget.threadId,
7843
+ name: title,
7844
+ });
7845
+ const current = deps
7846
+ .threadStore.listWorkspaceBindings()
7847
+ .find((value) => value.bindingKey === leaderBinding.bindingKey &&
7848
+ isDeepStrictEqual(value.target, unboundTarget));
7849
+ if (current) {
7850
+ deps.threadStore.upsertWorkspaceBinding({
7851
+ ...current,
7852
+ displayTitle: title,
7853
+ });
7854
+ await deps.threadStore.persist();
7855
+ }
7856
+ }
7857
+ catch (error) {
7858
+ deps.recordRuntimeEvent?.("bus", error, {
7859
+ phase: "leader-topic-reclaim-title",
7860
+ });
7861
+ }
7862
+ }
7657
7863
  await textDispatch.handleMessage(message, ctx);
7658
7864
  };
7659
7865
  const records = deps.threadStore.list();
@@ -7754,7 +7960,10 @@ export function createTelegramInboundRouteRuntime(deps) {
7754
7960
  throw error;
7755
7961
  }
7756
7962
  if (currentLeaderIsStale) {
7757
- const slot = deps.threadStore.allocateSlot(leaderProfileKey);
7963
+ const leaderBinding = claimLeaderWorkspaceBinding();
7964
+ const slot = deps.threadStore.allocateSlot(leaderProfileKey, leaderBinding?.slot, leaderBinding?.bindingKey);
7965
+ if (leaderBinding && leaderBinding.slot !== slot)
7966
+ deps.threadStore.releaseWorkspaceClaim(instanceId);
7758
7967
  if (!slot) {
7759
7968
  deps.threadStore.markStaleByTarget(currentLeaderRecord.target, "deleted", "Current leader thread is stale during unbound prompt routing.");
7760
7969
  await deps.threadStore.persist();
@@ -7767,7 +7976,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7767
7976
  await reclaimUnboundTargetForLeader(currentLeaderRecord, leaderProfileKey, slot, threadName, "Bus leader reclaimed stale-current unbound thread", {
7768
7977
  phase: "leader-topic-unbound-stale-reclaim",
7769
7978
  staleThreadId: currentLeaderRecord.target.threadId,
7770
- });
7979
+ }, leaderBinding?.slot === slot ? leaderBinding : undefined);
7771
7980
  return;
7772
7981
  }
7773
7982
  }
@@ -7777,7 +7986,12 @@ export function createTelegramInboundRouteRuntime(deps) {
7777
7986
  !hasAnyRoutableThread) {
7778
7987
  const priorLeaderRecord = deps.threadStore.getByProfileKey(leaderProfileKey);
7779
7988
  const priorLeaderIdentity = deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
7780
- const slot = deps.threadStore.allocateSlot(leaderProfileKey, priorLeaderRecord?.slot ?? priorLeaderIdentity?.slot);
7989
+ const leaderBinding = claimLeaderWorkspaceBinding();
7990
+ const slot = deps.threadStore.allocateSlot(leaderProfileKey, leaderBinding?.slot ??
7991
+ priorLeaderRecord?.slot ??
7992
+ priorLeaderIdentity?.slot, leaderBinding?.bindingKey);
7993
+ if (leaderBinding && leaderBinding.slot !== slot)
7994
+ deps.threadStore.releaseWorkspaceClaim(instanceId);
7781
7995
  if (!slot) {
7782
7996
  await deps.sendTextReply(target.chatId, message.message_id, TELEGRAM_SLOT_CAPACITY_MESSAGE, { target });
7783
7997
  return;
@@ -7790,7 +8004,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7790
8004
  identityThreadName ??
7791
8005
  ThreadNaming.chooseTelegramThreadName({ slot }) ??
7792
8006
  "Pi";
7793
- await reclaimUnboundTargetForLeader({ createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now() }, leaderProfileKey, slot, threadName, "Bus leader reclaimed unbound thread", { phase: "leader-topic-reclaim" });
8007
+ await reclaimUnboundTargetForLeader({ createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now() }, leaderProfileKey, slot, threadName, "Bus leader reclaimed unbound thread", { phase: "leader-topic-reclaim" }, leaderBinding?.slot === slot ? leaderBinding : undefined);
7794
8008
  return;
7795
8009
  }
7796
8010
  await sendUnboundRerouteChooser(message, ctx);
@@ -7817,6 +8031,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7817
8031
  onUpdateCompleted,
7818
8032
  prepareHeldCommand: commandHandler.prepareHeldCommand,
7819
8033
  canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
8034
+ onSessionSettled,
7820
8035
  observeLiveRebindLeaderWork,
7821
8036
  prepareLiveRebindLeaderCleanup,
7822
8037
  prepareLiveRebindFollowerCleanup,
@@ -855,6 +855,8 @@ export interface TelegramCurrentThreadAssemblyDeps {
855
855
  instanceId: string;
856
856
  listRecords: TelegramCurrentInstanceThreadRuntimeDeps["listRecords"];
857
857
  listWorkspaceBindings?: () => readonly TelegramWorkspaceThreadBinding[];
858
+ /** Display-mode title for a binding whose tab has no acknowledged title yet (fresh, moved or reclaimed). */
859
+ resolveAutomaticDisplayTitle?: (binding: TelegramWorkspaceThreadBinding) => string | undefined;
858
860
  getFollowerDisplayTitle?: () => string | undefined;
859
861
  getActiveTurnTarget(): TelegramTarget | undefined;
860
862
  getFollowerTarget(): TelegramTarget | undefined;