@llblab/pi-kit 0.29.1 → 0.29.2

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 (59) hide show
  1. package/BACKLOG.md +2 -1
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +4 -4
  4. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +12 -0
  5. package/node_modules/@llblab/pi-state-flow/README.md +2 -2
  6. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +2 -2
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +17 -8
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +7 -3
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +2 -1
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +3 -2
  11. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  12. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +3 -3
  13. package/node_modules/@llblab/pi-state-flow/docs/usage.md +3 -2
  14. package/node_modules/@llblab/pi-state-flow/lib/context.ts +17 -8
  15. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +6 -3
  16. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +4 -2
  17. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  18. package/node_modules/@llblab/pi-telegram/AGENTS.md +6 -5
  19. package/node_modules/@llblab/pi-telegram/BACKLOG.md +18 -1
  20. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +12 -0
  21. package/node_modules/@llblab/pi-telegram/README.md +5 -2
  22. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +1 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +4 -3
  24. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +4 -0
  25. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -1
  26. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +72 -11
  27. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +5 -0
  28. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +29 -11
  29. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +2 -0
  30. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +12 -2
  31. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +2 -0
  32. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +25 -3
  33. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +1 -0
  34. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +118 -44
  35. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +5 -0
  36. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +48 -5
  37. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +2 -0
  38. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +22 -1
  39. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +6 -4
  40. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +45 -3
  41. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +11 -4
  42. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +34 -18
  43. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  44. package/node_modules/@llblab/pi-telegram/docs/architecture.md +14 -8
  45. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  46. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +1 -0
  47. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +4 -2
  48. package/node_modules/@llblab/pi-telegram/lib/extension.ts +5 -0
  49. package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -11
  50. package/node_modules/@llblab/pi-telegram/lib/locks.ts +28 -11
  51. package/node_modules/@llblab/pi-telegram/lib/polling.ts +21 -2
  52. package/node_modules/@llblab/pi-telegram/lib/queue.ts +26 -3
  53. package/node_modules/@llblab/pi-telegram/lib/routing.ts +91 -36
  54. package/node_modules/@llblab/pi-telegram/lib/sync.ts +47 -5
  55. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +26 -1
  56. package/node_modules/@llblab/pi-telegram/lib/threads.ts +46 -7
  57. package/node_modules/@llblab/pi-telegram/lib/updates.ts +43 -21
  58. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  59. package/package.json +3 -3
