@llblab/pi-telegram 0.54.4 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +5 -3
  5. package/dist/lib/activity.d.ts +5 -1
  6. package/dist/lib/activity.js +31 -2
  7. package/dist/lib/bindings.d.ts +3 -1
  8. package/dist/lib/bindings.js +8 -2
  9. package/dist/lib/bus-api.d.ts +2 -2
  10. package/dist/lib/bus-api.js +7 -2
  11. package/dist/lib/bus-follower.d.ts +49 -3
  12. package/dist/lib/bus-follower.js +313 -146
  13. package/dist/lib/bus-leader.d.ts +3 -0
  14. package/dist/lib/bus-leader.js +99 -18
  15. package/dist/lib/bus.d.ts +5 -3
  16. package/dist/lib/bus.js +13 -10
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +48 -6
  20. package/dist/lib/lifecycle.d.ts +68 -0
  21. package/dist/lib/lifecycle.js +178 -58
  22. package/dist/lib/machine.d.ts +23 -0
  23. package/dist/lib/machine.js +37 -0
  24. package/dist/lib/media.d.ts +114 -28
  25. package/dist/lib/media.js +292 -119
  26. package/dist/lib/model.d.ts +54 -15
  27. package/dist/lib/model.js +98 -53
  28. package/dist/lib/polling.js +2 -0
  29. package/dist/lib/preview.d.ts +128 -113
  30. package/dist/lib/preview.js +448 -366
  31. package/dist/lib/queue.d.ts +2 -2
  32. package/dist/lib/queue.js +3 -3
  33. package/dist/lib/routing.d.ts +6 -1
  34. package/dist/lib/routing.js +334 -210
  35. package/dist/lib/runtime.d.ts +77 -11
  36. package/dist/lib/runtime.js +174 -92
  37. package/dist/lib/sections.js +0 -1
  38. package/dist/lib/telegram-api.d.ts +11 -9
  39. package/dist/lib/telegram-api.js +53 -4
  40. package/dist/lib/text-groups.d.ts +0 -26
  41. package/dist/lib/text-groups.js +51 -98
  42. package/dist/lib/thread-display.js +14 -6
  43. package/dist/lib/threads.d.ts +18 -5
  44. package/dist/lib/threads.js +107 -30
  45. package/dist/lib/turns.d.ts +2 -0
  46. package/dist/lib/turns.js +6 -1
  47. package/dist/lib/updates.d.ts +11 -4
  48. package/dist/lib/updates.js +54 -19
  49. package/dist/package.json +1 -1
  50. package/docs/activity.md +1 -1
  51. package/docs/architecture.md +12 -11
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +18 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +2 -2
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +9 -1
  58. package/lib/bus-api.ts +11 -3
  59. package/lib/bus-follower.ts +417 -203
  60. package/lib/bus-leader.ts +88 -14
  61. package/lib/bus.ts +17 -15
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +51 -9
  64. package/lib/lifecycle.ts +229 -55
  65. package/lib/machine.ts +60 -0
  66. package/lib/media.ts +438 -172
  67. package/lib/model.ts +169 -74
  68. package/lib/polling.ts +2 -0
  69. package/lib/preview.ts +715 -566
  70. package/lib/queue.ts +5 -5
  71. package/lib/routing.ts +325 -238
  72. package/lib/runtime.ts +260 -104
  73. package/lib/sections.ts +0 -2
  74. package/lib/telegram-api.ts +61 -8
  75. package/lib/text-groups.ts +75 -136
  76. package/lib/thread-display.ts +15 -4
  77. package/lib/threads.ts +120 -36
  78. package/lib/turns.ts +8 -1
  79. package/lib/updates.ts +58 -21
  80. package/package.json +1 -1
package/lib/threads.ts CHANGED
@@ -349,6 +349,10 @@ export type TelegramWorkspaceLiveRebindIntent = Pick<
349
349
  | "updatedAtMs"
