@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
@@ -73,6 +73,13 @@ export function createTelegramCleanupTargetProtection(store, departingRecord) {
73
73
  // optional fields; neither changes the authority represented by a snapshot.
74
74
  const sameSnapshot = (left, right) => isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
75
75
  return (target, action) => {
76
+ try {
77
+ if (store.listProtectedLiveRebindTargets?.().some(candidate => targetMatches(candidate, target)))
78
+ return true;
79
+ }
80
+ catch {
81
+ return true;
82
+ }
76
83
  // A source-bound temporary tab is removed only by its own Forward/Cancel, never by generic reconciliation.
77
84
  if (store.listTemporaryThreadTargets) {
78
85
  try {
@@ -1069,6 +1076,8 @@ function isWorkspaceLiveRebindIntent(value) {
1069
1076
  "recipient",
1070
1077
  "phase",
1071
1078
  "cleanup",
1079
+ "inputRelease",
1080
+ "oldTargetProtected",
1072
1081
  ]) ||
1073
1082
  value.kind !== "live-rebind" ||
1074
1083
  !isTelegramWorkspaceRestoreRequest(value.request) ||
@@ -1085,6 +1094,13 @@ function isWorkspaceLiveRebindIntent(value) {
1085
1094
  value.recipient.kind !==
1086
1095
  (value.request.owner.owner?.kind === "leader" ? "leader" : "follower"))
1087
1096
  return false;
1097
+ if (value.inputRelease !== undefined &&
1098
+ (!["held", "issued", "accepted"].includes(value.inputRelease) ||
1099
+ (value.inputRelease !== "held" && !["released", "finished"].includes(value.phase))))
1100
+ return false;
1101
+ if (value.oldTargetProtected !== undefined &&
1102
+ (value.oldTargetProtected !== true || value.inputRelease === undefined || value.phase !== "finished" || value.cleanup !== "not-issued"))
1103
+ return false;
1088
1104
  if (value.phase === "rebound")
1089
1105
  return value.cleanup === undefined;
1090
1106
  if (value.phase === "released")
@@ -1272,11 +1288,33 @@ function conflictsWithWorkspaceRestoreProvision(request, provision) {
1272
1288
  (targetMatches(provision.target, request.binding.target) ||
1273
1289
  targetMatches(provision.target, request.target))));
1274
1290
  }
1291
+ /** Canonical issuance is distinct from cleanup completion, source disposition and peer dispatch readiness. */
1292
+ export function isTelegramLiveRebindInputAuthorized(value) {
1293
+ return value.inputRelease === undefined
1294
+ ? value.phase === "released" && value.cleanup === undefined
1295
+ : ["issued", "accepted"].includes(value.inputRelease) &&
1296
+ ["released", "finished"].includes(value.phase) && value.cleanup !== "issued";
1297
+ }
1298
+ function liveRebindInputPending(value) {
1299
+ return value.inputRelease !== undefined && value.inputRelease !== "accepted";
1300
+ }
1301
+ function liveRebindActive(value) {
1302
+ return value.phase !== "finished" || liveRebindInputPending(value);
1303
+ }
1304
+ function liveRebindOldTargetUnknown(value) {
1305
+ return value.phase === "finished" && value.cleanup === "unknown" && value.inputRelease !== undefined;
1306
+ }
1307
+ function retainLiveRebind(value) {
1308
+ return liveRebindActive(value) || liveRebindOldTargetUnknown(value) || value.oldTargetProtected === true;
1309
+ }
1310
+ function protectedLiveRebindOldTargets(snapshot) {
1311
+ return (snapshot?.liveRebindings ?? []).filter(value => value.oldTargetProtected === true || liveRebindOldTargetUnknown(value) || value.inputRelease !== undefined && value.cleanup === "issued").map(value => ({ ...value.request.binding.target }));
1312
+ }
1275
1313
  function pendingWorkspaceRelocationRequests(snapshot) {
1276
1314
  return [
1277
1315
  ...(snapshot?.operations ?? []).map((value) => value.request),
1278
1316
  ...(snapshot?.liveRebindings ?? [])
1279
- .filter((value) => value.phase !== "finished")
1317
+ .filter(liveRebindActive)
1280
1318
  .map((value) => value.request),
1281
1319
  ];
1282
1320
  }
@@ -1311,7 +1349,7 @@ function assertWorkspaceRestoreBindingProtection(file) {
1311
1349
  const owners = (file.threads ?? []).filter((value) => value.slot === current.slot ||
1312
1350
  targetMatches(value.target, request.binding.target) ||
1313
1351
  targetMatches(value.target, request.target));
1314
- const live = file.workspaceRestore?.liveRebindings?.some((value) => value.phase !== "finished" &&
1352
+ const live = file.workspaceRestore?.liveRebindings?.some((value) => liveRebindActive(value) &&
1315
1353
  value.request.operationId === request.operationId);
1316
1354
  if (owners.length > 1 ||
1317
1355
  owners.some((value) => value.slot !== current.slot ||
@@ -1324,6 +1362,13 @@ function assertWorkspaceRestoreBindingProtection(file) {
1324
1362
  throw new Error("Protected Workspace Restore owner target changed.");
1325
1363
  }
1326
1364
  }
1365
+ for (const old of protectedLiveRebindOldTargets(file.workspaceRestore)) {
1366
+ if (file.workspaceBindings?.some(value => targetMatches(value.target, old)) ||
1367
+ file.threads?.some(value => targetMatches(value.target, old) && ["active", "starting", "pending", "probe-required"].includes(value.status)) ||
1368
+ file.pendingProvisions?.some(value => !value.target || targetMatches(value.target, old)) ||
1369
+ file.reservations?.some(value => targetMatches(value.target, old)))
1370
+ throw new Error("Unknown live cleanup target remains protected.");
1371
+ }
1327
1372
  const operations = [
1328
1373
  ...(file.workspaceRestore?.operations ?? []),
1329
1374
  ...(file.workspaceRestore?.liveRebindings ?? []),
@@ -1405,11 +1450,11 @@ function parseWorkspaceRestore(value) {
1405
1450
  if (liveOperations.some((operation, index) => liveOperations
1406
1451
  .slice(index + 1)
1407
1452
  .some((other) => operation.request.operationId === other.request.operationId ||
1408
- (operation.phase !== "finished" &&
1409
- other.phase !== "finished" &&
1453
+ (liveRebindActive(operation) &&
1454
+ liveRebindActive(other) &&
1410
1455
  workspaceRestoresConflict(operation.request, other.request))) ||
1411
1456
  snapshot.operations.some((other) => operation.request.operationId === other.request.operationId ||
1412
- (operation.phase !== "finished" &&
1457
+ (liveRebindActive(operation) &&
1413
1458
  workspaceRestoresConflict(operation.request, other.request)))))
1414
1459
  throw new Error("Conflicting Workspace live rebinding evidence.");
1415
1460
  const entries = snapshot.temporaryThreads ?? [];
@@ -1424,7 +1469,7 @@ function parseWorkspaceRestore(value) {
1424
1469
  entries.some((entry) => !!entry.target &&
1425
1470
  [
1426
1471
  ...snapshot.operations,
1427
- ...liveOperations.filter((value) => value.phase !== "finished"),
1472
+ ...liveOperations.filter(liveRebindActive),
1428
1473
  ].some(({ request }) => targetMatches(request.binding.target, entry.target) ||
1429
1474
  (targetMatches(request.target, entry.target) &&
1430
1475
  !isTemporaryThreadRestore(entry, [{ request }])))))
@@ -2461,7 +2506,7 @@ export function createTelegramTopicTargetStore(options) {
2461
2506
  (transition.kind === "relocate"
2462
2507
  ? !isDeepStrictEqual(transition.restore.operations.slice(0, -1), workspaceRestore?.operations ?? [])
2463
2508
  : !isDeepStrictEqual(transition.restore.operations, workspaceRestore?.operations ?? []) ||
2464
- !isDeepStrictEqual(transition.restore.liveRebindings?.slice(0, -1), (workspaceRestore?.liveRebindings ?? []).filter((value) => value.phase !== "finished"))))
2509
+ !isDeepStrictEqual(transition.restore.liveRebindings?.slice(0, -1), (workspaceRestore?.liveRebindings ?? []).filter(retainLiveRebind))))
2465
2510
  return false;
2466
2511
  file.workspaceRestore = structuredClone(transition.restore);
2467
2512
  parseWorkspaceRestore(file.workspaceRestore);
@@ -2891,6 +2936,14 @@ export function createTelegramTopicTargetStore(options) {
2891
2936
  !isDeepStrictEqual(parseTopicTargetFile(readStoreSnapshot(path, false)), file))
2892
2937
  throw new Error("Workspace live-rebind observation changed.");
2893
2938
  },
2939
+ hasPreDispatchRebindEvidence() {
2940
+ const file = parseTopicTargetFile(readStoreSnapshot(getPath(), false) ?? { version: 1, records: [] });
2941
+ return file.workspaceRestore?.liveRebindings?.some(value => value.inputRelease !== undefined) === true;
2942
+ },
2943
+ listProtectedLiveRebindTargets() {
2944
+ const file = parseTopicTargetFile(readStoreSnapshot(getPath(), false) ?? { version: 1, records: [] });
2945
+ return protectedLiveRebindOldTargets(file.workspaceRestore);
2946
+ },
2894
2947
  isWorkspaceLiveRebindCleanupTargetProtected(input) {
2895
2948
  try {
2896
2949
  const expected = structuredClone(input), path = getPath(), profile = activeProfile(), revision = mutationRevision;
@@ -2951,8 +3004,8 @@ export function createTelegramTopicTargetStore(options) {
2951
3004
  targetMatches(other.request.target, old);
2952
3005
  if (file.workspaceRestore.operations.some(touchesOld) ||
2953
3006
  file.workspaceRestore.liveRebindings?.some((other) => other.request.operationId !== request.operationId &&
2954
- other.phase !== "finished" &&
2955
- touchesOld(other)))
3007
+ (liveRebindActive(other) && touchesOld(other) ||
3008
+ (liveRebindOldTargetUnknown(other) || other.oldTargetProtected) && targetMatches(other.request.binding.target, old))))
2956
3009
  return true;
2957
3010
  for (const claim of workspaceClaims.values()) {
2958
3011
  const bindings = canonical.filter((binding) => binding.bindingKey === claim.identity.bindingKey);
@@ -3113,12 +3166,14 @@ export function createTelegramTopicTargetStore(options) {
3113
3166
  intent.routing !== undefined))
3114
3167
  return false;
3115
3168
  const state = read().state;
3169
+ if (protectedLiveRebindOldTargets(state).some(old => targetMatches(old, intent.request.binding.target) || targetMatches(old, intent.request.target)))
3170
+ return false;
3116
3171
  if (pendingWorkspaceRelocationRequests(state).some((request) => workspaceRestoresConflict(request, intent.request)) ||
3117
3172
  state.liveRebindings?.some((value) => value.request.operationId === intent.request.operationId))
3118
3173
  return false;
3119
3174
  if (state.revision === Number.MAX_SAFE_INTEGER)
3120
3175
  throw new Error("Workspace Restore revision exhausted.");
3121
- const pendingLive = (state.liveRebindings ?? []).filter((value) => value.phase !== "finished");
3176
+ const pendingLive = (state.liveRebindings ?? []).filter(retainLiveRebind);
3122
3177
  if ((live ? pendingLive.length : state.operations.length) >= 26)
3123
3178
  throw new Error("Workspace Restore operation capacity reached.");
3124
3179
  const restore = {
@@ -3352,7 +3407,7 @@ export function createTelegramTopicTargetStore(options) {
3352
3407
  return {
3353
3408
  list: () => structuredClone(storage.read().operations),
3354
3409
  listLiveRebindings: () => structuredClone(storage.read().liveRebindings ?? []),
3355
- async commitLiveRebind(requestInput, recipientInput, authority) {
3410
+ async commitLiveRebind(requestInput, recipientInput, authority, options) {
3356
3411
  const request = structuredClone(requestInput), recipient = structuredClone(recipientInput);
3357
3412
  const executor = structuredClone(authority.executor), operatorUserId = authority.operatorUserId;
3358
3413
  const current = () => currentScope() &&
@@ -3370,6 +3425,7 @@ export function createTelegramTopicTargetStore(options) {
3370
3425
  createdAtMs: at,
3371
3426
  updatedAtMs: at,
3372
3427
  phase: "rebound",
3428
+ ...(options?.holdInput ? { inputRelease: "held" } : {}),
3373
3429
  };
3374
3430
  if (!current() || !isWorkspaceLiveRebindIntent(intent))
3375
3431
  return undefined;
@@ -3383,7 +3439,8 @@ export function createTelegramTopicTargetStore(options) {
3383
3439
  isDeepStrictEqual(value.request, request) &&
3384
3440
  isDeepStrictEqual(value.recipient, recipient) &&
3385
3441
  isDeepStrictEqual(value.executor, executor) &&
3386
- value.operatorUserId === operatorUserId
3442
+ value.operatorUserId === operatorUserId &&
3443
+ (value.inputRelease !== undefined) === (options?.holdInput === true)
3387
3444
  ? structuredClone(value)
3388
3445
  : undefined;
3389
3446
  };
@@ -3411,14 +3468,24 @@ export function createTelegramTopicTargetStore(options) {
3411
3468
  if (!entry ||
3412
3469
  !isDeepStrictEqual(entry, expected) ||
3413
3470
  entry.operatorUserId !== operator ||
3414
- entry.phase === "finished")
3471
+ (entry.phase === "finished" && step !== "issue-input" && step !== "accept-input"))
3415
3472
  return undefined;
3416
- const issuing = step === "release" || step === "issue-cleanup";
3473
+ const issuing = step === "release" || step === "issue-cleanup" || step === "issue-input" || step === "accept-input" || step === "protect-old-target";
3417
3474
  if ((issuing || step === "confirmed") &&
3418
3475
  !isDeepStrictEqual(entry.executor, executor))
3419
3476
  return undefined;
3420
3477
  let next;
3421
- if (step === "release") {
3478
+ if (step === "issue-input" || step === "accept-input") {
3479
+ if (!["released", "finished"].includes(entry.phase) || entry.cleanup === "issued" || entry.inputRelease !== (step === "issue-input" ? "held" : "issued"))
3480
+ return undefined;
3481
+ next = { ...entry, inputRelease: step === "issue-input" ? "issued" : "accepted" };
3482
+ }
3483
+ else if (step === "protect-old-target") {
3484
+ if (entry.phase !== "released" || entry.cleanup !== undefined || entry.inputRelease !== "held")
3485
+ return undefined;
3486
+ next = { ...entry, phase: "finished", cleanup: "not-issued", oldTargetProtected: true };
3487
+ }
3488
+ else if (step === "release") {
3422
3489
  if (entry.phase !== "rebound")
3423
3490
  return undefined;
3424
3491
  next = { ...entry, phase: "released" };
@@ -3673,8 +3740,18 @@ export function createTelegramTopicTargetStore(options) {
3673
3740
  });
3674
3741
  },
3675
3742
  listTemporaryThreads: () => structuredClone(storage.read().temporaryThreads ?? []),
3676
- reserveTemporaryThread(sourceInput, token, authority) {
3743
+ reserveTemporaryThread(sourceInput, token, authority, groupInput) {
3677
3744
  const source = structuredClone(sourceInput);
3745
+ const group = groupInput
3746
+ ? structuredClone(groupInput)
3747
+ : {
3748
+ journalBindingKey: source.journalBindingKey,
3749
+ updateIds: [source.updateId],
3750
+ };
3751
+ if (!isTemporaryThreadInput(group) ||
3752
+ group.journalBindingKey !== source.journalBindingKey ||
3753
+ Math.min(...group.updateIds) !== source.updateId)
3754
+ return undefined;
3678
3755
  let reserved = false;
3679
3756
  const entry = mutate(authority, (file, executor, operator) => {
3680
3757
  const entries = file.temporaryThreads ?? [];
@@ -3690,12 +3767,7 @@ export function createTelegramTopicTargetStore(options) {
3690
3767
  throw new Error("Invalid Workspace Restore clock.");
3691
3768
  const next = {
3692
3769
  source,
3693
- inputs: [
3694
- {
3695
- journalBindingKey: source.journalBindingKey,
3696
- updateIds: [source.updateId],
3697
- },
3698
- ],
3770
+ inputs: [group],
3699
3771
  operatorUserId: operator,
3700
3772
  executor,
3701
3773
  token,
@@ -3708,7 +3780,7 @@ export function createTelegramTopicTargetStore(options) {
3708
3780
  if (!isTemporaryThreadEntry(next) ||
3709
3781
  entries.some((value) => value.token === token ||
3710
3782
  getTelegramTemporaryThreadInputs(value).some((input) => input.journalBindingKey === source.journalBindingKey &&
3711
- input.updateIds.includes(source.updateId))))
3783
+ input.updateIds.some((id) => group.updateIds.includes(id)))))
3712
3784
  return undefined;
3713
3785
  if (entries.length >= TEMPORARY_THREAD_CAPACITY)
3714
3786
  throw new Error("Temporary Thread capacity reached.");
@@ -4075,7 +4147,8 @@ export function createTelegramTopicTargetStore(options) {
4075
4147
  },
4076
4148
  reserveThread(reservation) {
4077
4149
  if (pendingWorkspaceRelocationRequests(workspaceRestore).some((request) => conflictsWithWorkspaceRestoreProvision(request, reservation)) ||
4078
- conflictsWithTemporaryThread(workspaceRestore, reservation.target)) {
4150
+ conflictsWithTemporaryThread(workspaceRestore, reservation.target) ||
4151
+ protectedLiveRebindOldTargets(workspaceRestore).some(old => targetMatches(old, reservation.target))) {
4079
4152
  throw new Error("Protected Workspace Restore provisioning conflict.");
4080
4153
  }
4081
4154
  const next = { ...reservation };
@@ -4086,7 +4159,8 @@ export function createTelegramTopicTargetStore(options) {
4086
4159
  },
4087
4160
  upsertPendingProvision(provision) {
4088
4161
  if (pendingWorkspaceRelocationRequests(workspaceRestore).some((request) => conflictsWithWorkspaceRestoreProvision(request, provision)) ||
4089
- conflictsWithTemporaryThread(workspaceRestore, provision.target)) {
4162
+ conflictsWithTemporaryThread(workspaceRestore, provision.target) ||
4163
+ protectedLiveRebindOldTargets(workspaceRestore).some(old => !provision.target || targetMatches(old, provision.target))) {
4090
4164
  throw new Error("Protected Workspace Restore provisioning conflict.");
4091
4165
  }
4092
4166
  const next = {
@@ -4937,7 +5011,7 @@ export function createTelegramTopicTargetStore(options) {
4937
5011
  const next = normalizeWorkspaceBindingRecord(binding);
4938
5012
  if (next && hasWorkspaceRetirementConflict(next))
4939
5013
  return undefined;
4940
- if (!next)
5014
+ if (!next || protectedLiveRebindOldTargets(workspaceRestore).some(old => targetMatches(old, next.target)))
4941
5015
  return undefined;
4942
5016
  const nextMapKey = getWorkspaceBindingMapKey(next);
4943
5017
  let claim = workspaceClaims.get(nextMapKey);
@@ -4994,16 +5068,19 @@ export function createTelegramTopicTargetStore(options) {
4994
5068
  if (previous?.manualThreadName && !next.manualThreadName) {
4995
5069
  next.manualThreadName = previous.manualThreadName;
4996
5070
  }
4997
- if (previous) {
5071
+ if (previous || replacedTargetBinding) {
4998
5072
  const journalBindingKeys = Array.from(new Set([
4999
- ...(previous.journalBindingKeys ?? []),
5073
+ ...(previous?.journalBindingKeys ?? []),
5074
+ ...(replacedTargetBinding?.journalBindingKeys ?? []),
5000
5075
  ...(next.journalBindingKeys ?? []),
5001
5076
  ]));
5002
- if (journalBindingKeys.length || previous.journalBindingsComplete)
5077
+ const retainedComplete = (!previous || previous.journalBindingsComplete === true) &&
5078
+ (!replacedTargetBinding || replacedTargetBinding.journalBindingsComplete === true);
5079
+ if (journalBindingKeys.length || retainedComplete)
5003
5080
  next.journalBindingKeys = journalBindingKeys;
5004
5081
  else
5005
5082
  delete next.journalBindingKeys;
5006
- if (previous.journalBindingsComplete)
5083
+ if (retainedComplete)
5007
5084
  next.journalBindingsComplete = true;
5008
5085
  else
5009
5086
  delete next.journalBindingsComplete;
@@ -7,6 +7,8 @@ import { type DownloadedTelegramMessageFile, type DownloadTelegramMessageFilesDe
7
7
  import { truncateTelegramQueueSummary, type PendingTelegramTurn, type TelegramPreparedPromptTurn, type TelegramQueueItem, type TelegramQueueStore } from "./queue.ts";
8
8
  import { type TelegramVoiceReplyMode } from "./voice.ts";
9
9
  export declare const TELEGRAM_PREFIX = "[telegram]";
10
+ /** A routed source keeps its unique message/file/group identity, but cannot quote an anchor in its former tab. */
11
+ export declare function withoutTelegramTurnReplyAnchor<TMessage extends object>(message: TMessage): TMessage;
10
12
  export declare const TELEGRAM_GUEST_TURN_NOTE = "[guest] delivery: answer quickly with one concise, self-contained reply";
11
13
  export interface TelegramTurnTarget {
12
14
  chatId: number;
package/dist/lib/turns.js CHANGED
@@ -9,6 +9,11 @@ import { appendTelegramAttachmentSection, appendTelegramListSection, buildTelegr
9
9
  import { createTelegramQueueAdmissionReceipt, truncateTelegramQueueSummary, } from "./queue.js";
10
10
  import { computeVoicePromptContribution, computeVoiceTurnFlags, getTelegramVoiceReplyMode, } from "./voice.js";
11
11
  export const TELEGRAM_PREFIX = "[telegram]";
12
+ const REPLY_ANCHOR_SUPPRESSED = Symbol.for("@llblab/pi-telegram:reply-anchor-suppressed");
13
+ /** A routed source keeps its unique message/file/group identity, but cannot quote an anchor in its former tab. */
14
+ export function withoutTelegramTurnReplyAnchor(message) {
15
+ return { ...message, [REPLY_ANCHOR_SUPPRESSED]: true };
16
+ }
12
17
  export const TELEGRAM_GUEST_TURN_NOTE = "[guest] delivery: answer quickly with one concise, self-contained reply";
13
18
  function getTelegramTurnTarget(message) {
14
19
  return Number.isInteger(message.message_thread_id)
@@ -456,7 +461,7 @@ function buildPreparedTelegramPromptTurn(options, images) {
456
461
  kind: "prompt",
457
462
  chatId: firstMessage.chat.id,
458
463
  target: getTelegramTurnTarget(firstMessage),
459
- replyToMessageId: firstMessage.message_id,
464
+ replyToMessageId: Reflect.get(firstMessage, REPLY_ANCHOR_SUPPRESSED) === true ? 0 : firstMessage.message_id,
460
465
  sourceMessageIds: collectTelegramMessageIds(options.messages),
461
466
  queueOrder: options.queueOrder,
462
467
  queueLane: "default",
@@ -229,7 +229,9 @@ interface TelegramUpdateAdmissionBinding {
229
229
  inspectSourceSnapshot?: () => TelegramDeferredSourceSnapshot | undefined;
230
230
  inspectCompletion?: () => TelegramDeferredSourceEvidence | undefined;
231
231
  isSourceUnsettled?: () => boolean;
232
- prepareLiveInput?: (sourceUpdateIds: readonly number[], isCurrent: () => boolean) => TelegramLiveDeferredInputPreparation | undefined;
232
+ prepareLiveInput?: (sourceUpdateIds: readonly number[], isCurrent: () => boolean, onTransferred?: () => void) => TelegramLiveDeferredInputPreparation | undefined;
233
+ /** Internal notification after the worker's exact donor-removal ACK; never issues completion itself. */
234
+ acknowledgeTransferred?: () => void;
233
235
  prepareQueueAdmission?: () => TelegramDeferredQueueAdmissionPreparation | undefined;
234
236
  prepareSourceCompletion?: () => Pick<TelegramDeferredSourceCompletionPreparation, "source" | "isCurrent"> | undefined;
235
237
  supportsAbandonment?: (journalBindingKey: string) => boolean;
@@ -241,7 +243,7 @@ interface TelegramUpdateAdmissionBinding {
241
243
  export type TelegramQueueAdmissionReceiptLike = TelegramQueueAdmissionReceipt;
242
244
  export declare function bindTelegramUpdateAdmissionSource<TUpdate extends TelegramUpdateFlow & {
243
245
  update_id: number;
244
- }>(update: TUpdate, report: TelegramUpdateAdmissionBinding["report"], controls?: Pick<TelegramUpdateAdmissionBinding, "abandon" | "armRoutingInput" | "getRoutingInput" | "acquireRouting" | "inspectSource" | "inspectSourceSnapshot" | "inspectCompletion" | "isSourceUnsettled" | "prepareLiveInput" | "prepareQueueAdmission" | "prepareSourceCompletion" | "supportsAbandonment" | "inspectAbandoning" | "inspectHistorical" | "isHistorical" | "isHistoricalReviewHeld">): TUpdate;
246
+ }>(update: TUpdate, report: TelegramUpdateAdmissionBinding["report"], controls?: Pick<TelegramUpdateAdmissionBinding, "abandon" | "armRoutingInput" | "getRoutingInput" | "acquireRouting" | "inspectSource" | "inspectSourceSnapshot" | "inspectCompletion" | "isSourceUnsettled" | "acknowledgeTransferred" | "prepareLiveInput" | "prepareQueueAdmission" | "prepareSourceCompletion" | "supportsAbandonment" | "inspectAbandoning" | "inspectHistorical" | "isHistorical" | "isHistoricalReviewHeld">): TUpdate;
245
247
  /**
246
248
  * Conservative read-only census: any retained entry naming this Thread, in any nested Telegram object or state,
247
249
  * is unresolved custody, except a lone button tap, which carries no input (the in-flight Cancel itself).
@@ -596,7 +598,9 @@ export interface TelegramUpdateExecutionFailureClassification {
596
598
  }
597
599
  export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
598
600
  journal: TelegramUpdateWorkerJournalPort;
599
- executeUpdate: (update: TelegramJournaledUpdate, ctx: TContext, signal: AbortSignal) => Promise<TelegramUpdateAdmissionOutcome> | TelegramUpdateAdmissionOutcome;
601
+ executeUpdate: (update: TelegramJournaledUpdate, ctx: TContext, signal: AbortSignal, target?: TelegramTarget & {
602
+ threadId: number;
603
+ }) => Promise<TelegramUpdateAdmissionOutcome> | TelegramUpdateAdmissionOutcome;
600
604
  /** Native source-only binding, installed by the admission-worker assembly rather than ordinary dispatch. */
601
605
  admitPreparedLiveInput?: (update: TelegramJournaledUpdate, ctx: TContext, signal: AbortSignal) => Promise<{
602
606
  outcome: TelegramUpdateAdmissionOutcome;
@@ -660,7 +664,9 @@ export interface TelegramLiveInputPreparation {
660
664
  prepareStatusConsumption?(input: TelegramLiveStatusConsumption): boolean;
661
665
  /** Native owner availability before recipient append; not admission or future target activation. */
662
666
  canPrepareStatusConsumption?(): boolean;
663
- release(canRelease: () => boolean): boolean;
667
+ release(canRelease: () => boolean, target?: TelegramTarget & {
668
+ threadId: number;
669
+ }): boolean;
664
670
  /** Ends a failed pre-append attempt only when no selected input was saved. */
665
671
  cancelEmpty(): boolean;
666
672
  /** One fenced removal of this never-dispatched recipient copy; unknown issuance cannot be retried. */
@@ -704,6 +710,7 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
704
710
  sourceUpdateIds: readonly number[];
705
711
  signal: AbortSignal;
706
712
  isCurrent(): boolean;
713
+ onTransferred?(): void;
707
714
  }) => TelegramLiveDeferredInputPreparation | undefined;
708
715
  prepareDeferredQueueAdmission?: (input: {
709
716
  updateId: number;
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { isDeepStrictEqual } from "node:util";
8
+ import { withoutTelegramTurnReplyAnchor } from "./turns.js";
8
9
  import { createTelegramUserPairingRuntime, getTelegramAuthorizationState, } from "./config.js";
9
10
  import { TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION, TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION, TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH, TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH, TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH, TELEGRAM_UPDATE_JOURNAL_VERSION, areTelegramUpdateJournalQueueOwnersEqual, createTelegramUpdateJournalEntryDigest, doesTelegramJournalUpdateNameThread, isTelegramJournalLoneCallbackUpdate, getTelegramUpdateJournalBindingPath, isTelegramUpdateJournalLegacyFamilyVersion, isTelegramUpdateJournalQueueOwnerProcess, parseTelegramUpdateJournalQueueOwner, } from "./journal.js";
10
11
  import { isPiStaleContextError } from "./pi.js";
@@ -262,19 +263,26 @@ export function prepareTelegramLiveDeferredInput(values, isCurrent) {
262
263
  carriers.some((value) => getTelegramUpdateExecutionFence(value)?.signal !== execution.signal ||
263
264
  getTelegramUpdateExecutionFence(value)?.isCurrent() !== true))
264
265
  return undefined;
265
- const unsettled = () => carriers.every((value) => getTelegramUpdateAdmissionBinding(value)?.isSourceUnsettled?.() ===
266
- true);
266
+ const bindings = carriers.map(getTelegramUpdateAdmissionBinding);
267
+ const acknowledgers = bindings.map(binding => binding?.acknowledgeTransferred);
268
+ const bindingsCurrent = () => carriers.every((value, index) => getTelegramUpdateAdmissionBinding(value) === bindings[index] && bindings[index]?.acknowledgeTransferred === acknowledgers[index]);
269
+ const unsettled = () => bindingsCurrent() && bindings.every(binding => binding?.isSourceUnsettled?.() === true);
267
270
  if (!unsettled())
268
271
  return undefined;
269
- const prepared = getTelegramUpdateAdmissionBinding(first)?.prepareLiveInput?.(ids, () => isCurrent() &&
270
- carriers.every((value) => getTelegramUpdateExecutionFence(value)?.isCurrent() === true));
272
+ let transferred = false;
273
+ const prepared = getTelegramUpdateAdmissionBinding(first)?.prepareLiveInput?.(ids, () => bindingsCurrent() && isCurrent() &&
274
+ carriers.every((value) => getTelegramUpdateExecutionFence(value)?.isCurrent() === true), () => {
275
+ transferred = true;
276
+ for (let index = 0; index < bindings.length; index++)
277
+ acknowledgers[index]?.call(bindings[index]);
278
+ });
271
279
  return (prepared && {
272
280
  ...prepared,
273
281
  isCurrent: () => unsettled() && prepared.isCurrent(),
274
282
  confirmSaved: () => unsettled() && prepared.confirmSaved(),
275
283
  beginRelease: (canRelease) => unsettled() && prepared.beginRelease(() => unsettled() && canRelease()),
276
284
  settleTransferred: (canSettle) => unsettled()
277
- ? prepared.settleTransferred(() => unsettled() && canSettle())
285
+ ? prepared.settleTransferred(() => (transferred || unsettled()) && canSettle())
278
286
  : "protected",
279
287
  cancel: () => unsettled() && prepared.cancel(),
280
288
  });
@@ -1714,7 +1722,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1714
1722
  transition("blocked", currentUpdateId, "authority-lost");
1715
1723
  return "blocked";
1716
1724
  };
1717
- const executeWithinOwner = async (expectedOwner, update, preparedExecute) => {
1725
+ const executeWithinOwner = async (expectedOwner, update, preparedExecute, target) => {
1718
1726
  if (expectedOwner.controller.signal.aborted) {
1719
1727
  return TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED;
1720
1728
  }
@@ -1723,7 +1731,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1723
1731
  ? preparedExecute(update, expectedOwner.ctx, expectedOwner.controller.signal)
1724
1732
  : deps.executeCustodiedUpdate
1725
1733
  ? deps.executeCustodiedUpdate(update, expectedOwner.ctx, expectedOwner.controller.signal)
1726
- : deps.executeUpdate(update, expectedOwner.ctx, expectedOwner.controller.signal));
1734
+ : deps.executeUpdate(update, expectedOwner.ctx, expectedOwner.controller.signal, target));
1727
1735
  const settlement = execution.then((outcome) => custodied
1728
1736
  ? {
1729
1737
  ok: true,
@@ -2434,7 +2442,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
2434
2442
  throw new Error("Prepared status admission is unconfirmed.");
2435
2443
  return admitted.outcome;
2436
2444
  }
2437
- : undefined);
2445
+ : undefined, prepared?.target);
2438
2446
  if (execution === TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED) {
2439
2447
  return "aborted";
2440
2448
  }
@@ -2490,6 +2498,12 @@ export function createTelegramUpdateWorkerRuntime(deps) {
2490
2498
  snapshotInvalidated = true;
2491
2499
  break;
2492
2500
  }
2501
+ if (prepared?.target) {
2502
+ prepared.ids = new Set([...prepared.ids].filter(id => id !== entry.updateId));
2503
+ if (!prepared.ids.size && livePreparation === prepared)
2504
+ livePreparation = undefined;
2505
+ updateClaimCounts();
2506
+ }
2493
2507
  const postExecutionAuthority = checkAuthority(expectedOwner, entry.updateId);
2494
2508
  if (postExecutionAuthority)
2495
2509
  return postExecutionAuthority;
@@ -2856,7 +2870,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
2856
2870
  launchDrain();
2857
2871
  },
2858
2872
  prepareDeferredLiveInput(input) {
2859
- const expectedOwner = owner, binding = deps.getJournalBindingKey?.(), ids = [...input.sourceUpdateIds];
2873
+ const expectedOwner = owner, binding = deps.getJournalBindingKey?.(), onTransferred = input.onTransferred, ids = [...input.sourceUpdateIds];
2860
2874
  const sources = ids.map((id) => deferredSources.get(id));
2861
2875
  const ownerCurrent = () => !!expectedOwner &&
2862
2876
  owner === expectedOwner &&
@@ -2867,7 +2881,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
2867
2881
  deps.hasAuthority(expectedOwner.ctx) &&
2868
2882
  deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
2869
2883
  isQueueOwnerIdentityCurrent(expectedOwner) &&
2870
- input.isCurrent();
2884
+ input.onTransferred === onTransferred && input.isCurrent();
2871
2885
  const sourcesCurrent = () => ownerCurrent() &&
2872
2886
  sources.every((source, index) => !!source &&
2873
2887
  deferredSources.get(ids[index]) === source &&
@@ -2946,10 +2960,11 @@ export function createTelegramUpdateWorkerRuntime(deps) {
2946
2960
  held.discardIssued = true;
2947
2961
  try {
2948
2962
  const result = completeExact(ids, expected, undefined, () => current() && canSettle());
2949
- if (!current() ||
2950
- !canSettle() ||
2951
- result.removedUpdateIds.length !== ids.length ||
2952
- !ids.every((id) => result.removedUpdateIds.includes(id)))
2963
+ if (result.removedUpdateIds.length !== ids.length || !ids.every(id => result.removedUpdateIds.includes(id)))
2964
+ return "unknown";
2965
+ // A consumed routing clock is expected only after this exact ACK, never after uncertain removal.
2966
+ onTransferred?.call(input);
2967
+ if (!current() || !canSettle())
2953
2968
  return "unknown";
2954
2969
  }
2955
2970
  catch (error) {
@@ -3063,7 +3078,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
3063
3078
  }
3064
3079
  };
3065
3080
  const finish = () => {
3066
- if (!held.released || !held.status)
3081
+ if (!held.released || !held.status && !held.target)
3067
3082
  livePreparation = undefined;
3068
3083
  updateClaimCounts();
3069
3084
  notifyStateChange();
@@ -3180,7 +3195,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
3180
3195
  };
3181
3196
  return held.status.current();
3182
3197
  },
3183
- release(canRelease) {
3198
+ release(canRelease, target) {
3184
3199
  if (!current() ||
3185
3200
  held.discardIssued ||
3186
3201
  !held.saved ||
@@ -3193,6 +3208,16 @@ export function createTelegramUpdateWorkerRuntime(deps) {
3193
3208
  !canRelease() ||
3194
3209
  !current())
3195
3210
  return false;
3211
+ if (target) {
3212
+ if (!Number.isSafeInteger(target.chatId) || target.chatId === 0 || !Number.isSafeInteger(target.threadId) || target.threadId <= 0 ||
3213
+ entries.some(entry => {
3214
+ const message = (entry.update.message ?? entry.update.edited_message);
3215
+ return message?.chat?.id !== target.chatId;
3216
+ }))
3217
+ return false;
3218
+ if (!held.status)
3219
+ held.target = { ...target };
3220
+ }
3196
3221
  released = true;
3197
3222
  held.released = true;
3198
3223
  finish();
@@ -5257,7 +5282,8 @@ function createTelegramUpdateSourceAdmissionHandle(deps, capture) {
5257
5282
  signal,
5258
5283
  })
5259
5284
  : undefined,
5260
- prepareLiveInput: (sourceUpdateIds, isCurrent) => execution.isCurrent() &&
5285
+ acknowledgeTransferred: () => { settlementReported = true; },
5286
+ prepareLiveInput: (sourceUpdateIds, isCurrent, onTransferred) => execution.isCurrent() &&
5261
5287
  !immediate &&
5262
5288
  !settlementReported &&
5263
5289
  !suspended
@@ -5266,6 +5292,7 @@ function createTelegramUpdateSourceAdmissionHandle(deps, capture) {
5266
5292
  sourceUpdateIds,
5267
5293
  signal,
5268
5294
  isCurrent: () => execution.isCurrent() && isCurrent(),
5295
+ onTransferred,
5269
5296
  })
5270
5297
  : undefined,
5271
5298
  isHistorical: (matchesOriginal) => !signal.aborted &&
@@ -6299,10 +6326,18 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
6299
6326
  update, ctx, signal),
6300
6327
  }
6301
6328
  : {}),
6302
- async executeUpdate(update, ctx, signal) {
6329
+ async executeUpdate(update, ctx, signal, target) {
6303
6330
  const typedUpdate = update;
6331
+ const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
6332
+ // Journal/source evidence remains the original; only the warm released handler projection changes destination.
6333
+ const routed = target ? { ...prepared,
6334
+ ...(prepared.message ? { message: prepared.message.message_thread_id === target.threadId ? { ...prepared.message }
6335
+ : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: target.threadId, reply_to_message: undefined }) } : {}),
6336
+ ...(prepared.edited_message ? { edited_message: prepared.edited_message.message_thread_id === target.threadId ? { ...prepared.edited_message }
6337
+ : withoutTelegramTurnReplyAnchor({ ...prepared.edited_message, message_thread_id: target.threadId, reply_to_message: undefined }) } : {}),
6338
+ } : prepared;
6304
6339
  try {
6305
- return await executeUpdate(deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate, ctx, signal);
6340
+ return await executeUpdate(routed, ctx, signal);
6306
6341
  }
6307
6342
  catch (error) {
6308
6343
  if (await deps.settleTerminalExecutionFailure?.(error)) {
package/dist/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"
package/docs/activity.md CHANGED
@@ -222,7 +222,7 @@ Default scope:
222
222
  - `telegram` uses `{ kind: "target", target: event.target }`, binding delayed handlers to the immutable originating thread rather than whichever turn happens to be active later.
223
223
  - `local`, `autonomous`, and `unknown` use `{ kind: "instance" }`.
224
224
 
225
- Handlers may explicitly choose aggregate or another authorized scope. Context methods delegate to the public delivery runtime on every call; they do not retain Telegram clients, Pi contexts, or transport ownership objects. The Delivery API rechecks authorization when an operation runs, so a captured target that is no longer owned fails closed instead of rerouting.
225
+ Handlers may explicitly choose an authorized scope; aggregate output is available only in Classic Mode, never Threaded Mode's input-only All. Context methods delegate to the public delivery runtime on every call; they do not retain Telegram clients, Pi contexts, or transport ownership objects. The Delivery API rechecks authorization when an operation runs, so a captured target that is no longer owned fails closed instead of rerouting.
226
226
 
227
227
  A handler may still receive events while no target is currently deliverable. Delivery then returns the normal structured `target-unavailable`, `target-unauthorized`, or `runtime-unavailable` result.
228
228