@@ -61,7 +61,7 @@ The connected Pi instance owns Telegram polling. Use `/telegram-connect <profile
61
61
 
62
62
  An eligible single-text unbound Thread chooser offers **⛔️ Cancel routing**: the original stays private and is not sent to Pi; its Telegram message and Thread are not deleted. This does not cancel already selected or accepted work. When protected cancellation attempts remain, **Status → ❌ Pending cancellations** lets the owner review and finish them, including after restart. Historical unbound startup text has no main-menu entry or historical-review submenu. Eligible raw unsupported command/media/unknown historical private-Thread originals use a separate retain-only path, including on bound Threads without surviving Restore/temporary membership. Each worker generation reclassifies them after cold forgetting, blocking handler replay, generic Cancel and cold spending. Ordinary bound startup dispatch stops for these originals; same-kind live arrivals and owner/custody paths remain unchanged. Old historical-review buttons are refused without routing or abandoning the original; removal of the UI does not cancel previously accepted work.
63
63
 
64
- New eligible unbound prompt choosers allow **60 minutes to choose a route**, starting at their first positively acknowledged publication. The deadline survives restart and is not renewed by refresh. At expiry, the exact unselected original is retained privately before removal from the pending journal; stale controls cannot dispatch it. TTL neither deletes the Telegram tab nor stops Pi, and does not require detecting a manually removed tab. A validated Reroute/Restore choice freezes expiry before effects; browsing Restore or an unavailable destination does not. Selected, queued, running and uncertain issued work stay protected. Older historical inputs and unconfirmed clock publication acquire no invented deadline; failed retention stays held. Reloading the candidate and live acceptance are separate operator actions.
64
+ Eligible routing choosers and their donor sources share a **fixed 60-minute lifetime** from the first acknowledged publication, preserved across refresh/restart. Expiry drops unselected or unacknowledged routing attempts without privately archiving prompt bodies; nondelivery is accepted, so resend manually if needed. Old controls and late replies cannot replay the source. An expired disposable unbound tab is removed after all its owned groups resolve and fresh protection checks pass. Accepted queues, independent work and restored bound Threads remain intact. Confirmed expiry releases worker custody even if tab metadata fails; retries use only body-free tab/source proof, and uncertain deletion is never repeated. Explicit Cancel retains its separate private-original policy. Historical originals without a saved clock and unconfirmed publication receive no invented deadline. Local candidate checks do not certify live deletion.
65
65
 
66
66
  `/telegram-connect` keeps working when the shared runtime `state.json` is damaged. If it is malformed, foreign or contains invalid evidence, the connecting leader replaces it with a fresh empty state and continues. Every profile then forgets old Thread bindings, slots, unfinished Restore/temporary work and polling continuity; `telegram.json`, Pi history and the released `tmp/telegram` are untouched. Permission errors still stop connection. See [Damaged-State Reset](./docs/architecture.md#damaged-state-reset-operator-approved).
67
67
 
@@ -69,6 +69,8 @@ The prompt queue is session-local. Before a reload or restart, let it drain or k
69
69
 
70
70
  Journal recovery is a separate existing loss policy, not shared-envelope recovery. A snapshot removed by older broad temp cleanup is rebuilt when its complete segment history proves an empty result, while a revisionless snapshot is repaired from the first surviving segment's exact predecessor when the reconstructed tail validates. Otherwise the damaged snapshot and segments are deleted (their inputs are lost), a fresh journal is published, and startup continues with an informational diagnostic. Unsupported journal versions block recovery without rewriting or quarantining retained files; use a compatible runtime rather than deleting journals. Saved `telegram.json` configuration and runtime diagnostics are outside that journal reset. Startup best-effort removes obsolete current-runtime root/session `recovery/` folders, so those directories are not a preservation location.
71
71
 
72
+ Ordinary network or admission failures use exponential retry pauses capped at 30 seconds, without permanently stopping after an arbitrary attempt count. The poller recovers when the outage clears. Explicit `/telegram-disconnect` still attempts to stop its captured local transport if Thread cleanup is unavailable; it reports unconfirmed cleanup rather than successful deletion, and an old disconnect cannot stop a new connection. Incomplete stop/release remains visible; check `/telegram-status --debug` and keep Pi open.
73
+
72
74
  Persistent competing `getUpdates` clients cause a bounded transport stand-down rather than endless retries. Accepted local work remains queued/executable, but Telegram delivery stops. Inspect `/telegram-status --debug`, stop the competing client, then reconnect. See [Runtime Ownership](./docs/architecture.md#runtime-ownership).
73
75
 
74
76
  ### 4. Pair your Telegram account
@@ -214,11 +216,12 @@ Queue policy:
214
216
  - Priority and Normal are separate FIFO lanes; Priority dispatches first.
215
217
  - Moving `Normal → Priority` removes the prompt from Normal and places it at the Priority tail. Moving `Priority → Normal` removes it from Priority and places it at the Normal tail; no former position is restored.
216
218
  - Keep/Skip never changes lane position. Skip preserves durable authority while waiting so Keep remains reversible, then settles that authority and drops the prompt without a model turn when dispatch reaches it. Skipped prompts stay visible at their physical queue position with a struck-through ordinal, but are excluded immediately from the executable queue count shown in both the Pi status bar and Telegram main menu. Graceful session shutdown discards all remaining queued authority, so a new session starts empty.
217
- - Reactions control two independent dimensions; changing one category preserves the other:
219
+ - For ordinary prompts, reactions control two independent dimensions; changing one category preserves the other:
218
220
  - `Positive`: `👍`, `⚡️`, `❤️`, `🕊`, `🔥` — controls Priority.
219
221
  - `Negative`: `👎`, `👻`, `💔`, `💩`, `🗑` — controls Skip.
220
222
  - Priority and Skip can coexist—for example `👍 + 💩`. Skip wins at dispatch, regardless of which negative emoji is selected.
221
223
  - Menu selectors and reactions share queue state, but the bot cannot remove a user's reaction; Keep may clear internal Skip while the user's emoji remains visible until they remove it.
224
+ - A negative reaction to your original standalone `/continue` immediately cancels its exact waiting continuation, even while other work is active. This is irreversible cancellation, not reversible Skip. Positive reactions do not change its control lane; already-dispatched or running work and synthetic model-switch continuations are untouched.
222
225
 
223
226
  The detailed contract lives in [Priority, Reactions, Keep, and Skip](./docs/architecture.md#priority-reactions-keep-and-skip). If Pi automatically retries a transient provider failure, the active Telegram turn stays bound until the successful reply arrives or Pi confirms that the run has settled.
224
227
 
@@ -34,6 +34,7 @@ export function createTelegramQueueBindingRuntime(deps) {
34
34
  };
35
35
  const mutation = Queue.createTelegramQueueMutationController({
36
36
  ...deps.store,
37
+ hasPendingDispatch: deps.lifecycle.hasDispatchPending,
37
38
  allocateLaneOrder: deps.queue.allocateItemOrder,
38
39
  onItemsDiscarded(items, ctx) {
39
40
  if (!settleDiscardedItems(items, ctx)) {
@@ -4,7 +4,7 @@
4
4
  * Wraps the direct Telegram Bot API runtime so follower instances can route outbound calls through the bus leader
5
5
  */
6
6
  import { markTelegramBusCrossTargetDelivery, stripTelegramBusApiMetadata, } from "./bus.js";
7
- import { buildTelegramAnswerGuestQueryBody, isTelegramMessageNotModifiedError, } from "./telegram-api.js";
7
+ import { buildTelegramAnswerGuestQueryBody, formatTelegramCallbackAnswerText, isTelegramMessageNotModifiedError, } from "./telegram-api.js";
8
8
  function asRecord(value) {
9
9
  return value && typeof value === "object" && !Array.isArray(value)
10
10
  ? value
@@ -201,15 +201,16 @@ export function createTelegramBusAwareApiRuntime(deps) {
201
201
  ]);
202
202
  },
203
203
  async answerCallbackQuery(callbackQueryId, text) {
204
+ const answer = formatTelegramCallbackAnswerText(text);
204
205
  if (deps.ownsDirect()) {
205
- await deps.directRuntime.answerCallbackQuery(callbackQueryId, text);
206
+ await deps.directRuntime.answerCallbackQuery(callbackQueryId, answer);
206
207
  return;
207
208
  }
208
209
  await deps.callFollowerApi("call", [
209
210
  "answerCallbackQuery",
210
211
  {
211
212
  callback_query_id: callbackQueryId,
212
- ...(text !== undefined ? { text } : {}),
213
+ ...(answer !== undefined ? { text: answer } : {}),
213
214
  },
214
215
  ]);
215
216
  },
@@ -943,6 +943,9 @@ export default function (pi) {
943
943
  return telegramJournalBindingRuntime.inspectSourceAbandonment(journalBindingKey, updateId);
944
944
  });
945
945
  },
946
+ inspectRoutingInputGroupExpiry(input) {
947
+ return telegramJournalReferenceRegistry.withReference({ referenceClass: "operator-disposition", recoveryKey: input.journalBindingKey }, function () { return telegramJournalBindingRuntime.inspectSourceGroupExpiry(input.journalBindingKey, input.updateIds); });
948
+ },
946
949
  inspectTemporaryThreadSources(_target, requiredJournalBindingKeys) {
947
950
  const binding = resolveTelegramUpdateJournalBinding();
948
951
  const botToken = configStore.getBotToken();
@@ -1548,6 +1551,7 @@ export default function (pi) {
1548
1551
  getSyncState: telegramSyncStateRuntime.getState,
1549
1552
  setSyncState: telegramSyncStateRuntime.setState,
1550
1553
  stopPolling: lockedPollingRuntime.stop,
1554
+ captureStopPolling: lockedPollingRuntime.captureStop,
1551
1555
  suspendPolling: lockedPollingRuntime.suspend,
1552
1556
  recordRuntimeEvent,
1553
1557
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
@@ -374,6 +374,9 @@ export interface TelegramRoutingInputAuthority {
374
374
  operatorUserId: number;
375
375
  isCurrent(): boolean;
376
376
  }
377
+ /** Expiry drops a donor attempt without retaining its body or claiming recipient execution/cancellation. */
378
+ export type TelegramRoutingInputExpiryResult = Omit<TelegramUpdateJournalPendingAbandonmentResult, "retainedPath">;
379
+ export type TelegramRoutingInputExpiryEvidence = Omit<TelegramUpdateJournalAbandonedPendingEvidence, "retainedPath">;
377
380
  /** Package-private v1 capability; raw input custody does not expose it. */
378
381
  export interface TelegramRoutingInputJournal {
379
382
  arm(input: TelegramRoutingInputAuthority & {
@@ -385,7 +388,9 @@ export interface TelegramRoutingInputJournal {
385
388
  };
386
389
  expire(input: Omit<TelegramRoutingInputAuthority, "entries"> & {
387
390
  entry: TelegramUpdateJournalEntry;
388
- }): TelegramUpdateJournalPendingAbandonmentResult;
391
+ }): TelegramRoutingInputExpiryResult;
392
+ inspectExpiry(updateId: number): TelegramRoutingInputExpiryEvidence | undefined;
393
+ inspectGroupExpiry(updateIds: readonly number[]): TelegramRoutingInputExpiryEvidence[] | undefined;
389
394
  }
390
395
  export interface TelegramUpdateJournalStore {
391
396
  routingInputs?: TelegramRoutingInputJournal;
@@ -804,6 +809,7 @@ export interface TelegramUpdateJournalBindingRuntime {
804
809
  getActiveRecoveryKey: () => string | undefined;
805
810
  /** Exact historical proof lookup; no store, admission, execution, recovery or mutation port. */
806
811
  inspectSourceAbandonment: (journalBindingKey: string, updateId: number) => TelegramUpdateJournalAbandonedPendingEvidence | undefined;
812
+ inspectSourceGroupExpiry: (journalBindingKey: string, updateIds: readonly number[]) => TelegramRoutingInputExpiryEvidence[] | undefined;
807
813
  inspectSourceCompletion: (journalBindingKey: string, expected: TelegramUpdateJournalSourceCompletion) => TelegramUpdateJournalSourceCompletion | undefined;
808
814
  inspectQueuedReceipt: (journalBindingKey: string, expected: TelegramUpdateJournalQueuedCompletion) => TelegramUpdateJournalQueuedReceiptEvidence | undefined;
809
815
  /** In-process `/new` succession: move unclaimed predecessor pending inputs into the successor session journal. */
@@ -285,6 +285,27 @@ export const TELEGRAM_ROUTING_INPUT_TTL_MS = 60 * 60 * 1000;
285
285
  export function createTelegramUpdateJournalEntryDigest(entry) {
286
286
  return { updateId: entry.updateId, sourceSha256: createHash("sha256").update(JSON.stringify(entry)).digest("hex") };
287
287
  }
288
+ function inspectRoutingInputExpiry(file, journalBindingKey, updateId) {
289
+ const disposition = file.operatorDispositions?.find(value => value.updateId === updateId &&
290
+ "dispositionKind" in value && value.dispositionKind === "legacy-custody" && value.action === "discard" &&
291
+ value.failureId === `routing-expiry:${value.evidenceSha256}`);
292
+ if (!disposition || !("operatorAuthorityId" in disposition) || file.entries.some(entry => entry.updateId === updateId))
293
+ return undefined;
294
+ return { journalBindingKey, updateId, operatorAuthorityId: disposition.operatorAuthorityId };
295
+ }
296
+ function inspectRoutingGroupExpiry(file, journalBindingKey, updateIds) {
297
+ if (!Array.isArray(updateIds) || !updateIds.length || updateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
298
+ updateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= updateIds[index - 1])))
299
+ return undefined;
300
+ if (file.entries.some(entry => updateIds.includes(entry.updateId)))
301
+ return undefined;
302
+ const expired = updateIds.map(id => inspectRoutingInputExpiry(file, journalBindingKey, id)).filter(value => value !== undefined);
303
+ if (!expired.length || expired.some(value => value.operatorAuthorityId !== expired[0].operatorAuthorityId))
304
+ return undefined;
305
+ // Some members may have been acknowledged earlier. Whole donor absence plus one exact expiry ends this known cohort,
306
+ // not recipient custody; fresh Thread protection still independently gates deletion.
307
+ return updateIds.map(updateId => ({ journalBindingKey, updateId, operatorAuthorityId: expired[0].operatorAuthorityId }));
308
+ }
288
309
  function createJournalError(code, path, detail, cause) {
289
310
  return new TelegramUpdateJournalError(code, path, `Telegram update journal ${detail}: ${path}`, cause === undefined ? undefined : { cause });
290
311
  }
@@ -2064,6 +2085,9 @@ export function createTelegramUpdateJournalBindingRuntime(deps) {
2064
2085
  return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
2065
2086
  });
2066
2087
  },
2088
+ inspectSourceGroupExpiry(journalBindingKey, updateIds) {
2089
+ return inspectHistoricalSource(journalBindingKey, file => inspectRoutingGroupExpiry(file, journalBindingKey, updateIds));
2090
+ },
2067
2091
  inspectSourceCompletion(journalBindingKey, expected) {
2068
2092
  const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
2069
2093
  return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
@@ -3401,6 +3425,7 @@ function createJournalStoreCore(options, getInputContext) {
3401
3425
  update: entry.update,
3402
3426
  admittedAtMs: entry.admittedAtMs,
3403
3427
  ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
3428
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
3404
3429
  state: "queued",
3405
3430
  queueKind: receipt.queueKind,
3406
3431
  queueReceiptId: receipt.receiptId,
@@ -4069,22 +4094,58 @@ function createJournalStoreCore(options, getInputContext) {
4069
4094
  });
4070
4095
  },
4071
4096
  };
4072
- const abandonRoutingPending = journal.abandonPending.bind(journal);
4073
4097
  if (isTelegramUpdateJournalLegacyFamilyVersion(version))
4074
4098
  journal.routingInputs = {
4075
4099
  arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
4076
4100
  select(input) { return mutateRoutingInputs(input, true); },
4101
+ inspectExpiry(updateId) {
4102
+ return runMutation(readCurrent => {
4103
+ if (!isSafeNonNegativeInteger(updateId))
4104
+ throw createJournalError("invalid", path, "invalid routing expiry update ID");
4105
+ return inspectRoutingInputExpiry(readCurrent().file, createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateId);
4106
+ });
4107
+ },
4108
+ inspectGroupExpiry(updateIds) {
4109
+ return runMutation(readCurrent => inspectRoutingGroupExpiry(readCurrent().file, createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateIds));
4110
+ },
4077
4111
  expire(input) {
4078
- const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
4079
- if (!lifetime || lifetime.phase !== "waiting" || lifetime.operatorUserId !== input.operatorUserId ||
4080
- !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
4081
- throw createJournalError("conflict", path, "routing input expiry lacks exact waiting authority");
4082
- }
4083
- const current = () => { const now = getNowMs(); return isSafeNonNegativeInteger(now) && now >= lifetime.expiresAtMs && input.isCurrent(); };
4084
- if (!current())
4085
- throw createJournalError("conflict", path, "routing input has not expired under current authority");
4086
- return abandonRoutingPending({ journalBindingKey: input.journalBindingKey, entry: original,
4087
- operatorAuthorityId: `telegram-owner:${input.operatorUserId}`, isCurrent: current });
4112
+ return runMutation(readCurrent => {
4113
+ const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
4114
+ const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
4115
+ if (!lifetime || original.state !== "pending" || original.inputClaim || original.inputProvenance || original.queueOwner ||
4116
+ original.queueReceiptId || original.queueHandoff || original.failure || input.journalBindingKey !== binding ||
4117
+ lifetime.operatorUserId !== input.operatorUserId || !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
4118
+ throw createJournalError("conflict", path, "routing expiry requires an exact unclaimed chooser source");
4119
+ }
4120
+ const assertCurrent = () => {
4121
+ const now = getNowMs();
4122
+ if (!isSafeNonNegativeInteger(now) || now < lifetime.expiresAtMs || !input.isCurrent())
4123
+ throw createJournalError("conflict", path, "routing source has not expired under current authority");
4124
+ };
4125
+ assertCurrent();
4126
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
4127
+ const evidenceSha256 = createHash("sha256").update(JSON.stringify(original)).digest("hex");
4128
+ const failureId = `routing-expiry:${evidenceSha256}`;
4129
+ const existing = current.file.operatorDispositions?.find(value => value.updateId === original.updateId);
4130
+ const entry = current.file.entries.find(value => value.updateId === original.updateId);
4131
+ if (existing) {
4132
+ if (entry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" || existing.action !== "discard" ||
4133
+ existing.failureId !== failureId || existing.evidenceSha256 !== evidenceSha256 || existing.operatorAuthorityId !== `telegram-owner:${input.operatorUserId}`)
4134
+ throw createJournalError("conflict", path, "routing source has contradictory expiry evidence");
4135
+ assertCurrent();
4136
+ return { disposition: existing, duplicate: true, entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
4137
+ }
4138
+ if (!entry || !isDeepStrictEqual(entry, original))
4139
+ throw createJournalError("conflict", path, "routing source changed before expiry");
4140
+ // A discard tombstone prevents old readers and delayed reports from replaying; no prompt-body archive is written.
4141
+ const now = getNowMs();
4142
+ const disposition = { dispositionKind: "legacy-custody", failureId,
4143
+ updateId: original.updateId, action: "discard", committedAtMs: now, authorizedAtMs: now, evidenceSha256,
4144
+ operatorAuthorityId: `telegram-owner:${input.operatorUserId}` };
4145
+ const published = publishMutation(current, current.file.entries.filter(value => value.updateId !== original.updateId), true, [...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId, (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
4146
+ assertCurrent();
4147
+ return { disposition, duplicate: false, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
4148
+ });
4088
4149
  },
4089
4150
  };
4090
4151
  if (!getInputContext)
@@ -215,6 +215,11 @@ type TelegramLockedPollingStartResult = {
215
215
  export interface TelegramLockedPollingRuntime<TContext extends TelegramLockContext> {
216
216
  start: (ctx: TContext, options?: TelegramLockedPollingStartOptions) => Promise<TelegramLockedPollingStartResult>;
217
217
  stop: () => Promise<string>;
218
+ /** Capture one disconnect attempt before cleanup awaits; a newer start revokes it. */
219
+ captureStop: () => {
220
+ isCurrent: () => boolean;
221
+ stop: () => Promise<string>;
222
+ };
218
223
  suspend: () => Promise<void>;
219
224
  isSuspended: () => boolean;
220
225
  /** Fence one owned polling generation; suspension, restart, conflict or lock loss revokes it. */
@@ -1210,6 +1210,8 @@ export function createTelegramLockedPollingRuntime(deps) {
1210
1210
  stopOwnershipWatcher();
1211
1211
  if (sessionAutoStartRun) {
1212
1212
  await sessionAutoStartRun;
1213
+ if (generation !== pollingGeneration)
1214
+ return;
1213
1215
  deps.stopFollowerRegistration?.();
1214
1216
  }
1215
1217
  if (ownershipStop) {
@@ -1325,6 +1327,21 @@ export function createTelegramLockedPollingRuntime(deps) {
1325
1327
  const canStartPolling = (ctx) => deps.canStartPolling?.(ctx) ?? true;
1326
1328
  const formatStartBlockedMessage = (ctx) => deps.formatStartBlockedMessage?.(ctx) ??
1327
1329
  "Telegram polling is unavailable in this Pi run mode.";
1330
+ const stop = async () => {
1331
+ const generation = pollingGeneration + 1;
1332
+ await suspendPolling();
1333
+ if (generation !== pollingGeneration)
1334
+ throw new Error("Telegram disconnect was superseded by a new connection.");
1335
+ const state = deps.lock.release();
1336
+ deps.onTransportAvailabilityChanged?.();
1337
+ if (state.kind === "active-elsewhere") {
1338
+ return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
1339
+ }
1340
+ if (state.kind === "stale") {
1341
+ return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
1342
+ }
1343
+ return "Telegram bridge disconnected.";
1344
+ };
1328
1345
  return {
1329
1346
  start: async (ctx, options = {}) => {
1330
1347
  if (!deps.hasBotToken()) {
@@ -1440,17 +1457,18 @@ export function createTelegramLockedPollingRuntime(deps) {
1440
1457
  const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
1441
1458
  return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
1442
1459
  },
1443
- stop: async () => {
1444
- await suspendPolling();
1445
- const state = deps.lock.release();
1446
- deps.onTransportAvailabilityChanged?.();
1447
- if (state.kind === "active-elsewhere") {
1448
- return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
1449
- }
1450
- if (state.kind === "stale") {
1451
- return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
1452
- }
1453
- return "Telegram bridge disconnected.";
1460
+ stop,
1461
+ captureStop() {
1462
+ const generation = pollingGeneration;
1463
+ const isCurrent = () => generation === pollingGeneration;
1464
+ return {
1465
+ isCurrent,
1466
+ async stop() {
1467
+ if (!isCurrent())
1468
+ throw new Error("Telegram disconnect was superseded by a new connection.");
1469
+ return stop();
1470
+ },
1471
+ };
1454
1472
  },
1455
1473
  suspend: suspendPolling,
1456
1474
  captureTransportAuthority(ctx) {
@@ -28,6 +28,8 @@ export declare class TelegramPersistentGetUpdatesConflictError extends Error {
28
28
  readonly count: number;
29
29
  constructor(count: number);
30
30
  }
31
+ /** Exponential backoff for non-conflict polling/admission failures. */
32
+ export declare function getTelegramPollingRetryDelayMs(consecutiveFailures: number): number;
31
33
  export declare class TelegramGetUpdatesTimeoutError extends Error {
32
34
  readonly timeoutMs: number;
33
35
  constructor(timeoutMs: number);
@@ -14,7 +14,8 @@ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
14
14
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
15
15
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
16
16
  const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
17
- const TELEGRAM_POLLING_RETRY_MS = 3_000;
17
+ const TELEGRAM_POLLING_RETRY_BASE_MS = 1_000;
18
+ const TELEGRAM_POLLING_RETRY_MAX_MS = 30_000;
18
19
  const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
19
20
  // Standard Telegram DM polling does not expose ordinary message-deletion events.
20
21
  // Business deletions belong to a separate namespace and default routing ignores them.
@@ -51,6 +52,11 @@ export class TelegramPersistentGetUpdatesConflictError extends Error {
51
52
  this.count = count;
52
53
  }
53
54
  }
55
+ /** Exponential backoff for non-conflict polling/admission failures. */
56
+ export function getTelegramPollingRetryDelayMs(consecutiveFailures) {
57
+ const exponent = Math.max(0, Math.min(consecutiveFailures - 1, 16));
58
+ return Math.min(TELEGRAM_POLLING_RETRY_BASE_MS * 2 ** exponent, TELEGRAM_POLLING_RETRY_MAX_MS);
59
+ }
54
60
  export class TelegramGetUpdatesTimeoutError extends Error {
55
61
  timeoutMs;
56
62
  constructor(timeoutMs) {
@@ -1103,6 +1109,7 @@ export async function runTelegramPollLoop(deps) {
1103
1109
  if (!deps.config.botToken)
1104
1110
  return;
1105
1111
  let consecutiveGetUpdatesConflicts = 0;
1112
+ let consecutiveFailures = 0;
1106
1113
  const retryConflict = async () => {
1107
1114
  consecutiveGetUpdatesConflicts += 1;
1108
1115
  if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
@@ -1171,6 +1178,7 @@ export async function runTelegramPollLoop(deps) {
1171
1178
  onPhaseChange: deps.onPhaseChange,
1172
1179
  recordRuntimeEvent: deps.recordRuntimeEvent,
1173
1180
  });
1181
+ consecutiveFailures = 0;
1174
1182
  currentUpdateId = undefined;
1175
1183
  }
1176
1184
  catch (error) {
@@ -1190,8 +1198,10 @@ export async function runTelegramPollLoop(deps) {
1190
1198
  : {}),
1191
1199
  });
1192
1200
  consecutiveGetUpdatesConflicts = 0;
1201
+ consecutiveFailures += 1;
1193
1202
  deps.onErrorStatus(getTelegramPollingErrorMessage(error));
1194
- await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
1203
+ // Cap the pause, not retry count: outages must recover without manual reconnect.
1204
+ await deps.sleep(getTelegramPollingRetryDelayMs(consecutiveFailures), deps.signal);
1195
1205
  if (deps.signal.aborted)
1196
1206
  return;
1197
1207
  deps.onStatusReset();
@@ -613,11 +613,13 @@ export type TelegramSessionLifecycleHookEvent = unknown;
613
613
  export declare function createTelegramSessionStateApplier<TQueueItem, TModel>(deps: TelegramSessionStateApplierDeps<TQueueItem, TModel>): TelegramSessionStateApplier<TQueueItem, TModel>;
614
614
  export interface TelegramQueueMutationRuntimeDeps<TContext> extends TelegramQueueStore<TContext>, TelegramRuntimeEventRecorderPort {
615
615
  ctx: TContext;
616
+ hasPendingDispatch?: () => boolean;
616
617
  allocateLaneOrder?: () => number;
617
618
  onItemsDiscarded?: (items: readonly TelegramQueueItem<TContext>[], ctx: TContext) => void;
618
619
  updateStatus: (ctx: TContext) => void;
619
620
  }
620
621
  export interface TelegramQueueMutationControllerDeps<TContext> extends TelegramQueueStore<TContext>, TelegramRuntimeEventRecorderPort {
622
+ hasPendingDispatch?: () => boolean;
621
623
  allocateLaneOrder?: () => number;
622
624
  onItemsDiscarded?: (items: readonly TelegramQueueItem<TContext>[], ctx: TContext) => void;
623
625
  updateStatus: (ctx: TContext) => void;
@@ -582,7 +582,7 @@ function removeTelegramQueuedGuestPromptByOrder(items, queueOrder) {
582
582
  export function applyTelegramQueuePromptReactionDisposition(items, messageId, disposition, destinationLaneOrder, scope) {
583
583
  let nextItems = items;
584
584
  for (const [index, item] of items.entries()) {
585
- if (!isPendingTelegramTurn(item) ||
585
+ if (!isPendingTelegramTurn(item) || item.queueLane === "control" ||
586
586
  !isTelegramQueueItemInMessageScope(item, scope) ||
587
587
  !item.sourceMessageIds.includes(messageId)) {
588
588
  continue;
@@ -1550,7 +1550,15 @@ function updateTelegramQueueStatusRuntime(deps) {
1550
1550
  }
1551
1551
  }
1552
1552
  function commitReorderedTelegramQueueItemsRuntime(items, deps) {
1553
- deps.setQueuedItems([...items].sort(compareTelegramQueueItems));
1553
+ const pendingHead = deps.hasPendingDispatch?.() ? deps.getQueuedItems()[0] : undefined;
1554
+ const ordered = [...items].sort(compareTelegramQueueItems);
1555
+ if (pendingHead) {
1556
+ const index = ordered.findIndex(item => item.queueOrder === pendingHead.queueOrder && item.kind === pendingHead.kind &&
1557
+ item.chatId === pendingHead.chatId && item.replyToMessageId === pendingHead.replyToMessageId);
1558
+ if (index > 0)
1559
+ ordered.unshift(ordered.splice(index, 1)[0]);
1560
+ }
1561
+ deps.setQueuedItems(ordered);
1554
1562
  updateTelegramQueueStatusRuntime(deps);
1555
1563
  }
1556
1564
  function appendTelegramQueueItemRuntime(item, deps) {
@@ -1602,8 +1610,22 @@ function removeTelegramQueuedGuestPromptByOrderRuntime(queueOrder, deps) {
1602
1610
  }
1603
1611
  export function applyTelegramQueuePromptReactionDispositionRuntime(messageId, disposition, deps, scope) {
1604
1612
  const queuedItems = deps.getQueuedItems();
1613
+ const suppression = disposition.kind === "reaction-transition" ? disposition.suppressionEmoji :
1614
+ disposition.kind === "suppressed" ? disposition.emoji : disposition.kind === "priority-suppressed" ? disposition.suppressionEmoji : undefined;
1615
+ // Source-addressed control-lane prompts are explicit continuations. Synthetic model-switch turns have no source IDs.
1616
+ // A Pi-owned dispatched head cannot be removed: agent_start must consume that exact item, not its successor.
1617
+ const cancelled = suppression && deps.hasPendingDispatch ? queuedItems.filter((item, index) => isPendingTelegramTurn(item) && item.queueLane === "control" && isTelegramQueueItemInMessageScope(item, scope) &&
1618
+ item.sourceMessageIds.includes(messageId) && !(index === 0 && deps.hasPendingDispatch())) : [];
1619
+ if (cancelled.length) {
1620
+ if (cancelled.some(item => (item.admissionReceipts?.length ?? 0) > 0) && !deps.onItemsDiscarded)
1621
+ return false;
1622
+ deps.onItemsDiscarded?.(cancelled, deps.ctx);
1623
+ deps.setQueuedItems(queuedItems.filter(item => !cancelled.includes(item)));
1624
+ updateTelegramQueueStatusRuntime(deps);
1625
+ return true;
1626
+ }
1605
1627
  const changesLane = queuedItems.some((item) => {
1606
- if (!isPendingTelegramTurn(item) ||
1628
+ if (!isPendingTelegramTurn(item) || item.queueLane === "control" ||
1607
1629
  !isTelegramQueueItemInMessageScope(item, scope) ||
1608
1630
  !item.sourceMessageIds.includes(messageId)) {
1609
1631
  return false;
@@ -121,6 +121,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
121
121
  captureWorkspaceExternalProtection?: ReturnType<typeof createTelegramWorkspaceExternalProtectionCapture>;
122
122
  /** Strict committed abandonment plus retained original; shared by Restore and temporary-input cancellation. */
123
123
  inspectRestoreSourceAbandonment?: (updateId: number, journalBindingKey: string) => Threads.TelegramTemporaryThreadCancellationEvidence | undefined;
124
+ inspectRoutingInputGroupExpiry?: (input: Threads.TelegramTemporaryThreadInput) => readonly Threads.TelegramTemporaryThreadCancellationEvidence[] | undefined;
124
125
  /** Strict active-journal observation only; a hint or missing source never substitutes for this ACK. */
125
126
  inspectRestoreSourceCompletion?: (expected: Updates.TelegramDeferredSourceEvidence & {
126
127
  completionSha256: string;