@llblab/pi-telegram 0.54.2 → 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"
@@ -910,6 +927,8 @@ export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
910
927
  export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "Routing choice expired";
911
928
  /** Every temporary routing tab carries this name, whether the bot created it or adopted Telegram's native tab. */
912
929
  const TELEGRAM_TEMPORARY_THREAD_NAME = "🚦 Routing";
930
+ /** A mobile client's native tab creation precedes the All input it is named after by about a second. */
931
+ const TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC = 10;
913
932
  export function isTelegramAllTabCommandExpired(message, nowMs = Date.now()) {
914
933
  return (message.message_thread_id === undefined &&
915
934
  typeof message.date === "number" &&
@@ -1559,6 +1578,47 @@ export function createTelegramInboundRouteRuntime(deps) {
1559
1578
  };
1560
1579
  /** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
1561
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
+ };
1562
1622
  const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
1563
1623
  const chatId = query.message?.chat?.id;
1564
1624
  const messageId = query.message?.message_id;
@@ -1584,6 +1644,26 @@ export function createTelegramInboundRouteRuntime(deps) {
1584
1644
  return false;
1585
1645
  }
1586
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
+ };
1587
1667
  const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent, ownTemporary) => {
1588
1668
  if (!target || !deps.threadStore)
1589
1669
  return true;
@@ -1706,8 +1786,9 @@ export function createTelegramInboundRouteRuntime(deps) {
1706
1786
  continue;
1707
1787
  if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
1708
1788
  continue;
1789
+ // A sibling cancelled by Restore stays visible in the restored tab, like a desktop input typed there.
1709
1790
  if (await retireCancellationChooser(id, pending, current))
1710
- await deleteAllTabCopies(pending);
1791
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
1711
1792
  }
1712
1793
  catch (error) {
1713
1794
  deps.recordRuntimeEvent?.("routing", error, {
@@ -2237,12 +2318,28 @@ export function createTelegramInboundRouteRuntime(deps) {
2237
2318
  if (!intent)
2238
2319
  continue;
2239
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
+ };
2240
2336
  if (intent.routing?.cleanup === undefined) {
2241
2337
  if (!intent.request.source.updateIds.every((id) => intent.routing.settlements.some((value) => value.kind !== "queued" && value.updateIds.includes(id))))
2242
2338
  continue;
2243
- // 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.
2244
2340
  await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
2245
2341
  assertSettlementCurrent();
2342
+ await retireRestoreChoosers();
2246
2343
  if (isRerouteTargetProtected(intent.request.binding.target, intent))
2247
2344
  continue;
2248
2345
  const grant = store.issueCleanup(intent, settlementAuthority);
@@ -2263,19 +2360,7 @@ export function createTelegramInboundRouteRuntime(deps) {
2263
2360
  if (!store.retire(intent, settlementAuthority))
2264
2361
  continue;
2265
2362
  retired = true;
2266
- for (const [id, pending] of pendingUnboundReroutes) {
2267
- if (pending.workspaceRestore?.operationId !== operationId)
2268
- continue;
2269
- pending.phase = { kind: "finalizing", message: "Restored" };
2270
- if (!deps.editInteractiveMessage ||
2271
- pending.chooserMessageId === undefined)
2272
- continue;
2273
- assertSettlementCurrent();
2274
- await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
2275
- assertSettlementCurrent();
2276
- if (pendingUnboundReroutes.get(id) === pending)
2277
- removePendingReroute(id);
2278
- }
2363
+ await retireRestoreChoosers();
2279
2364
  }
2280
2365
  }))
2281
2366
  .catch((error) => {
@@ -3209,6 +3294,20 @@ export function createTelegramInboundRouteRuntime(deps) {
3209
3294
  messages: selection.messages,
3210
3295
  restoreStore: store,
3211
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,
3212
3311
  authority: {
3213
3312
  executor: { instanceId, leaderEpoch: String(epoch) },
3214
3313
  operatorUserId,
@@ -3727,6 +3826,8 @@ export function createTelegramInboundRouteRuntime(deps) {
3727
3826
  const issueLiveRebindFollowerCleanup = (intent, ctx) => inspectLiveRebindRecipient(intent, ctx, "follower-issue");
3728
3827
  // One pacing chain per operation: a waiting timer or a running attempt; repeated choices never start another.
3729
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();
3730
3831
  /** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
3731
3832
  const terminalizeUnissuedLiveRebindCleanup = async (operationId, ctx) => {
3732
3833
  const run = deps.runWorkspaceOperation, store = deps.getWorkspaceRestoreStore?.();
@@ -3777,7 +3878,8 @@ export function createTelegramInboundRouteRuntime(deps) {
3777
3878
  liveRebindCleanupChains.delete(operationId);
3778
3879
  return;
3779
3880
  }
3780
- const timer = setTimeout(() => {
3881
+ const attemptNow = () => {
3882
+ liveRebindCleanupWakers.delete(operationId);
3781
3883
  if (liveRebindCleanupChains.get(operationId) !== timer)
3782
3884
  return;
3783
3885
  liveRebindCleanupChains.set(operationId, "running");
@@ -3812,9 +3914,25 @@ export function createTelegramInboundRouteRuntime(deps) {
3812
3914
  });
3813
3915
  restoreSettlementTasks.add(task);
3814
3916
  void task.finally(() => restoreSettlementTasks.delete(task));
3815
- }, pacing.delaysMs[attempt] ?? pacing.intervalMs);
3917
+ };
3918
+ const timer = setTimeout(attemptNow, pacing.delaysMs[attempt] ?? pacing.intervalMs);
3816
3919
  timer.unref?.();
3817
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();
3818
3936
  };
3819
3937
  const restoreWorkspace = deps.workspaceRestoreRecipient
3820
3938
  ? async (input) => {
@@ -4201,17 +4319,25 @@ export function createTelegramInboundRouteRuntime(deps) {
4201
4319
  }
4202
4320
  await promptEnqueue(messages, ctx);
4203
4321
  };
4204
- 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) => {
4205
4323
  assertExecutionCurrent();
4206
4324
  const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
4207
4325
  assertExecutionCurrent();
4208
4326
  if (dismissed) {
4209
- 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);
4210
4332
  await deps.answerCallbackQuery(query.id, successMessage);
4211
4333
  await deleteAllTabCopies(pending);
4212
4334
  return;
4213
4335
  }
4214
- pending.phase = { kind: "finalizing", message: successMessage };
4336
+ pending.phase = {
4337
+ kind: "finalizing",
4338
+ message: successMessage,
4339
+ ...(retainRecord ? { retainRecord: true } : {}),
4340
+ };
4215
4341
  await deps.answerCallbackQuery(query.id, `${successMessage}; tap again to clear the menu`);
4216
4342
  };
4217
4343
  const isTemporaryReroute = (pending) => !!pending.temporaryThread ||
@@ -4611,10 +4737,10 @@ export function createTelegramInboundRouteRuntime(deps) {
4611
4737
  const retireCancellationChooser = async (id, pending, isCurrent) => {
4612
4738
  if (!isPendingRerouteCancelled(pending) ||
4613
4739
  !isCurrent() ||
4614
- !deps.editInteractiveMessage ||
4740
+ (!deps.deleteMessage && !deps.editInteractiveMessage) ||
4615
4741
  pending.chooserMessageId === undefined)
4616
4742
  return false;
4617
- 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>");
4618
4744
  if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
4619
4745
  return false;
4620
4746
  removePendingReroute(id);
@@ -4971,9 +5097,11 @@ export function createTelegramInboundRouteRuntime(deps) {
4971
5097
  if (!result)
4972
5098
  return;
4973
5099
  // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
5100
+ let movedIntoTab = false;
4974
5101
  for (const [id, pending] of pendingUnboundReroutes) {
4975
5102
  if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
4976
5103
  continue;
5104
+ movedIntoTab ||= pending.allTabCopiesForwarded === true;
4977
5105
  removePendingReroute(id);
4978
5106
  if (signal.aborted ||
4979
5107
  deps.isContextActive?.(ctx) !== true ||
@@ -4997,7 +5125,9 @@ export function createTelegramInboundRouteRuntime(deps) {
4997
5125
  // never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
4998
5126
  if (temporary?.target && runtimeCurrent()) {
4999
5127
  scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
5000
- 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));
5001
5131
  }
5002
5132
  });
5003
5133
  };
@@ -5446,7 +5576,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5446
5576
  return true;
5447
5577
  }
5448
5578
  if (pending.phase.kind === "finalizing") {
5449
- 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);
5450
5580
  return true;
5451
5581
  }
5452
5582
  if (pending.liveRebind &&
@@ -5721,7 +5851,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5721
5851
  if (row?.phase === "released" && row.cleanup === undefined)
5722
5852
  scheduleLiveRebindCleanup(operationId, selection.record.owner?.kind === "leader" ? "leader" : "follower", ctx);
5723
5853
  if (outcome === "released")
5724
- await deleteAllTabCopies(pending);
5854
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
5725
5855
  }
5726
5856
  catch (error) {
5727
5857
  deps.recordRuntimeEvent?.("routing", error, {
@@ -5732,29 +5862,32 @@ export function createTelegramInboundRouteRuntime(deps) {
5732
5862
  selection.isCurrent = undefined;
5733
5863
  }
5734
5864
  assertExecutionCurrent();
5735
- await deps.answerCallbackQuery(query.id, outcome === "released"
5736
- ? selection.extensionKind === "generated-prompt"
5737
- ? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
5738
- : selection.extensionCommand
5739
- ? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
5740
- : selection.nameCommand
5741
- ? selection.nameCommand.args.trim()
5742
- ? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
5743
- : "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
5744
- : selection.confirmationCommand
5745
- ? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
5746
- : selection.helpCommand
5747
- ? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
5748
- : selection.stopCommand
5749
- ? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
5750
- : selection.nextCommand
5751
- ? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
5752
- : selection.menuCommand || selection.abortCommand
5753
- ? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
5754
- : selection.coordinator?.selectedCommandReference()
5755
- ? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
5756
- : "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
5757
- : "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);
5758
5891
  return true;
5759
5892
  }
5760
5893
  if (!(await selectPendingDestination()))
@@ -5766,6 +5899,8 @@ export function createTelegramInboundRouteRuntime(deps) {
5766
5899
  messages: [...pending.messages],
5767
5900
  });
5768
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;
5769
5904
  const ownerUserId = deps.configStore.getAllowedUserId();
5770
5905
  const leaderEpoch = deps.getCurrentLeaderEpoch?.();
5771
5906
  const admissionScope = deps.getAdmissionScope?.();
@@ -5816,12 +5951,15 @@ export function createTelegramInboundRouteRuntime(deps) {
5816
5951
  : routed;
5817
5952
  await dispatchPendingRerouteMessages(pending, messages, ctx);
5818
5953
  assertRecipientCurrent();
5819
- await deleteAllTabCopies(pending);
5954
+ delivered = true;
5955
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
5820
5956
  return true;
5821
5957
  }
5822
5958
  const forwarded = await forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
5823
- if (forwarded)
5824
- await deleteAllTabCopies(pending);
5959
+ if (forwarded) {
5960
+ delivered = true;
5961
+ await releaseAllTabCopiesIntoSurvivingTab(pending);
5962
+ }
5825
5963
  return forwarded;
5826
5964
  },
5827
5965
  });
@@ -5832,7 +5970,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5832
5970
  assertExecutionCurrent();
5833
5971
  if (!selectionCurrent())
5834
5972
  return true;
5835
- await deps.answerCallbackQuery(query.id, "Restore unconfirmed; not resending");
5973
+ await deps.answerCallbackQuery(query.id, delivered ? "Restored" : "Restore unconfirmed; not resending");
5836
5974
  return true;
5837
5975
  }
5838
5976
  if (record.instanceId &&
@@ -6854,6 +6992,33 @@ export function createTelegramInboundRouteRuntime(deps) {
6854
6992
  }
6855
6993
  return undefined;
6856
6994
  };
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
+ */
6999
+ const findImplicitCreationForAllInput = (message, updateId, cap, ctx) => {
7000
+ const text = (message.text ?? message.caption ?? "").trim();
7001
+ let match;
7002
+ for (const [key, implicit] of implicitThreadCreations) {
7003
+ if (((implicit.name && text.startsWith(implicit.name)) ||
7004
+ implicit.updateId === updateId - 1) &&
7005
+ implicit.updateId < updateId &&
7006
+ implicit.createdAtSec > 0 &&
7007
+ message.date !== undefined &&
7008
+ message.date >= implicit.createdAtSec &&
7009
+ message.date - implicit.createdAtSec <=
7010
+ TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC &&
7011
+ implicit.ctx === ctx &&
7012
+ implicit.operatorUserId === cap.operatorUserId &&
7013
+ implicit.journalBindingKey === cap.journalBindingKey &&
7014
+ isDeepStrictEqual(implicit.executor, cap.authority.executor) &&
7015
+ implicit.generation === deps.getSessionGeneration?.() &&
7016
+ implicit.scope === deps.getAdmissionScope?.() &&
7017
+ (!match || implicit.updateId > match.implicit.updateId))
7018
+ match = { key, implicit };
7019
+ }
7020
+ return match;
7021
+ };
6857
7022
  /**
6858
7023
  * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
6859
7024
  * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
@@ -6880,6 +7045,9 @@ export function createTelegramInboundRouteRuntime(deps) {
6880
7045
  .find((entry) => entry.source.journalBindingKey === journalBindingKey &&
6881
7046
  entry.source.updateId === updateId);
6882
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;
6883
7051
  await deps.runWorkspaceOperation({
6884
7052
  operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
6885
7053
  operationKind: "workspace.temporary-thread",
@@ -6893,6 +7061,35 @@ export function createTelegramInboundRouteRuntime(deps) {
6893
7061
  entry = found;
6894
7062
  return;
6895
7063
  }
7064
+ // Mobile clients sending from All first create a native tab named after the input, then deliver
7065
+ // the input itself to All: adopt that tab rather than leaving it behind next to a second one.
7066
+ const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
7067
+ if (native) {
7068
+ const adopted = store.registerImplicitTemporaryThread({ journalBindingKey, updateIds: [updateId] }, native.implicit.target, randomBytes(16).toString("hex"), {
7069
+ ...authority,
7070
+ isCurrent: () => current() &&
7071
+ implicitThreadCreations.get(native.key) === native.implicit,
7072
+ });
7073
+ if (adopted && current()) {
7074
+ implicitThreadCreations.delete(native.key);
7075
+ entry = adopted;
7076
+ openedNow = true;
7077
+ // Adopted native tabs share the routing name; a failed rename never blocks routing.
7078
+ try {
7079
+ await deps.callApi("editForumTopic", {
7080
+ chat_id: native.implicit.target.chatId,
7081
+ message_thread_id: native.implicit.target.threadId,
7082
+ name: TELEGRAM_TEMPORARY_THREAD_NAME,
7083
+ });
7084
+ }
7085
+ catch (error) {
7086
+ deps.recordRuntimeEvent?.("routing", error, {
7087
+ phase: "temporary-thread-name",
7088
+ });
7089
+ }
7090
+ return;
7091
+ }
7092
+ }
6896
7093
  const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
6897
7094
  entry = reservation?.entry;
6898
7095
  if (!reservation?.reserved || !current())
@@ -6917,8 +7114,9 @@ export function createTelegramInboundRouteRuntime(deps) {
6917
7114
  !Number.isSafeInteger(threadId) ||
6918
7115
  threadId <= 0)
6919
7116
  return;
6920
- entry =
6921
- 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;
6922
7120
  });
6923
7121
  if (!current() || !entry)
6924
7122
  throw new Error("Temporary Thread authority changed; the input remains retryable.");
@@ -6949,6 +7147,9 @@ export function createTelegramInboundRouteRuntime(deps) {
6949
7147
  };
6950
7148
  }
6951
7149
  pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
7150
+ const tabCopyId = openedNow
7151
+ ? await moveAllTabSourceIntoTab(pending, target, current)
7152
+ : undefined;
6952
7153
  try {
6953
7154
  if (!deps.sendInteractiveMessage)
6954
7155
  throw new Error("Temporary Thread chooser publication is unavailable.");
@@ -6956,7 +7157,9 @@ export function createTelegramInboundRouteRuntime(deps) {
6956
7157
  canRestore: true,
6957
7158
  canCancel: !!pending.abandonment,
6958
7159
  getDisplayTitle: deps.getDisplayTitle,
6959
- }), { target });
7160
+ }), tabCopyId === undefined
7161
+ ? { target }
7162
+ : { target, replyToMessageId: tabCopyId });
6960
7163
  rememberRerouteChooser(rerouteId, chooserId);
6961
7164
  }
6962
7165
  catch (error) {
@@ -7253,6 +7456,14 @@ export function createTelegramInboundRouteRuntime(deps) {
7253
7456
  executor: structuredClone(cap.authority.executor),
7254
7457
  generation: deps.getSessionGeneration?.(),
7255
7458
  scope: deps.getAdmissionScope?.(),
7459
+ target: {
7460
+ chatId: lifecycle.target.chatId,
7461
+ threadId: lifecycle.target.threadId,
7462
+ },
7463
+ name: "name" in created && typeof created.name === "string"
7464
+ ? created.name
7465
+ : "",
7466
+ createdAtSec: message.date ?? 0,
7256
7467
  });
7257
7468
  }
7258
7469
  else
@@ -7563,7 +7774,35 @@ export function createTelegramInboundRouteRuntime(deps) {
7563
7774
  threadId: target.threadId,
7564
7775
  };
7565
7776
  /** Rebinds the leader record to this unbound tab, publishes its identity and handles the message there. */
7566
- 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
+ }
7567
7806
  deps.threadStore.upsert({
7568
7807
  ...base,
7569
7808
  profileKey,
@@ -7594,6 +7833,33 @@ export function createTelegramInboundRouteRuntime(deps) {
7594
7833
  slot,
7595
7834
  profileKey,
7596
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
+ }
7597
7863
  await textDispatch.handleMessage(message, ctx);
7598
7864
  };
7599
7865
  const records = deps.threadStore.list();
@@ -7694,7 +7960,10 @@ export function createTelegramInboundRouteRuntime(deps) {
7694
7960
  throw error;
7695
7961
  }
7696
7962
  if (currentLeaderIsStale) {
7697
- 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);
7698
7967
  if (!slot) {
7699
7968
  deps.threadStore.markStaleByTarget(currentLeaderRecord.target, "deleted", "Current leader thread is stale during unbound prompt routing.");
7700
7969
  await deps.threadStore.persist();
@@ -7707,7 +7976,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7707
7976
  await reclaimUnboundTargetForLeader(currentLeaderRecord, leaderProfileKey, slot, threadName, "Bus leader reclaimed stale-current unbound thread", {
7708
7977
  phase: "leader-topic-unbound-stale-reclaim",
7709
7978
  staleThreadId: currentLeaderRecord.target.threadId,
7710
- });
7979
+ }, leaderBinding?.slot === slot ? leaderBinding : undefined);
7711
7980
  return;
7712
7981
  }