350
350
  > & {
351
351
  kind: "live-rebind";
352
+ /** Versioned native input gate: terminal cleanup does not itself authorize or settle input. */
353
+ inputRelease?: "held" | "issued" | "accepted";
354
+ /** A session-replacing producer must not drop earlier old-target protection or carry its delete grant forward. */
355
+ oldTargetProtected?: true;
352
356
  recipient: TelegramWorkspaceRestoreRecipient;
353
357
  } & (
354
358
  | { phase: "rebound"; cleanup?: never }
@@ -418,6 +422,7 @@ export interface TelegramWorkspaceRestore {
418
422
  request: TelegramWorkspaceRestoreRequest,
419
423
  recipient: TelegramWorkspaceRestoreRecipient,
420
424
  authority: TelegramWorkspaceRestoreAuthority,
425
+ options?: { holdInput: true },
421
426
  ): Promise<TelegramWorkspaceLiveRebindIntent | undefined>;
422
427
  /** Caller proves local apply for release or current live clearance for cleanup; unknown issuance never repeats. */
423
428
  advanceLiveRebind(
@@ -425,6 +430,9 @@ export interface TelegramWorkspaceRestore {
425
430
  step:
426
431
  | "release"
427
432
  | "issue-cleanup"
433
+ | "issue-input"
434
+ | "accept-input"
435
+ | "protect-old-target"
428
436
  | Extract<
429
437
  TelegramWorkspaceLiveRebindIntent,
430
438
  { phase: "finished" }
@@ -501,11 +509,15 @@ export interface TelegramWorkspaceRestore {
501
509
  expected: TelegramTemporaryThreadEntry,
502
510
  authority: TelegramWorkspaceRestoreAuthority,
503
511
  ): boolean;
504
- /** Publishes `creating` before the caller's single creation request; an existing source entry is returned, never recreated. */
512
+ /**
513
+ * Publishes `creating` before the caller's single creation request; an existing source entry is returned, never
514
+ * recreated. `group` records an album as the tab's one creating input; it must start at the source update.
515
+ */
505
516
  reserveTemporaryThread(
506
517
  source: TelegramTemporaryThreadEntry["source"],
507
518
  token: string,
508
519
  authority: TelegramWorkspaceRestoreAuthority,
520
+ group?: TelegramTemporaryThreadInput,
509
521
  ): { reserved: boolean; entry: TelegramTemporaryThreadEntry } | undefined;
510
522
  /** Caller proves a fresh owner-authenticated implicit Telegram creation and exact live source under profile admission.
511
523
  * Registers that observed unbound target without issuing or fabricating a Bot API creation. */
@@ -745,6 +757,8 @@ export interface TelegramTopicTargetStore {
745
757
  ) => undefined,
746
758
  ) => void;
747
759
  /** Current live-origin ownership veto; effect callers still require Workspace admission. False is not work/recipient/deletion authority. */
760
+ hasPreDispatchRebindEvidence: () => boolean;
761
+ listProtectedLiveRebindTargets: () => TelegramTarget[];
748
762
  isWorkspaceLiveRebindCleanupTargetProtected: (
749
763
  expected: TelegramWorkspaceLiveRebindIntent,
750
764
  ) => boolean;
@@ -948,6 +962,7 @@ export function createTelegramCleanupTargetProtection(
948
962
  | "listPendingProvisions"
949
963
  | "listPendingCleanups"
950
964
  | "listTemporaryThreadTargets"
965
+ | "listProtectedLiveRebindTargets"
951
966
  >
952
967
  >,
953
968
  departingRecord?: TelegramTopicTargetRecord,
@@ -966,6 +981,9 @@ export function createTelegramCleanupTargetProtection(
966
981
  JSON.parse(JSON.stringify(right)),
967
982
  );
968
983
  return (target, action) => {
984
+ try {
985
+ if (store.listProtectedLiveRebindTargets?.().some(candidate => targetMatches(candidate, target))) return true;
986
+ } catch { return true; }
969
987
  // A source-bound temporary tab is removed only by its own Forward/Cancel, never by generic reconciliation.
970
988
  if (store.listTemporaryThreadTargets) {
971
989
  try {
@@ -2366,6 +2384,8 @@ function isWorkspaceLiveRebindIntent(
2366
2384
  "recipient",
2367
2385
  "phase",
2368
2386
  "cleanup",
2387
+ "inputRelease",
2388
+ "oldTargetProtected",
2369
2389
  ]) ||
2370
2390
  value.kind !== "live-rebind" ||
2371
2391
  !isTelegramWorkspaceRestoreRequest(value.request) ||
@@ -2383,6 +2403,11 @@ function isWorkspaceLiveRebindIntent(
2383
2403
  (value.request.owner.owner?.kind === "leader" ? "leader" : "follower")
2384
2404
  )
2385
2405
  return false;
2406
+ if (value.inputRelease !== undefined &&
2407
+ (!["held", "issued", "accepted"].includes(value.inputRelease as string) ||
2408
+ (value.inputRelease !== "held" && !["released", "finished"].includes(value.phase as string)))) return false;
2409
+ if (value.oldTargetProtected !== undefined &&
2410
+ (value.oldTargetProtected !== true || value.inputRelease === undefined || value.phase !== "finished" || value.cleanup !== "not-issued")) return false;
2386
2411
  if (value.phase === "rebound") return value.cleanup === undefined;
2387
2412
  if (value.phase === "released")
2388
2413
  return value.cleanup === undefined || value.cleanup === "issued";
@@ -2675,13 +2700,37 @@ function conflictsWithWorkspaceRestoreProvision(
2675
2700
  );
2676
2701
  }
2677
2702
 
2703
+ /** Canonical issuance is distinct from cleanup completion, source disposition and peer dispatch readiness. */
2704
+ export function isTelegramLiveRebindInputAuthorized(value: TelegramWorkspaceLiveRebindIntent): boolean {
2705
+ return value.inputRelease === undefined
2706
+ ? value.phase === "released" && value.cleanup === undefined
2707
+ : ["issued", "accepted"].includes(value.inputRelease) &&
2708
+ ["released", "finished"].includes(value.phase) && value.cleanup !== "issued";
2709
+ }
2710
+ function liveRebindInputPending(value: TelegramWorkspaceLiveRebindIntent): boolean {
2711
+ return value.inputRelease !== undefined && value.inputRelease !== "accepted";
2712
+ }
2713
+ function liveRebindActive(value: TelegramWorkspaceLiveRebindIntent): boolean {
2714
+ return value.phase !== "finished" || liveRebindInputPending(value);
2715
+ }
2716
+ function liveRebindOldTargetUnknown(value: TelegramWorkspaceLiveRebindIntent): boolean {
2717
+ return value.phase === "finished" && value.cleanup === "unknown" && value.inputRelease !== undefined;
2718
+ }
2719
+ function retainLiveRebind(value: TelegramWorkspaceLiveRebindIntent): boolean {
2720
+ return liveRebindActive(value) || liveRebindOldTargetUnknown(value) || value.oldTargetProtected === true;
2721
+ }
2722
+ function protectedLiveRebindOldTargets(snapshot: TelegramWorkspaceRestoreSnapshot | undefined): TelegramTarget[] {
2723
+ return (snapshot?.liveRebindings ?? []).filter(value =>
2724
+ value.oldTargetProtected === true || liveRebindOldTargetUnknown(value) || value.inputRelease !== undefined && value.cleanup === "issued",
2725
+ ).map(value => ({ ...value.request.binding.target }));
2726
+ }
2678
2727
  function pendingWorkspaceRelocationRequests(
2679
2728
  snapshot: TelegramWorkspaceRestoreSnapshot | undefined,
2680
2729
  ): TelegramWorkspaceRestoreRequest[] {
2681
2730
  return [
2682
2731
  ...(snapshot?.operations ?? []).map((value) => value.request),
2683
2732
  ...(snapshot?.liveRebindings ?? [])
2684
- .filter((value) => value.phase !== "finished")
2733
+ .filter(liveRebindActive)
2685
2734
  .map((value) => value.request),
2686
2735
  ];
2687
2736
  }
@@ -2749,7 +2798,7 @@ function assertWorkspaceRestoreBindingProtection(
2749
2798
  );
2750
2799
  const live = file.workspaceRestore?.liveRebindings?.some(
2751
2800
  (value) =>
2752
- value.phase !== "finished" &&
2801
+ liveRebindActive(value) &&
2753
2802
  value.request.operationId === request.operationId,
2754
2803
  );
2755
2804
  if (
@@ -2768,6 +2817,13 @@ function assertWorkspaceRestoreBindingProtection(
2768
2817
  throw new Error("Protected Workspace Restore owner target changed.");
2769
2818
  }
2770
2819
  }
2820
+ for (const old of protectedLiveRebindOldTargets(file.workspaceRestore)) {
2821
+ if (file.workspaceBindings?.some(value => targetMatches(value.target, old)) ||
2822
+ file.threads?.some(value => targetMatches(value.target, old) && ["active", "starting", "pending", "probe-required"].includes(value.status)) ||
2823
+ file.pendingProvisions?.some(value => !value.target || targetMatches(value.target, old)) ||
2824
+ file.reservations?.some(value => targetMatches(value.target, old)))
2825
+ throw new Error("Unknown live cleanup target remains protected.");
2826
+ }
2771
2827
  const operations = [
2772
2828
  ...(file.workspaceRestore?.operations ?? []),
2773
2829
  ...(file.workspaceRestore?.liveRebindings ?? []),
@@ -2875,14 +2931,14 @@ function parseWorkspaceRestore(
2875
2931
  .some(
2876
2932
  (other) =>
2877
2933
  operation.request.operationId === other.request.operationId ||
2878
- (operation.phase !== "finished" &&
2879
- other.phase !== "finished" &&
2934
+ (liveRebindActive(operation) &&
2935
+ liveRebindActive(other) &&
2880
2936
  workspaceRestoresConflict(operation.request, other.request)),
2881
2937
  ) ||
2882
2938
  snapshot.operations.some(
2883
2939
  (other) =>
2884
2940
  operation.request.operationId === other.request.operationId ||
2885
- (operation.phase !== "finished" &&
2941
+ (liveRebindActive(operation) &&
2886
2942
  workspaceRestoresConflict(operation.request, other.request)),
2887
2943
  ),
2888
2944
  )
@@ -2915,7 +2971,7 @@ function parseWorkspaceRestore(
2915
2971
  !!entry.target &&
2916
2972
  [
2917
2973
  ...snapshot.operations,
2918
- ...liveOperations.filter((value) => value.phase !== "finished"),
2974
+ ...liveOperations.filter(liveRebindActive),
2919
2975
  ].some(
2920
2976
  ({ request }) =>
2921
2977
  targetMatches(request.binding.target, entry.target!) ||
@@ -4424,9 +4480,7 @@ export function createTelegramTopicTargetStore(
4424
4480
  ) ||
4425
4481
  !isDeepStrictEqual(
4426
4482
  transition.restore.liveRebindings?.slice(0, -1),
4427
- (workspaceRestore?.liveRebindings ?? []).filter(
4428
- (value) => value.phase !== "finished",
4429
- ),
4483
+ (workspaceRestore?.liveRebindings ?? []).filter(retainLiveRebind),
4430
4484
  ))
4431
4485
  )
4432
4486
  return false;
@@ -5006,6 +5060,14 @@ export function createTelegramTopicTargetStore(
5006
5060
  )
5007
5061
  throw new Error("Workspace live-rebind observation changed.");
5008
5062
  },
5063
+ hasPreDispatchRebindEvidence() {
5064
+ const file = parseTopicTargetFile(readStoreSnapshot(getPath(), false) ?? { version: 1, records: [] });
5065
+ return file.workspaceRestore?.liveRebindings?.some(value => value.inputRelease !== undefined) === true;
5066
+ },
5067
+ listProtectedLiveRebindTargets() {
5068
+ const file = parseTopicTargetFile(readStoreSnapshot(getPath(), false) ?? { version: 1, records: [] });
5069
+ return protectedLiveRebindOldTargets(file.workspaceRestore);
5070
+ },
5009
5071
  isWorkspaceLiveRebindCleanupTargetProtected(input) {
5010
5072
  try {
5011
5073
  const expected = structuredClone(input),
@@ -5130,8 +5192,8 @@ export function createTelegramTopicTargetStore(
5130
5192
  file.workspaceRestore!.liveRebindings?.some(
5131
5193
  (other) =>
5132
5194
  other.request.operationId !== request.operationId &&
5133
- other.phase !== "finished" &&
5134
- touchesOld(other),
5195
+ (liveRebindActive(other) && touchesOld(other) ||
5196
+ (liveRebindOldTargetUnknown(other) || other.oldTargetProtected) && targetMatches(other.request.binding.target, old)),
5135
5197
  )
5136
5198
  )
5137
5199
  return true;
@@ -5357,6 +5419,8 @@ export function createTelegramTopicTargetStore(
5357
5419
  )
5358
5420
  return false;
5359
5421
  const state = read().state;
5422
+ if (protectedLiveRebindOldTargets(state).some(old => targetMatches(old, intent.request.binding.target) || targetMatches(old, intent.request.target)))
5423
+ return false;
5360
5424
  if (
5361
5425
  pendingWorkspaceRelocationRequests(state).some((request) =>
5362
5426
  workspaceRestoresConflict(request, intent.request),
@@ -5369,9 +5433,7 @@ export function createTelegramTopicTargetStore(
5369
5433
  return false;
5370
5434
  if (state.revision === Number.MAX_SAFE_INTEGER)
5371
5435
  throw new Error("Workspace Restore revision exhausted.");
5372
- const pendingLive = (state.liveRebindings ?? []).filter(
5373
- (value) => value.phase !== "finished",
5374
- );
5436
+ const pendingLive = (state.liveRebindings ?? []).filter(retainLiveRebind);
5375
5437
  if ((live ? pendingLive.length : state.operations.length) >= 26)
5376
5438
  throw new Error("Workspace Restore operation capacity reached.");
5377
5439
  const restore: TelegramWorkspaceRestoreSnapshot = {
@@ -5694,7 +5756,7 @@ export function createTelegramTopicTargetStore(
5694
5756
  list: () => structuredClone(storage.read().operations),
5695
5757
  listLiveRebindings: () =>
5696
5758
  structuredClone(storage.read().liveRebindings ?? []),
5697
- async commitLiveRebind(requestInput, recipientInput, authority) {
5759
+ async commitLiveRebind(requestInput, recipientInput, authority, options) {
5698
5760
  const request = structuredClone(requestInput),
5699
5761
  recipient = structuredClone(recipientInput);
5700
5762
  const executor = structuredClone(authority.executor),
@@ -5715,6 +5777,7 @@ export function createTelegramTopicTargetStore(
5715
5777
  createdAtMs: at,
5716
5778
  updatedAtMs: at,
5717
5779
  phase: "rebound",
5780
+ ...(options?.holdInput ? { inputRelease: "held" as const } : {}),
5718
5781
  };
5719
5782
  if (!current() || !isWorkspaceLiveRebindIntent(intent))
5720
5783
  return undefined;
@@ -5729,7 +5792,8 @@ export function createTelegramTopicTargetStore(
5729
5792
  isDeepStrictEqual(value.request, request) &&
5730
5793
  isDeepStrictEqual(value.recipient, recipient) &&
5731
5794
  isDeepStrictEqual(value.executor, executor) &&
5732
- value.operatorUserId === operatorUserId
5795
+ value.operatorUserId === operatorUserId &&
5796
+ (value.inputRelease !== undefined) === (options?.holdInput === true)
5733
5797
  ? structuredClone(value)
5734
5798
  : undefined;
5735
5799
  };
@@ -5760,17 +5824,23 @@ export function createTelegramTopicTargetStore(
5760
5824
  !entry ||
5761
5825
  !isDeepStrictEqual(entry, expected) ||
5762
5826
  entry.operatorUserId !== operator ||
5763
- entry.phase === "finished"
5827
+ (entry.phase === "finished" && step !== "issue-input" && step !== "accept-input")
5764
5828
  )
5765
5829
  return undefined;
5766
- const issuing = step === "release" || step === "issue-cleanup";
5830
+ const issuing = step === "release" || step === "issue-cleanup" || step === "issue-input" || step === "accept-input" || step === "protect-old-target";
5767
5831
  if (
5768
5832
  (issuing || step === "confirmed") &&
5769
5833
  !isDeepStrictEqual(entry.executor, executor)
5770
5834
  )
5771
5835
  return undefined;
5772
5836
  let next: TelegramWorkspaceLiveRebindIntent;
5773
- if (step === "release") {
5837
+ if (step === "issue-input" || step === "accept-input") {
5838
+ if (!["released", "finished"].includes(entry.phase) || entry.cleanup === "issued" || entry.inputRelease !== (step === "issue-input" ? "held" : "issued")) return undefined;
5839
+ next = { ...entry, inputRelease: step === "issue-input" ? "issued" : "accepted" };
5840
+ } else if (step === "protect-old-target") {
5841
+ if (entry.phase !== "released" || entry.cleanup !== undefined || entry.inputRelease !== "held") return undefined;
5842
+ next = { ...entry, phase: "finished", cleanup: "not-issued", oldTargetProtected: true };
5843
+ } else if (step === "release") {
5774
5844
  if (entry.phase !== "rebound") return undefined;
5775
5845
  next = { ...entry, phase: "released" };
5776
5846
  } else if (step === "issue-cleanup") {
@@ -6086,8 +6156,20 @@ export function createTelegramTopicTargetStore(
6086
6156
  },
6087
6157
  listTemporaryThreads: () =>
6088
6158
  structuredClone(storage.read().temporaryThreads ?? []),
6089
- reserveTemporaryThread(sourceInput, token, authority) {
6159
+ reserveTemporaryThread(sourceInput, token, authority, groupInput) {
6090
6160
  const source = structuredClone(sourceInput);
6161
+ const group: TelegramTemporaryThreadInput = groupInput
6162
+ ? structuredClone(groupInput)
6163
+ : {
6164
+ journalBindingKey: source.journalBindingKey,
6165
+ updateIds: [source.updateId],
6166
+ };
6167
+ if (
6168
+ !isTemporaryThreadInput(group) ||
6169
+ group.journalBindingKey !== source.journalBindingKey ||
6170
+ Math.min(...group.updateIds) !== source.updateId
6171
+ )
6172
+ return undefined;
6091
6173
  let reserved = false;
6092
6174
  const entry = mutate<TelegramTemporaryThreadEntry>(
6093
6175
  authority,
@@ -6108,12 +6190,7 @@ export function createTelegramTopicTargetStore(
6108
6190
  throw new Error("Invalid Workspace Restore clock.");
6109
6191
  const next: TelegramTemporaryThreadEntry = {
6110
6192
  source,
6111
- inputs: [
6112
- {
6113
- journalBindingKey: source.journalBindingKey,
6114
- updateIds: [source.updateId],
6115
- },
6116
- ],
6193
+ inputs: [group],
6117
6194
  operatorUserId: operator,
6118
6195
  executor,
6119
6196
  token,
@@ -6131,7 +6208,9 @@ export function createTelegramTopicTargetStore(
6131
6208
  getTelegramTemporaryThreadInputs(value).some(
6132
6209
  (input) =>
6133
6210
  input.journalBindingKey === source.journalBindingKey &&
6134
- input.updateIds.includes(source.updateId),
6211
+ input.updateIds.some((id) =>
6212
+ group.updateIds.includes(id),
6213
+ ),
6135
6214
  ),
6136
6215
  )
6137
6216
  )
@@ -6659,7 +6738,8 @@ export function createTelegramTopicTargetStore(
6659
6738
  pendingWorkspaceRelocationRequests(workspaceRestore).some((request) =>
6660
6739
  conflictsWithWorkspaceRestoreProvision(request, reservation),
6661
6740
  ) ||
6662
- conflictsWithTemporaryThread(workspaceRestore, reservation.target)
6741
+ conflictsWithTemporaryThread(workspaceRestore, reservation.target) ||
6742
+ protectedLiveRebindOldTargets(workspaceRestore).some(old => targetMatches(old, reservation.target))
6663
6743
  ) {
6664
6744
  throw new Error("Protected Workspace Restore provisioning conflict.");
6665
6745
  }
@@ -6677,7 +6757,8 @@ export function createTelegramTopicTargetStore(
6677
6757
  pendingWorkspaceRelocationRequests(workspaceRestore).some((request) =>
6678
6758
  conflictsWithWorkspaceRestoreProvision(request, provision),
6679
6759
  ) ||
6680
- conflictsWithTemporaryThread(workspaceRestore, provision.target)
6760
+ conflictsWithTemporaryThread(workspaceRestore, provision.target) ||
6761
+ protectedLiveRebindOldTargets(workspaceRestore).some(old => !provision.target || targetMatches(old, provision.target))
6681
6762
  ) {
6682
6763
  throw new Error("Protected Workspace Restore provisioning conflict.");
6683
6764
  }
@@ -7692,7 +7773,7 @@ export function createTelegramTopicTargetStore(
7692
7773
  if (workspaceRetirementCommitInFlight) return undefined;
7693
7774
  const next = normalizeWorkspaceBindingRecord(binding);
7694
7775
  if (next && hasWorkspaceRetirementConflict(next)) return undefined;
7695
- if (!next) return undefined;
7776
+ if (!next || protectedLiveRebindOldTargets(workspaceRestore).some(old => targetMatches(old, next.target))) return undefined;
7696
7777
  const nextMapKey = getWorkspaceBindingMapKey(next);
7697
7778
  let claim = workspaceClaims.get(nextMapKey);
7698
7779
  const replacedTargetBinding = Array.from(workspaceBindings.values()).find(
@@ -7762,18 +7843,21 @@ export function createTelegramTopicTargetStore(
7762
7843
  if (previous?.manualThreadName && !next.manualThreadName) {
7763
7844
  next.manualThreadName = previous.manualThreadName;
7764
7845
  }
7765
- if (previous) {
7846
+ if (previous || replacedTargetBinding) {
7766
7847
  const journalBindingKeys = Array.from(
7767
7848
  new Set([
7768
- ...(previous.journalBindingKeys ?? []),
7849
+ ...(previous?.journalBindingKeys ?? []),
7850
+ ...(replacedTargetBinding?.journalBindingKeys ?? []),
7769
7851
  ...(next.journalBindingKeys ?? []),
7770
7852
  ]),
7771
7853
  );
7772
- if (journalBindingKeys.length || previous.journalBindingsComplete)
7854
+ const retainedComplete =
7855
+ (!previous || previous.journalBindingsComplete === true) &&
7856
+ (!replacedTargetBinding || replacedTargetBinding.journalBindingsComplete === true);
7857
+ if (journalBindingKeys.length || retainedComplete)
7773
7858
  next.journalBindingKeys = journalBindingKeys;
7774
7859
  else delete next.journalBindingKeys;
7775
- if (previous.journalBindingsComplete)
7776
- next.journalBindingsComplete = true;
7860
+ if (retainedComplete) next.journalBindingsComplete = true;
7777
7861
  else delete next.journalBindingsComplete;
7778
7862
  }
7779
7863
  if (previous && !targetMatches(previous.target, next.target)) {
package/lib/turns.ts CHANGED
@@ -44,6 +44,13 @@ import {
44
44
 
45
45
  export const TELEGRAM_PREFIX = "[telegram]";
46
46
 
47
+ const REPLY_ANCHOR_SUPPRESSED = Symbol.for("@llblab/pi-telegram:reply-anchor-suppressed");
48
+
49
+ /** A routed source keeps its unique message/file/group identity, but cannot quote an anchor in its former tab. */
50
+ export function withoutTelegramTurnReplyAnchor<TMessage extends object>(message: TMessage): TMessage {
51
+ return { ...message, [REPLY_ANCHOR_SUPPRESSED]: true };
52
+ }
53
+
47
54
  export const TELEGRAM_GUEST_TURN_NOTE =
48
55
  "[guest] delivery: answer quickly with one concise, self-contained reply";
49
56
 
@@ -733,7 +740,7 @@ function buildPreparedTelegramPromptTurn(
733
740
  kind: "prompt",
734
741
  chatId: firstMessage.chat.id,
735
742
  target: getTelegramTurnTarget(firstMessage),
736
- replyToMessageId: firstMessage.message_id,
743
+ replyToMessageId: Reflect.get(firstMessage, REPLY_ANCHOR_SUPPRESSED) === true ? 0 : firstMessage.message_id,
737
744
  sourceMessageIds: collectTelegramMessageIds(options.messages),
738
745
  queueOrder: options.queueOrder,
739
746
  queueLane: "default",
package/lib/updates.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { isDeepStrictEqual } from "node:util";
9
+ import { withoutTelegramTurnReplyAnchor } from "./turns.ts";
9
10
 
10
11
  import type {
11
12
  TelegramBusEnvelope,
@@ -552,7 +553,10 @@ interface TelegramUpdateAdmissionBinding {
552
553
  prepareLiveInput?: (
553
554
  sourceUpdateIds: readonly number[],
554
555
  isCurrent: () => boolean,
556
+ onTransferred?: () => void,
555
557
  ) => TelegramLiveDeferredInputPreparation | undefined;
558
+ /** Internal notification after the worker's exact donor-removal ACK; never issues completion itself. */
559
+ acknowledgeTransferred?: () => void;
556
560
  prepareQueueAdmission?: () =>
557
561
  TelegramDeferredQueueAdmissionPreparation | undefined;
558
562
  prepareSourceCompletion?: () =>
@@ -614,6 +618,7 @@ export function bindTelegramUpdateAdmissionSource<
614
618
  | "inspectSourceSnapshot"
615
619
  | "inspectCompletion"
616
620
  | "isSourceUnsettled"
621
+ | "acknowledgeTransferred"
617
622
  | "prepareLiveInput"
618
623
  | "prepareQueueAdmission"
619
624
  | "prepareSourceCompletion"
@@ -761,20 +766,24 @@ export function prepareTelegramLiveDeferredInput(
761
766
  )
762
767
  )
763
768
  return undefined;
764
- const unsettled = () =>
765
- carriers.every(
766
- (value) =>
767
- getTelegramUpdateAdmissionBinding(value)?.isSourceUnsettled?.() ===
768
- true,
769
- );
769
+ const bindings = carriers.map(getTelegramUpdateAdmissionBinding);
770
+ const acknowledgers = bindings.map(binding => binding?.acknowledgeTransferred);
771
+ const bindingsCurrent = () => carriers.every((value, index) =>
772
+ getTelegramUpdateAdmissionBinding(value) === bindings[index] && bindings[index]?.acknowledgeTransferred === acknowledgers[index]);
773
+ const unsettled = () => bindingsCurrent() && bindings.every(binding => binding?.isSourceUnsettled?.() === true);
770
774
  if (!unsettled()) return undefined;
775
+ let transferred = false;
771
776
  const prepared = getTelegramUpdateAdmissionBinding(first)?.prepareLiveInput?.(
772
777
  ids,
773
778
  () =>
774
- isCurrent() &&
779
+ bindingsCurrent() && isCurrent() &&
775
780
  carriers.every(
776
781
  (value) => getTelegramUpdateExecutionFence(value)?.isCurrent() === true,
777
782
  ),
783
+ () => {
784
+ transferred = true;
785
+ for (let index = 0; index < bindings.length; index++) acknowledgers[index]?.call(bindings[index]);
786
+ },
778
787
  );
779
788
  return (
780
789
  prepared && {
@@ -785,7 +794,7 @@ export function prepareTelegramLiveDeferredInput(
785
794
  unsettled() && prepared.beginRelease(() => unsettled() && canRelease()),
786
795
  settleTransferred: (canSettle) =>
787
796
  unsettled()
788
- ? prepared.settleTransferred(() => unsettled() && canSettle())
797
+ ? prepared.settleTransferred(() => (transferred || unsettled()) && canSettle())
789
798
  : "protected",
790
799
  cancel: () => unsettled() && prepared.cancel(),
791
800
  }
@@ -2350,6 +2359,7 @@ export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
2350
2359
  update: TelegramJournaledUpdate,
2351
2360
  ctx: TContext,
2352
2361
  signal: AbortSignal,
2362
+ target?: TelegramTarget & { threadId: number },
2353
2363
  ) => Promise<TelegramUpdateAdmissionOutcome> | TelegramUpdateAdmissionOutcome;
2354
2364
  /** Native source-only binding, installed by the admission-worker assembly rather than ordinary dispatch. */
2355
2365
  admitPreparedLiveInput?: (
@@ -2466,7 +2476,7 @@ export interface TelegramLiveInputPreparation {
2466
2476
  prepareStatusConsumption?(input: TelegramLiveStatusConsumption): boolean;
2467
2477
  /** Native owner availability before recipient append; not admission or future target activation. */
2468
2478
  canPrepareStatusConsumption?(): boolean;
2469
- release(canRelease: () => boolean): boolean;
2479
+ release(canRelease: () => boolean, target?: TelegramTarget & { threadId: number }): boolean;
2470
2480
  /** Ends a failed pre-append attempt only when no selected input was saved. */
2471
2481
  cancelEmpty(): boolean;
2472
2482
  /** One fenced removal of this never-dispatched recipient copy; unknown issuance cannot be retried. */
@@ -2532,6 +2542,7 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
2532
2542
  sourceUpdateIds: readonly number[];
2533
2543
  signal: AbortSignal;
2534
2544
  isCurrent(): boolean;
2545
+ onTransferred?(): void;
2535
2546
  }) => TelegramLiveDeferredInputPreparation | undefined;
2536
2547
  prepareDeferredQueueAdmission?: (input: {
2537
2548
  updateId: number;
@@ -2652,6 +2663,7 @@ interface TelegramLiveInputPreparationState<TContext> {
2652
2663
  saved?: TelegramUpdateWorkerJournalSnapshot["entries"][number][];
2653
2664
  discardIssued?: true;
2654
2665
  released?: true;
2666
+ target?: TelegramTarget & { threadId: number };
2655
2667
  status?: {
2656
2668
  current(): boolean;
2657
2669
  assertCurrent(): void;
@@ -3707,6 +3719,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3707
3719
  expectedOwner: TelegramUpdateWorkerOwner<TContext>,
3708
3720
  update: TelegramJournaledUpdate,
3709
3721
  preparedExecute?: TelegramUpdateWorkerRuntimeDeps<TContext>["executeUpdate"],
3722
+ target?: TelegramTarget & { threadId: number },
3710
3723
  ): Promise<
3711
3724
  | TelegramUpdateWorkerExecutionSettlement
3712
3725
  | typeof TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED
@@ -3735,6 +3748,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3735
3748
  update,
3736
3749
  expectedOwner.ctx,
3737
3750
  expectedOwner.controller.signal,
3751
+ target,
3738
3752
  ),
3739
3753
  );
3740
3754
  const settlement: Promise<TelegramUpdateWorkerExecutionSettlement> =
@@ -4739,6 +4753,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
4739
4753
  return admitted.outcome;
4740
4754
  }
4741
4755
  : undefined,
4756
+ prepared?.target,
4742
4757
  );
4743
4758
  if (execution === TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED) {
4744
4759
  return "aborted";
@@ -4821,6 +4836,11 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
4821
4836
  snapshotInvalidated = true;
4822
4837
  break;
4823
4838
  }
4839
+ if (prepared?.target) {
4840
+ prepared.ids = new Set([...prepared.ids].filter(id => id !== entry.updateId));
4841
+ if (!prepared.ids.size && livePreparation === prepared) livePreparation = undefined;
4842
+ updateClaimCounts();
4843
+ }
4824
4844
  const postExecutionAuthority = checkAuthority(
4825
4845
  expectedOwner,
4826
4846
  entry.updateId,
@@ -5270,6 +5290,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5270
5290
  prepareDeferredLiveInput(input) {
5271
5291
  const expectedOwner = owner,
5272
5292
  binding = deps.getJournalBindingKey?.(),
5293
+ onTransferred = input.onTransferred,
5273
5294
  ids = [...input.sourceUpdateIds];
5274
5295
  const sources = ids.map((id) => deferredSources.get(id));
5275
5296
  const ownerCurrent = () =>
@@ -5282,7 +5303,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5282
5303
  deps.hasAuthority(expectedOwner.ctx) &&
5283
5304
  deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
5284
5305
  isQueueOwnerIdentityCurrent(expectedOwner) &&
5285
- input.isCurrent();
5306
+ input.onTransferred === onTransferred && input.isCurrent();
5286
5307
  const sourcesCurrent = () =>
5287
5308
  ownerCurrent() &&
5288
5309
  sources.every(
@@ -5381,13 +5402,11 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5381
5402
  undefined,
5382
5403
  () => current() && canSettle(),
5383
5404
  );
5384
- if (
5385
- !current() ||
5386
- !canSettle() ||
5387
- result.removedUpdateIds.length !== ids.length ||
5388
- !ids.every((id) => result.removedUpdateIds.includes(id))
5389
- )
5405
+ if (result.removedUpdateIds.length !== ids.length || !ids.every(id => result.removedUpdateIds.includes(id)))
5390
5406
  return "unknown";
5407
+ // A consumed routing clock is expected only after this exact ACK, never after uncertain removal.
5408
+ onTransferred?.call(input);
5409
+ if (!current() || !canSettle()) return "unknown";
5391
5410
  } catch (error) {
5392
5411
  recordRuntimeEvent(error, {
5393
5412
  phase: "live-deferred-transfer-settlement",
@@ -5511,7 +5530,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5511
5530
  }
5512
5531
  };
5513
5532
  const finish = () => {
5514
- if (!held.released || !held.status) livePreparation = undefined;
5533
+ if (!held.released || !held.status && !held.target) livePreparation = undefined;
5515
5534
  updateClaimCounts();
5516
5535
  notifyStateChange();
5517
5536
  blocked = false;
@@ -5659,7 +5678,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5659
5678
  };
5660
5679
  return held.status.current();
5661
5680
  },
5662
- release(canRelease) {
5681
+ release(canRelease, target) {
5663
5682
  if (
5664
5683
  !current() ||
5665
5684
  held.discardIssued ||
@@ -5676,6 +5695,14 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5676
5695
  !current()
5677
5696
  )
5678
5697
  return false;
5698
+ if (target) {
5699
+ if (!Number.isSafeInteger(target.chatId) || target.chatId === 0 || !Number.isSafeInteger(target.threadId) || target.threadId <= 0 ||
5700
+ entries.some(entry => {
5701
+ const message = (entry.update.message ?? entry.update.edited_message) as { chat?: { id?: number } } | undefined;
5702
+ return message?.chat?.id !== target.chatId;
5703
+ })) return false;
5704
+ if (!held.status) held.target = { ...target };
5705
+ }
5679
5706
  released = true;
5680
5707
  held.released = true;
5681
5708
  finish();
@@ -8846,7 +8873,8 @@ function createTelegramUpdateSourceAdmissionHandle<
8846
8873
  signal,
8847
8874
  })
8848
8875
  : undefined,
8849
- prepareLiveInput: (sourceUpdateIds, isCurrent) =>
8876
+ acknowledgeTransferred: () => { settlementReported = true; },
8877
+ prepareLiveInput: (sourceUpdateIds, isCurrent, onTransferred) =>
8850
8878
  execution.isCurrent() &&
8851
8879
  !immediate &&
8852
8880
  !settlementReported &&
@@ -8856,6 +8884,7 @@ function createTelegramUpdateSourceAdmissionHandle<
8856
8884
  sourceUpdateIds,
8857
8885
  signal,
8858
8886
  isCurrent: () => execution.isCurrent() && isCurrent(),
8887
+ onTransferred,
8859
8888
  })
8860
8889
  : undefined,
8861
8890
  isHistorical: (matchesOriginal) =>
@@ -10599,11 +10628,19 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
10599
10628
  ),
10600
10629
  }
10601
10630
  : {}),
10602
- async executeUpdate(update, ctx, signal) {
10631
+ async executeUpdate(update, ctx, signal, target) {
10603
10632
  const typedUpdate = update as TUpdate;
10633
+ const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
10634
+ // Journal/source evidence remains the original; only the warm released handler projection changes destination.
10635
+ const routed = target ? { ...prepared,
10636
+ ...(prepared.message ? { message: prepared.message.message_thread_id === target.threadId ? { ...prepared.message }
10637
+ : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: target.threadId, reply_to_message: undefined }) } : {}),
10638
+ ...(prepared.edited_message ? { edited_message: prepared.edited_message.message_thread_id === target.threadId ? { ...prepared.edited_message }
10639
+ : withoutTelegramTurnReplyAnchor({ ...prepared.edited_message, message_thread_id: target.threadId, reply_to_message: undefined }) } : {}),
10640
+ } as TUpdate : prepared;
10604
10641
  try {
10605
10642
  return await executeUpdate(
10606
- deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate,
10643
+ routed,
10607
10644
  ctx,
10608
10645
  signal,
10609
10646
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.54.4",
3
+ "version": "0.55.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"