7713
7982
  }
@@ -7717,7 +7986,12 @@ export function createTelegramInboundRouteRuntime(deps) {
7717
7986
  !hasAnyRoutableThread) {
7718
7987
  const priorLeaderRecord = deps.threadStore.getByProfileKey(leaderProfileKey);
7719
7988
  const priorLeaderIdentity = deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
7720
- 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);
7721
7995
  if (!slot) {
7722
7996
  await deps.sendTextReply(target.chatId, message.message_id, TELEGRAM_SLOT_CAPACITY_MESSAGE, { target });
7723
7997
  return;
@@ -7730,7 +8004,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7730
8004
  identityThreadName ??
7731
8005
  ThreadNaming.chooseTelegramThreadName({ slot }) ??
7732
8006
  "Pi";
7733
- 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);
7734
8008
  return;
7735
8009
  }
7736
8010
  await sendUnboundRerouteChooser(message, ctx);
@@ -7757,6 +8031,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7757
8031
  onUpdateCompleted,
7758
8032
  prepareHeldCommand: commandHandler.prepareHeldCommand,
7759
8033
  canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
8034
+ onSessionSettled,
7760
8035
  observeLiveRebindLeaderWork,
7761
8036
  prepareLiveRebindLeaderCleanup,
7762
8037
  prepareLiveRebindFollowerCleanup,