@llblab/pi-telegram 0.52.0 → 0.52.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.
@@ -1701,24 +1701,34 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1701
1701
  return false;
1702
1702
  let nextAtMs, changed = false;
1703
1703
  const binding = deps.getJournalBindingKey?.(), snapshotIds = new Set(snapshot.entries.map(entry => entry.updateId));
1704
- // Lost removal ACKs reconcile only the exact held original through the retained copy/tombstone.
1704
+ // Lost expiry ACKs reconcile the exact held original against its body-free discard tombstone.
1705
1705
  const candidates = [...snapshot.entries, ...[...deferredSources.values()]
1706
1706
  .filter(source => source.journalBindingKey === binding && !snapshotIds.has(source.entry.updateId))
1707
1707
  .map(source => source.entry)];
1708
1708
  for (const entry of candidates) {
1709
1709
  const lifetime = entry.routingInput;
1710
- if (claims.get(entry.updateId) === "retained" || (expectedOwner.startupUpdateIds?.has(entry.updateId) &&
1711
- !claims.has(entry.updateId) && deps.shouldReviewHistoricalInput))
1710
+ if (!lifetime || entry.state !== "pending" || !binding || claims.get(entry.updateId) === "queued" ||
1711
+ unsettledExecutionsByUpdateId.get(entry.updateId)?.size)
1712
1712
  continue;
1713
- if (!lifetime || lifetime.phase !== "waiting" || entry.state !== "pending" || !binding || unsettledExecutionsByUpdateId.get(entry.updateId)?.size)
1713
+ // Classify the first startup snapshot before constructing any historical expiry carrier.
1714
+ if (expectedOwner.startupUpdateIds?.has(entry.updateId) && !claims.has(entry.updateId) && deps.shouldReviewHistoricalInput) {
1715
+ nextAtMs = Math.min(nextAtMs ?? Infinity, Math.max(getNowMs() + 1, lifetime.expiresAtMs));
1714
1716
  continue;
1717
+ }
1715
1718
  if (getNowMs() < lifetime.expiresAtMs) {
1716
1719
  nextAtMs = Math.min(nextAtMs ?? Infinity, lifetime.expiresAtMs);
1717
1720
  continue;
1718
1721
  }
1719
1722
  const existing = deferredSources.get(entry.updateId);
1720
- if (existing && !isDeepStrictEqual(existing.entry, entry))
1721
- continue;
1723
+ if (existing && !isDeepStrictEqual(existing.entry, entry)) {
1724
+ // Lost clock/selection ACKs may leave only this immutable metadata behind its exact source; reconciliation grants expiry, never another dispatch.
1725
+ const previous = existing.entry.routingInput;
1726
+ if (!isDeepStrictEqual({ ...existing.entry, routingInput: lifetime }, entry) ||
1727
+ (previous && (!isDeepStrictEqual({ ...previous, phase: lifetime.phase }, lifetime) ||
1728
+ (previous.phase === "selected" && lifetime.phase !== "selected"))))
1729
+ continue;
1730
+ existing.entry = structuredClone(entry);
1731
+ }
1722
1732
  const source = existing ?? { entry: structuredClone(entry), journalBindingKey: binding, historical: true };
1723
1733
  deferredSources.set(entry.updateId, source);
1724
1734
  if (!claims.has(entry.updateId))
@@ -1738,13 +1748,14 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1738
1748
  return { ...committed, duplicate: true };
1739
1749
  claims.set(entry.updateId, "abandoning");
1740
1750
  committed = expireRouting({ entry: source.entry, journalBindingKey: binding, operatorUserId: lifetime.operatorUserId, isCurrent: current });
1751
+ // Source expiry is terminal before chooser/cleanup awaits; later failures must not retain a deferred/retry source.
1741
1752
  claims.delete(entry.updateId);
1742
1753
  deferredSources.delete(entry.updateId);
1743
1754
  state.journalEntryCount = committed.entryCount;
1744
1755
  state.journalSerializedBytes = committed.serializedBytes;
1745
1756
  updateClaimCounts();
1746
1757
  notifyStateChange();
1747
- changed = true;
1758
+ changed ||= !committed.duplicate;
1748
1759
  return committed;
1749
1760
  } }, expectedOwner.ctx, expectedOwner.controller.signal);
1750
1761
  }
@@ -1964,7 +1975,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1964
1975
  transition("deferred", entry.updateId);
1965
1976
  continue;
1966
1977
  }
1967
- if (review && historical && spendHistorical && classifyPending === deps.shouldReviewHistoricalInput) {
1978
+ if (review && historical && spendHistorical && !entry.routingInput && classifyPending === deps.shouldReviewHistoricalInput) {
1968
1979
  completedUpdateIds.push({ updateId: entry.updateId, spent: true });
1969
1980
  continue;
1970
1981
  }
@@ -1975,10 +1986,6 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1975
1986
  }
1976
1987
  }
1977
1988
  if (entry.state === "pending" && entry.routingInput) {
1978
- if (historical && spendHistorical) {
1979
- completedUpdateIds.push({ updateId: entry.updateId, spent: true });
1980
- continue;
1981
- }
1982
1989
  claims.set(entry.updateId, "historical");
1983
1990
  transition("deferred", entry.updateId);
1984
1991
  continue;
@@ -2123,7 +2130,9 @@ export function createTelegramUpdateWorkerRuntime(deps) {
2123
2130
  expectedOwner.heldSourcesPreparedIssued = true;
2124
2131
  // Do not couple lifecycle/stop or the worker drain to a held controller/API reply.
2125
2132
  void Promise.resolve().then(() => isCurrent() ? onHeldSourcesPrepared({ ctx,
2126
- journalBindingKey: key, signal: expectedOwner.controller.signal, isCurrent }) : undefined).catch(error => {
2133
+ journalBindingKey: key, signal: expectedOwner.controller.signal, isCurrent,
2134
+ routingSourceIds: [...deferredSources].filter(([, source]) => source.journalBindingKey === key && !!source.entry.routingInput)
2135
+ .map(([id]) => id) }) : undefined).catch(error => {
2127
2136
  try {
2128
2137
  deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "held-source-preparation" });
2129
2138
  }
@@ -2286,6 +2295,10 @@ export function createTelegramUpdateWorkerRuntime(deps) {
2286
2295
  },
2287
2296
  armRoutingInput(input) { return mutateRoutingSource(input, false)?.lifetime; },
2288
2297
  selectRoutingInput(input) { return mutateRoutingSource(input, true)?.issued === true; },
2298
+ isRoutingInputCurrent(input) {
2299
+ return owner?.controller.signal === input.signal && !input.signal.aborted &&
2300
+ deferredSources.has(input.updateId) && claims.get(input.updateId) !== "abandoning";
2301
+ },
2289
2302
  inspectDeferredSource(input) {
2290
2303
  const expectedOwner = owner;
2291
2304
  const source = deferredSources.get(input.updateId);
@@ -2346,7 +2359,8 @@ export function createTelegramUpdateWorkerRuntime(deps) {
2346
2359
  return;
2347
2360
  }
2348
2361
  const claim = claims.get(input.updateId);
2349
- if (claim === "abandoning" || claim === "historical" || claim === "retained")
2362
+ if (claim === "abandoning" || claim === "historical" || claim === "retained" ||
2363
+ (!claim && routingJournal?.inspectExpiry(input.updateId)))
2350
2364
  return;
2351
2365
  if (input.outcome.kind === "complete") {
2352
2366
  // Expiry may retire only a still-deferred source, never accepted queue work.
@@ -3551,9 +3565,10 @@ export function createTelegramUpdateAdmissionHandle(deps) {
3551
3565
  generation,
3552
3566
  updateId: update.update_id,
3553
3567
  signal,
3554
- isCurrent: () => !signal.aborted && !suspended,
3568
+ isCurrent: () => !signal.aborted && !suspended && ((!routingLifetime && !routingClockUnknown) || immediate || settlementReported ||
3569
+ deps.isRoutingInputCurrent?.({ updateId: update.update_id, signal }) !== false),
3555
3570
  assertCurrent() {
3556
- if (signal.aborted || suspended) {
3571
+ if (!execution.isCurrent()) {
3557
3572
  throw signal.reason ?? new DOMException("Aborted", "AbortError");
3558
3573
  }
3559
3574
  },
@@ -3567,7 +3582,7 @@ export function createTelegramUpdateAdmissionHandle(deps) {
3567
3582
  let settlementReported = false;
3568
3583
  let outcome;
3569
3584
  const boundUpdate = bindTelegramUpdateExecutionFence(bindTelegramUpdateAdmissionSource(update, (next) => {
3570
- if (suspended || signal.aborted)
3585
+ if (!execution.isCurrent())
3571
3586
  return;
3572
3587
  if (next.kind === "deferred" && next.routingReview) {
3573
3588
  if (!immediate || settlementReported || routingClaims > 0 || !deps.isHistoricalSource?.({ updateId: update.update_id, signal })) {
@@ -3586,7 +3601,7 @@ export function createTelegramUpdateAdmissionHandle(deps) {
3586
3601
  }
3587
3602
  void Promise.resolve()
3588
3603
  .then(() => {
3589
- if (suspended || signal.aborted)
3604
+ if (!execution.isCurrent())
3590
3605
  return;
3591
3606
  return deps.onLateOutcome(next, {
3592
3607
  updateId: update.update_id,
@@ -4487,6 +4502,7 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
4487
4502
  abandonDeferred: input => worker?.abandonDeferred?.(input),
4488
4503
  armRoutingInput: input => worker?.armRoutingInput?.(input),
4489
4504
  selectRoutingInput: input => worker?.selectRoutingInput?.(input) === true,
4505
+ isRoutingInputCurrent: input => worker?.isRoutingInputCurrent?.(input) !== false,
4490
4506
  supportsDeferredAbandonment: input => worker?.supportsDeferredAbandonment?.(input) === true,
4491
4507
  inspectAbandoning: input => worker?.inspectAbandoning?.(input),
4492
4508
  inspectHistorical: input => worker?.inspectHistorical?.(input),
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.52.0",
3
+ "version": "0.52.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -334,10 +334,12 @@ Read-only migration probes constrain that comparison. Publishing a v2 snapshot o
334
334
 
335
335
  ### Runtime Ownership
336
336
 
337
- - `/telegram-connect` acquires or moves the active profile's owner slot before polling starts. `/telegram-disconnect` keeps its destructive confirmation, then stops polling and releases only that exact slot. In Threaded Mode it tears down the disconnecting instance's bound Telegram thread: leaders delete their own thread directly, and followers send an authenticated exact-generation disconnect envelope and wait for confirmed leader cleanup before unregistering. Graceful Pi `quit` always preserves the owner slot as restart intent, allowing a reopened same-`cwd` session to reclaim the stale lease. When `threads.automaticCleanup` is enabled (the default), quit also deletes the bound Telegram tab without releasing that restart intent; disabling it preserves the tab. After delivery, polling and inbound-worker shutdown complete, a quitting leader detaches its exact owner record under profile admission while retaining the Workspace, letter and restart ownership. Publication rechecks the captured session generation, profile, leader epoch and successful current-generation suspension; unfinished startup/teardown, unproven suspension and pending cleanup stay protected. Preparation/publication errors are diagnostic and cannot block remaining session cleanup. Failed automatic deletion likewise falls back to safe suspension.
337
+ - `/telegram-connect` acquires or moves the active profile's owner slot before polling starts. `/telegram-disconnect` keeps its destructive confirmation, then stops polling and releases only that exact slot. In Threaded Mode it tears down the disconnecting instance's bound Telegram thread: leaders delete their own thread directly, and followers send an authenticated exact-generation disconnect envelope seeking confirmed leader cleanup before unregistering. If cleanup fails, explicit manual disconnect may instead stop its captured local transport without claiming Thread deletion. Graceful Pi `quit` always preserves the owner slot as restart intent, allowing a reopened same-`cwd` session to reclaim the stale lease. When `threads.automaticCleanup` is enabled (the default), quit also deletes the bound Telegram tab without releasing that restart intent; disabling it preserves the tab. After delivery, polling and inbound-worker shutdown complete, a quitting leader detaches its exact owner record under profile admission while retaining the Workspace, letter and restart ownership. Publication rechecks the captured session generation, profile, leader epoch and successful current-generation suspension; unfinished startup/teardown, unproven suspension and pending cleanup stay protected. Preparation/publication errors are diagnostic and cannot block remaining session cleanup. Failed automatic deletion likewise falls back to safe suspension.
338
+ - Explicit manual disconnect captures one lifecycle-generation-bound stop before entering cleanup admission. The cleanup path rechecks that captured authority before observing a target, after intent publication, before API/store effects and after awaited reconciliation. A newer connection revokes old cleanup and fallback stop; teardown also checks its generation after suspension before durable lock release. Cleanup admission failure may trigger only the captured local stop, not an unguarded latest-runtime stop or new Thread mutations. Failed cleanup reports unconfirmed outcome with debug guidance rather than claiming deletion was skipped or exposing raw errors. A stop/release error propagates as incomplete without another teardown attempt. Session-restart cleanup remains fail-closed behind admission fences and never uses this manual fallback.
338
339
  - Without an explicit carried connection intent, session start schedules polling resume asynchronously only when the owner slot already points at the current `pid`/`cwd`, or when a stale same-`cwd` owner can be safely replaced after process restart. Under a live foreign leader, startup instead attempts capability-gated restore-only follower admission when the selected profile has a remembered exact-`cwd` Workspace binding; it never provisions an unremembered Workspace. Startup and `/resume` do not wait on leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
339
340
  - On local `/resume`, an in-flight `/telegram-connect` or established connection supplies one process-local, 30-second intent containing the selected profile, exact CWD and target session file. The file matches only the lifecycle successor; the fresh public session ID still owns binding selection. Startup claims intent before asynchronous initialization and uses the normal connection path instead of concurrent auto-restore. Quit/reload/disconnect, mismatch, expiry or supersession cancel it. The predecessor carries no Pi context, callback, Thread or slot, and late completion can clear only its own attempt. Resume startup has no automatic forced takeover or repeated failure retry; errors remain diagnostic with a compact recovery notice.
340
341
  - The polling owner alone bounds `getUpdates`: each request derives its cancellation budget from Telegram's declared long-poll timeout plus 10 seconds of transport grace (10 seconds for the zero-timeout initial sync and 40 seconds for the normal 30-second poll). The request-local controller inherits poller cancellation, rejects its owner at the budget, and fences any late transport result. Ordinary Bot API and media operations do not receive speculative blanket deadlines. Existing caller signals remain authoritative through API retry waits, only retry-safe methods replay explicit retryable responses, and non-idempotent sends preserve commit-unknown evidence instead of risking duplicate mutation.
342
+ - Ordinary non-conflict poll/admission failures retry with exponential pauses of `1s → 2s → 4s → 8s → 16s → 30s`, capped at 30 seconds without a retry-count ceiling. Only successful durable admission resets this backoff; a successful HTTP response followed by journal failure does not. Retry sleeps remain poller-abortable, and accepted inputs/cursor ordering are unchanged. Outages therefore recover through the existing poller without manual reconnect.
341
343
  - Ten consecutive `getUpdates` conflict responses, including initial cursor sync, terminate polling with `persistent-conflict`; a successful response or a different error resets the count. The controller detaches its inner promise before notifying the locked lifecycle, avoiding teardown waiting on itself. That lifecycle stops ownership checks, lease refresh, capability monitoring, typing, and classic or bus transport (including leader health, pruning, and IPC), withdraws local direct authority, and transactionally releases only its exact lock. A failed durable release leaves local authority revoked; explicit reacquisition mints a fresh epoch. Accepted queue receipts and local Pi dispatch survive. One terminal diagnostic distinguishes lost local ownership from a competing client despite an apparently owned lock and reports cleanup failures; ordinary status refreshes retain generic `error` until transport recovers. Remove the competing client, then use `/telegram-connect`; no automatic retry continues after the terminal threshold.
342
344
  - Manual and automatic polling starts share a lifecycle generation. A later suspend, disconnect, persistent-conflict stop, or accepted start invalidates older startup continuations; reconnect captures its generation before waiting for transport teardown and rechecks it before acquiring ownership and after awaited startup work. Obsolete completion or failure cannot report a successful connection or roll back a replacement. Admission rejects stale/unauthorized contexts before advancing its own startup generation, so a rejected call cannot cancel valid in-flight initialization. After awaited bus startup, thread-aware completion checks its generation before starting leader health or changing fallback/startup-option state; teardown clears health independently of the current mode flag. Startup probes, capability-monitor transitions, and observed-target transitions share an orchestration lifecycle fence and check it inside their effect-owning helpers after awaited queries, persistence, or transport work. Monitor stop also invalidates pending observations. These checks suppress subsequent state changes, fallback, health, and status effects; already-issued API/persistence calls retain their own transport/storage fencing.
343
345
  - `pollingActive` reports only whether this runtime still owns an unresolved polling lifecycle; it is not health evidence. A separate observable state records `starting`, `long-poll`, `persisting-journal`, `persisting-offset`, `retrying`, or `stopped`, together with phase start, current update id, last successful response time/count, and terminal stop reason. This distinguishes a stuck HTTP poll from downstream update work without a wall-clock stale heuristic.
@@ -460,13 +462,13 @@ This restart barrier applies once the retention copy is atomically published and
460
462
 
461
463
  Historical classification cannot rely on a clean v1 pending entry alone. The native integration test `Historical pending source alone cannot prove no follower acceptance` holds the acknowledgement after durable recipient admission, then revokes the source generation: reopening the source yields exactly its pre-forward snapshot, with no queue receipt, owner, input claim or provenance, while the recipient still owns admitted input. A topic-created reply marker does not distinguish those histories either. This demonstrates an information boundary, not that the reporter's particular inputs were forwarded. Recreating a chooser after startup cannot supply the missing historical evidence. Each worker generation now records all update IDs in its first fully validated journal snapshot before batching or execution. Without retention evidence, those sources cannot acquire ordinary fresh-cancellation capabilities, including through a newly published chooser or direct worker call. The baseline is not advanced on read/validation failure and is renewed for every generation; entries arriving before the first valid snapshot are conservatively included. Later admitted pending inputs retain normal cancellation, and independently protected retention attempts retain exact recovery. This fresh-cancellation fence does not establish non-delivery. Historical prior recipient acceptance remains unknown; removal of its review UI grants neither cancellation nor replay.
462
464
 
463
- Production leader admission composes routing's narrow historical classifier; follower custody never enters this classifier. It selects supported private plain-text startup inputs with no currently live bound Thread (also protecting them when Threaded Mode is unavailable). It does not infer deletion or repair bindings. Eligible raw unsupported commands/media/unknown historical private-Thread originals without receipt/claim/provenance authority receive a distinct `retain` verdict regardless of whether their target is bound or Restore/temporary membership survives. This operator-approved policy stops ordinary bound startup dispatch for that subset; same-kind live arrivals stay outside the startup census and dispatch normally. Business, forwarded and known owner-bearing sources keep their existing owner paths. Holding is evidence protection, not sender authorization; it exposes no historical-review UI. Missing routing authority or unreadable Thread state fails closed. The domain-owned `shouldReviewHistoricalInput` classifier receives detached v1 pending evidence, the context and abort signal before any companion/default handler runs. Generation, context, journal binding, transport and exact source evidence are rechecked across its await; classification/read failure does not fall through to dispatch. Boolean true selects legacy `historical` review/spending; `retain` installs a separate `retained` claim, also included in `historicalClaimCount`, without a journal mutation or invented cancellation intent. Retain-only sources cannot be cold-spent, recovered through historical retry, generically cancelled, expired through an old routing clock or claimed by grouped queue outcomes. Each classifier receives detached evidence and its verdict is source-rechecked before installation. Each new generation must classify them again. Routing can also report a last-boundary historical hold if ownership changes after early classification: this suspends the carrier, refuses prior selection/queue reports, and cannot be requested retroactively after the handler returns. Its source-kind predicate receives detached original evidence through the current worker capability; a handler projection that strips forwarding/media metadata cannot manufacture eligibility. Predicate calls and their results remain generation-fenced. The native update-plan boundary recognizes only that exact carrier's acknowledged terminal hold; intentional suspension must not become a retryable execution error.
465
+ Production leader admission composes routing's narrow historical classifier; follower custody never enters this classifier. It selects supported private plain-text startup inputs with no currently live bound Thread (also protecting them when Threaded Mode is unavailable). It does not infer deletion or repair bindings. Eligible raw unsupported commands/media/unknown historical private-Thread originals without receipt/claim/provenance authority receive a distinct `retain` verdict regardless of whether their target is bound or Restore/temporary membership survives. This operator-approved policy stops ordinary bound startup dispatch for that subset; same-kind live arrivals stay outside the startup census and dispatch normally. Business, forwarded and known owner-bearing sources keep their existing owner paths. Holding is evidence protection, not sender authorization; it exposes no historical-review UI. Missing routing authority or unreadable Thread state fails closed. The domain-owned `shouldReviewHistoricalInput` classifier receives detached v1 pending evidence, the context and abort signal before any companion/default handler runs. Generation, context, journal binding, transport and exact source evidence are rechecked across its await; classification/read failure does not fall through to dispatch. Boolean true selects legacy `historical` review/spending; `retain` installs a separate `retained` claim, also included in `historicalClaimCount`, without a journal mutation or invented cancellation intent. Retain-only sources cannot be cold-spent, recovered through historical retry, generically cancelled or claimed by grouped queue outcomes. An exact acknowledged chooser clock has its own operator-approved expiry policy below; no clock is inferred from age alone. Each classifier receives detached evidence and its verdict is source-rechecked before installation. Each new generation must classify them again. Routing can also report a last-boundary historical hold if ownership changes after early classification: this suspends the carrier, refuses prior selection/queue reports, and cannot be requested retroactively after the handler returns. Its source-kind predicate receives detached original evidence through the current worker capability; a handler projection that strips forwarding/media metadata cannot manufacture eligibility. Predicate calls and their results remain generation-fenced. The native update-plan boundary recognizes only that exact carrier's acknowledged terminal hold; intentional suspension must not become a retryable execution error.
464
466
 
465
- `inspectTelegramHistoricalInputs` remains a package-private bounded worker capability requiring a fresh current carrier and caller authority; it scans neither archive directories nor journal files. Ordinary abandonment still rejects historical sources, and late or grouped queue outcomes cannot steal their claims. The Historical inputs main-menu entry and historical review/Stop retrying submenu are removed. The reserved `reroutecancel:history:` prefix consumes old controls with an unavailable answer, never forwarding them to companion handlers or Pi, reopening a view, acquiring mutation admission, or abandoning a source. Held originals remain unchanged; this UI removal adds no expiry, retention intent or Thread deletion. Existing interrupted private-retention attempts still use the separate Pending cancellations review, which warns that previously accepted work may continue. Native worker and cold-router tests cover retired list, selection, confirmation, paging and refresh controls. Cross-domain IPC fixtures lose an ACK after recipient admission and prove that removal leaves uncertain source evidence and independently accepted recipient work intact. Protected-attempt recovery is tested with a supplied interrupted-retention precondition, not a retired UI grant. A production-root fixture proves that the normal menu omits Historical inputs while retained Restore originals remain held. Newly confirmed eligible prompt choosers now use the source-only lifetime described below; historical evidence never acquires a fabricated deadline. Elapsed time never proves non-delivery or authorizes Thread deletion. The operator accepted this behavior in the operator's 0.52.0 live smoke.
467
+ `inspectTelegramHistoricalInputs` remains a package-private bounded worker capability requiring a fresh current carrier and caller authority; it scans neither archive directories nor journal files. Ordinary abandonment still rejects historical sources, and late or grouped queue outcomes cannot steal their claims. The Historical inputs main-menu entry and historical review/Stop retrying submenu are removed. The reserved `reroutecancel:history:` prefix consumes old controls with an unavailable answer, never forwarding them to companion handlers or Pi, reopening a view, acquiring mutation admission, or abandoning a source. Held originals remain unchanged; this UI removal adds no expiry, retention intent or Thread deletion. Existing interrupted private-retention attempts still use the separate Pending cancellations review, which warns that previously accepted work may continue. Native worker and cold-router tests cover retired list, selection, confirmation, paging and refresh controls. Cross-domain IPC fixtures lose an ACK after recipient admission and prove that removal leaves uncertain source evidence and independently accepted recipient work intact. Protected-attempt recovery is tested with a supplied interrupted-retention precondition, not a retired UI grant. A production-root fixture proves that the normal menu omits Historical inputs while retained Restore originals remain held. Newly confirmed eligible prompt choosers now use the source-only lifetime described below; historical evidence never acquires a fabricated deadline. Elapsed time never proves non-delivery; the explicit chooser expiry policy below may end donor attempts without that claim and separately license fully resolved disposable-tab cleanup. The operator accepted this behavior in the operator's 0.52.0 live smoke.
466
468
 
467
- New private-owner prompt choosers, including grouped sources, arm a source-only 60-minute lifetime after positive publication acknowledgement. The worker captures that clock before storage contention; `journal.routingInputs.arm` atomically retains each exact v1 source's operator, `publishedAtMs`, fixed `expiresAtMs` and `waiting` phase. Refresh and restart cannot renew it. The root also uses this capability for supported acknowledged command choosers, including Telegram-provided unbound tabs and source-bound temporary tabs under the owned leader epoch; unsupported compatibility carriers retain their legacy behavior. Actual destination validation precedes `routingInputs.select` under the existing Workspace admission; the complete source cohort becomes `selected` atomically before dispatch or Restore effects. Menu browsing, invalid targets and refused Restore capability leave `waiting` intact. Source/epoch/operator/context/binding/process/session fences and post-publication readback remain mandatory; uncertain clock or selection ACK refuses issuance rather than bypassing the deadline. Selected metadata prevents cold redispatch and automatic expiry, including unknown accepted work. A failed clock publication holds the source without automatic handler retry or a made-up replacement clock; reconstruction can use only positively retained metadata.
469
+ Acknowledged private-owner routing choosers, including commands and grouped prompts, own one fixed 60-minute lifetime from their first positive publication. `journal.routingInputs.arm` retains each exact v1 source's operator, `publishedAtMs`, `expiresAtMs` and `waiting` phase; refresh/restart never renews it. Destination validation precedes atomic `select` under Workspace admission. Selection still prevents another issuance, but does not extend the donor deadline: an unclaimed selected source with an unknown delivery outcome also expires. Queue admission carries the selected clock into the queued entry; queued/running recipient work is outside donor expiry. Unsupported carriers, unconfirmed clocks and historical originals without lifetime metadata acquire no invented deadline.
468
470
 
469
- The worker schedules one generation-fenced deadline wake, also checking retained lifetimes during normal drain and restart. Routing expiry reacquires profile-wide Workspace admission, checks current source/operator/context/leader/binding authority and refuses retained Restore or live selection/cleanup work. Only exact `waiting` sources can use the existing `abandonPending` private-copy/tombstone transaction; readback and positive disposition precede claim/control retirement, never a task-completion observer. A failed retention attempt stays protected; unsuccessful deadline wakes schedule a one-minute retry, and normal drains also recheck it. Lost removal replies reconcile the exact held original against the existing private copy and committed tombstone; absence alone cannot release it. UI edits are best-effort after ACK. No tab visibility probe, Thread deletion, target retirement, Pi stop or reset of an issued Restore grant occurs. Deadline expiry works even if the chooser/tab was manually removed; optional reliable disappearance detection is not implemented. Historical, unconfirmed, selected, queued, running and unknown issued sources remain outside automatic expiry. All peers and successor writers must understand the new v1 entry metadata before candidate reload. Native journal/producer/worker fixtures cover grouped sources, late selection, expiry boundary, restart, stale wakes/buttons, missing views, retention/clock/selection ACK faults and changed authority; they do not prove live operator acceptance.
471
+ The operator-approved optimistic chooser policy accepts nondelivery after the fixed deadline. Expiry reacquires profile-wide Workspace admission and exact owner/context/leader/source authority, then CAS-removes the unclaimed donor pending source and writes a body-free legacy-compatible discard tombstone. It creates no private prompt archive, execution completion or recipient cancellation. Exact tombstone readback reconciles lost ACKs; changed/queued/owner-bearing sources refuse. The originating carrier loses authority, old controls are retired, and late reports cannot revive the input or block unrelated work. A lost selection ACK may reconcile only the waiting-to-selected phase of an otherwise identical source, never grant another send. Clock-bearing historical sources survive cold spending until the same deadline; positively unbound temporary frames associated with them survive new-world forgetting. The worker retries only unconfirmed source disposition. A positive expiry ACK immediately drops its deferred source and claim, before observer/UI awaits. Failed cleanup metadata is retried once per minute by the existing one-timer-per-tab scheduler, reconstructing known groups from body-free journal evidence rather than retaining prompt bodies. Future metadata retries do not join settlement waits; only running attempts do. Same-instance session replacement may reprepare unissued expiry frames under fresh authority without resurrecting a source. Missing/unknown grant state or an issued destructive cleanup forbids retry.
470
472
 
471
473
  With live bindings or a retained temporary entry, a known command or a fresh private plain-text prompt from All first enters the shared source-bound temporary-Thread path under current owner/profile/journal/context/leader authority and profile-wide Workspace admission. Threads atomically reserves a source/token intent before one `createForumTopic` attempt and records only its exact acknowledged target. The tab owns no Pi binding or A–Z slot; its presentation target is separate from the original All source. A creation error, missing target or lost publication result leaves unknown custody held, never a second creation attempt. A `created` entry can republish its chooser while its source is still pending after restart; a `creating` entry supplies no creation or dispatch grant. The acknowledged tab offers the shared Forward/Restore chooser and Cancel only when the current source has fresh private-abandonment capability. Publication defers rather than completes the original command; selection retains command semantics, and no current Pi is chosen automatically. Commands already completed by the compatibility path are not reconstructed. Unpresented expired commands keep the existing command-expiry fence; a confirmed retained tab is not evidence of expiry. Fresh threadless plain-text prompts use `New chat` with the same source-bound routing menu; fallback guidance remains when exact temporary-creation authority is unavailable. Recognized commands include built-in handlers (not only bot-menu entries), registered Telegram extension commands regardless of menu visibility, and discovered prompt-template commands. Bound-Thread and classic routing are unchanged. Local candidate composition is default with the required stores and owned epoch; there is no temporary-tab feature flag. Actual Pi/Telegram behavior was accepted in the operator's 0.52.0 live smoke; no owner-visible uncertainty recovery is scheduled.
472
474
 
@@ -474,7 +476,7 @@ With live bindings or a retained temporary entry, a known command or a fresh pri
474
476
 
475
477
  Temporary command tabs use the command itself as their title (for example `/start` or `/status`), without a visible internal token; fresh plain-text prompt tabs use `New chat`. Both use the same mode chooser and differ only in their title and introductory copy. A command delivered with a Telegram-provided unbound private target uses the same chooser and fresh exact-source Cancel capability; its first chooser renames that target to the command without creating another tab. A received target or successful rename alone never manufactures temporary authority. A fresh owner-authenticated private `forum_topic_created` with literal `is_name_implicit: true` supplies native creation evidence: routing retains a bounded process-local observation tied to the exact context, session generation, admission scope, operator, journal binding and executor. A later live single-source input in that exact target registers it through Threads `registerImplicitTemporaryThread` under profile admission, without calling or fabricating `createForumTopic`; the publication CAS refuses any binding, owner record, reservation, provisioning, cleanup or Restore conflict. Normal grouped membership, one-shot Forward, source disposition, last-Cancel quiet period and cleanup issuance then apply identically. Historical creation signals, context/epoch changes, manual names, string flags, foreign creators and missing observations grant nothing. The observation is consumed on registration, never reconstructed from a title or restart. Existing bindings/reservations and acknowledged temporary targets retain their protections. Their root chooser offers `Reroute…`, `Restore…`, and eligible `Cancel routing`; each mode opens its own live-target submenu without dispatching the held input. Submenus place `⬆️ Back` first to return to the mode chooser with its exact original description, retained on that pending chooser; navigation never substitutes or recomputes the root copy. Cancel appears only at the root. After cancellation, the 1-second quiet-period cleanup remains proof-gated. Private-chat temporary tabs skip the unsupported `closeForumTopic` call and issue only one `deleteForumTopic` attempt; literal positive deletion acknowledgement is still required, and uncertainty never authorizes a retry.
476
478
 
477
- This multi-input lifecycle is implemented in the local candidate, not authority for live deletion. It supersedes unconditional per-chooser tab removal. The reject-only guard prevents cleanup/retirement while another known same-tab chooser remains, including unresolved cleanup-only work. `temporaryThreads.inputs` now records bounded append-only journal source groups through the unbound producer before chooser publication; duplicates are read-only, partial/foreign/overlapping groups refuse, and faults retain the source without a new chooser. Cold reads preserve this protection without process-local chooser memory. A group has no readiness or disposition flag, and an absent legacy field does not prove complete coverage. `cancelledInputs` now records exact whole known groups only after strict same-journal/operator abandonment inspection confirms committed tombstones plus matching private originals. Store publication and returned/retained acknowledgement recheck that proof and operator/executor authority; a lost reply reconciles the exact fact read-only, while missing/foreign/corrupt proof stays protective. The producer composes this into explicit Cancel before temporary cleanup or chooser retirement. Cancelled groups cannot Restore; independent sources remain untouched. More than one known group still blocks cleanup and raw retirement, including all-cancelled membership: these facts grant no deletion. Each successful Cancel retires only its own chooser. If durable membership is fully resolved by exact cancellation/completion facts and no chooser remains, a process-local quiet period starts (1 s; new input in the tab cancels it). Its single attempt, under profile admission, rechecks authority, whole-group tombstone plus retention proof, exact entry CAS, an empty strict journal census (any retained update naming the Thread, any state), absence of choosers and the normal reconciler protections. `issueTemporaryThreadCleanup` publishes canonical `cleanupIssued: true` only for an exact fully resolved entry with strict unbound canonical evidence checked inside the publication transaction; the normal target/source guards precede issuance and run again at API boundaries. The reconciler requires that exact retained issued frame and limits its plan to this tab; unrelated expired-provision cleanup never borrows the grant. Close/delete calls disable retries and transport fallback and require a positive acknowledgement. Confirmed current deletion alone permits entry retirement; skip, negative/lost reply, authority loss or post-rename publication interruption retains the marker. Cold reads classify already-issued entries as unknown, new membership refuses, and executor adoption never renews the grant. Missing evidence only refuses; an unpublished grant issues no API call and may still receive its first grant. The quiet period remains process-local, but issued uncertainty survives restart. The census cannot see updates in transit before journal append. Restore from a multi-input tab is allowed only when each other known group is durably cancelled, Forward-completed or a live unselected chooser that supports private abandonment. A previously completed Forward never supplies a new selection or cancellation grant. After positive settlement of every selected source, such siblings are abandoned with cancellation facts and their controls edited to cancelled, then `retireTemporaryThread(expected, authority, completed)` releases the entry (one newly completed selected group, all others durably cancelled or previously Forward-completed). Missing authority/proof or an unproven Restore leaves siblings pending and protected. On startup, a retained journal input recorded in a created temporary tab whose target a Workspace binding now owns is held as historical (never cancelled, discarded or routed). Live pending sources and due retry-wait sources (including startup retries) use the same fresh membership/binding predicate through `shouldHoldPendingInput` before execution, with source/context/binding/transport checks after classification awaits. The hold grants neither cancellation nor delivery and does not block unrelated inputs. Recipient custody never uses this direct-source classifier. A native production witness admits a recorded command after the worker has already processed an unrelated command, proving it cannot fall through to ordinary bound dispatch. A production retry witness keeps recorded failure metadata intact without command execution, cancellation or deletion. Claimed retries cannot schedule repeated due-time wakes; independent due and future retries remain executable. Real-IPC Forward/Restore sibling witnesses now include recipient journal drain through the real worker and follower router: only the selected message executes, source disposal is observed, and repeated controls do not replay the command. Existing message-ownership composition keeps a locally published chooser's callbacks at its leader publisher after follower rebinding; the native fixture now records publication ownership rather than relying only on target ownership. Four native delivery-reply-loss witnesses cover both routing actions (Forward/Restore), before recipient execution and after worker disposal: the leader source remains unresolved without ACK, independent sibling Cancel preserves the selected group/tab, and a late reply or repeated issued Restore click cannot settle/replay it. Explicit Forward retry after a lost ACK reproduced command duplication when the recipient worker had already disposed its journal record; stable delivery identity alone was not terminal deduplication. The follower's bounded process-local delivery window now acknowledges such a retry without re-execution while it retains that delivery. Ordinary Forward from a temporary tab first publishes a durable per-group `forwardedInputs` issuance fact in the canonical Threads owner under current authority, before either local command handling/prompt preparation/queue admission or follower RPC. Only positively unpublished issuance may receive its first grant; a published fact (even if its reply is ambiguous) is never retried, including after reload, restart or leader replacement. Local repeated selection refuses the retained grant before handler or queue re-entry. Captured All-tab provenance and acknowledged typed-input membership remain protective if the metadata reader disappears; unavailable evidence never falls back to unguarded generic dispatch. Restore retains its separate canonical routing grant. Native local fixtures interrupt source completion before/after commit, prompt preparation and issuance publication/authority; issued sources stay nonterminal, independent siblings remain cancellable and cold restart never manufactures completion or another attempt. A process-local `foreignForwardIssued` mirror gates the live chooser. An issued group is refused by repeat Forward, Cancel, expiry and Restore (the store also rejects them), keeps sibling accounting protective and is resolved only by a positive `completedInputs` fact; independent siblings remain operable and a restarted chooser cannot resend. Issuance is not proof of delivery, and recipient-side deduplication is limited to the bounded process-local delivery window; an operator-visible exit for a permanently unknown issued group, generic journal forwarding and non-temporary reroute retries are unchanged and not scheduled. Canonical Restore keeps its existing durable grants. Mixed native witnesses prove that a completed Forward allows Restore of another group without replay/recancellation, while an unknown Forward prevents sibling Restore before an intent is created. Prompt Restore leaves a command sibling pending at queue admission, then privately cancels it only after exact scoped worker receipt disposition. Independently Forwarded queued input is neither unassigned nor cancellation-capable: either receipt may dispose first, the other retains custody, and only both positive dispositions release temporary membership while keeping the bound tab/slot. An ordinary queued ACK emits the existing source-completion hint only after whole acknowledged removal and fresh context, owner, generation and originating journal-binding checks; it creates no immutable Restore marker. A published independent Forward group fact wakes pure inspection of already settled Restore only after its Workspace admission releases. The store refuses both Forward facts and cancellation for any source overlapping a retained Restore grant, regardless of target; malformed partial selected groups stay protective. Native ordinary prompt Forward plus sibling Cancel also delays deletion until receipt disposition and fresh all-resolved cleanup. Pi handoff is supplied by the fixture, not observed model/task completion. The retained All-command fixture registers 84 scenarios across creation, Forward, Cancel, cleanup/membership and Restore, including full slots, publication faults, sibling protection, native Unix IPC and recipient worker drain through the real non-reentrant Workspace gate. Full-slot follower cases hold a real Unix response before apply, after apply, and after read-only inspection until client timeout. Fresh same-session registration/context generations reconcile only through inspection; late replies never publish readiness, repeat apply or dispatch, cancel siblings, release independent queued recipient work or delete the tab. Successful continuation retains exact scoped source-disposition proof. A separate cold-successor witness loads fresh stores and a journal binding after an issued command lost acceptance authority, then starts a fresh context/generation/epoch under supplied same-session owner publication. Original command and sibling stay pending, spent routing/canonical facts remain unchanged, and stale Restore/Forward/Cancel controls cannot replay or dispose them. No chooser is reconstructed: the protective hold is proven, not a usable cold recovery surface. Pi effects and startup registration remain supplied adapters/preconditions; actual Pi startup and live behavior were accepted in the operator's 0.52.0 live smoke. Forward completes only its own input: no Forward removes the tab, the worker's completion ACK records a durable `completedInputs` group (process-local accumulation across the group's sources), and when every known group is cancelled or completed the delayed, fully rechecked cleanup removes the tab once. Cancelled and completed groups are disjoint, cannot Restore, and a completed Forward queued prompt holds the tab until its receipt completes. The tab chooser discloses removal only when no other input remains; a failed removal no longer edits the original chooser. Source-only prompt Cancel remains unchanged. The candidate's temporary-tab chooser is the default under the owned leader epoch, not a configurable feature flag. Upgrade all prospective canonical readers/writers before live activation; the metadata (including `forwardProtocol`, `forwardedInputs` and `cleanupIssued`) is not downgrade-safe through older snapshot writers, which could drop issuance facts.
479
+ This multi-input lifecycle is implemented in the local candidate, not authority for live deletion. It supersedes unconditional per-chooser tab removal. The reject-only guard prevents cleanup/retirement while another known same-tab chooser remains, including unresolved cleanup-only work. `temporaryThreads.inputs` now records bounded append-only journal source groups through the unbound producer before chooser publication; duplicates are read-only, partial/foreign/overlapping groups refuse, and faults retain the source without a new chooser. Cold reads preserve this protection without process-local chooser memory. A group has no readiness or disposition flag, and an absent legacy field does not prove complete coverage. `cancelledInputs` records exact whole known donor groups after either same-journal/operator retained-cancellation evidence or the separate body-free expiry tombstone. Expiry recording may clear a group's unknown Forward issuance or its expired Restore intent, but never mutates a canonical binding, recipient queue or immutable scoped settlement proof. If Restore already bound the tab, expiry releases the temporary frame without deletion. Otherwise fully resolved groups enter the existing one-second quiet-period cleanup. A partially acknowledged known cohort may finish donor disposition when every member is absent from that exact source journal and at least one member has same-operator expiry proof; this is not recipient completion or non-delivery proof. Fresh strict namespace/protection evidence still gates deletion; independent work and bound Threads remain protected. Store publication and returned/retained acknowledgement recheck that proof and operator/executor authority; a lost reply reconciles the exact fact read-only, while missing/foreign/corrupt proof stays protective. The producer composes this into explicit Cancel before temporary cleanup or chooser retirement. Cancelled groups cannot Restore; independent sources remain untouched. More than one known group still blocks cleanup and raw retirement, including all-cancelled membership: these facts grant no deletion. Each successful Cancel retires only its own chooser. If durable membership is fully resolved by exact cancellation/completion facts and no chooser remains, a process-local quiet period starts (1 s; new input in the tab cancels it). Its single attempt, under profile admission, rechecks authority, whole-group cancellation/expiry evidence, exact entry CAS, an empty strict journal census (any retained update naming the Thread, any state), absence of choosers and the normal reconciler protections. `issueTemporaryThreadCleanup` publishes canonical `cleanupIssued: true` only for an exact fully resolved entry with strict unbound canonical evidence checked inside the publication transaction; the normal target/source guards precede issuance and run again at API boundaries. The reconciler requires that exact retained issued frame and limits its plan to this tab; unrelated expired-provision cleanup never borrows the grant. Close/delete calls disable retries and transport fallback and require a positive acknowledgement. Confirmed current deletion alone permits entry retirement; skip, negative/lost reply, authority loss or post-rename publication interruption retains the marker. Cold reads classify already-issued entries as unknown, new membership refuses, and executor adoption never renews the grant. Missing evidence only refuses; an unpublished grant issues no API call and may still receive its first grant. The quiet period remains process-local, but issued uncertainty survives restart. The census cannot see updates in transit before journal append. Restore from a multi-input tab is allowed only when each other known group is durably cancelled, Forward-completed or a live unselected chooser that supports private abandonment. A previously completed Forward never supplies a new selection or cancellation grant. After positive settlement of every selected source, such siblings are abandoned with cancellation facts and their controls edited to cancelled, then `retireTemporaryThread(expected, authority, completed)` releases the entry (one newly completed selected group, all others durably cancelled or previously Forward-completed). Missing authority/proof or an unproven Restore leaves siblings pending and protected. On startup, a retained journal input recorded in a created temporary tab whose target a Workspace binding now owns is held as historical, never rerouted; an exact saved chooser clock may expire its unclaimed donor attempt without affecting the binding. Live pending sources and due retry-wait sources (including startup retries) use the same fresh membership/binding predicate through `shouldHoldPendingInput` before execution, with source/context/binding/transport checks after classification awaits. The hold grants neither cancellation nor delivery and does not block unrelated inputs. Recipient custody never uses this direct-source classifier. A native production witness admits a recorded command after the worker has already processed an unrelated command, proving it cannot fall through to ordinary bound dispatch. A production retry witness keeps recorded failure metadata intact without command execution, cancellation or deletion. Claimed retries cannot schedule repeated due-time wakes; independent due and future retries remain executable. Real-IPC Forward/Restore sibling witnesses now include recipient journal drain through the real worker and follower router: only the selected message executes, source disposal is observed, and repeated controls do not replay the command. Existing message-ownership composition keeps a locally published chooser's callbacks at its leader publisher after follower rebinding; the native fixture now records publication ownership rather than relying only on target ownership. Four native delivery-reply-loss witnesses cover both routing actions (Forward/Restore), before recipient execution and after worker disposal: the leader source remains unresolved without ACK, independent sibling Cancel preserves the selected group/tab, and a late reply or repeated issued Restore click cannot settle/replay it. Explicit Forward retry after a lost ACK reproduced command duplication when the recipient worker had already disposed its journal record; stable delivery identity alone was not terminal deduplication. The follower's bounded process-local delivery window now acknowledges such a retry without re-execution while it retains that delivery. Ordinary Forward from a temporary tab first publishes a durable per-group `forwardedInputs` issuance fact in the canonical Threads owner under current authority, before either local command handling/prompt preparation/queue admission or follower RPC. Only positively unpublished issuance may receive its first grant; a published fact (even if its reply is ambiguous) is never retried, including after reload, restart or leader replacement. Local repeated selection refuses the retained grant before handler or queue re-entry. Captured All-tab provenance and acknowledged typed-input membership remain protective if the metadata reader disappears; unavailable evidence never falls back to unguarded generic dispatch. Restore retains its separate canonical routing grant. Native local fixtures interrupt source completion before/after commit, prompt preparation and issuance publication/authority; issued sources stay nonterminal, independent siblings remain cancellable and cold restart never manufactures completion or another attempt. A process-local `foreignForwardIssued` mirror gates the live chooser. An issued group is refused by repeat Forward, explicit Cancel and Restore (the store also rejects them); before its fixed chooser deadline it keeps sibling accounting protective, while body-free donor expiry or positive `completedInputs` may resolve it; independent siblings remain operable and a restarted chooser cannot resend. Issuance is not proof of delivery, and recipient-side deduplication is limited to the bounded process-local delivery window; the chooser deadline supplies a loss-accepting exit for unclaimed unknown issued groups; generic journal forwarding and non-chooser retry policy are unchanged. Canonical Restore keeps its existing durable grants. Mixed native witnesses prove that a completed Forward allows Restore of another group without replay/recancellation, while an unknown Forward prevents sibling Restore before an intent is created. Prompt Restore leaves a command sibling pending at queue admission, then privately cancels it only after exact scoped worker receipt disposition. Independently Forwarded queued input is neither unassigned nor cancellation-capable: either receipt may dispose first, the other retains custody, and only both positive dispositions release temporary membership while keeping the bound tab/slot. An ordinary queued ACK emits the existing source-completion hint only after whole acknowledged removal and fresh context, owner, generation and originating journal-binding checks; it creates no immutable Restore marker. A published independent Forward group fact wakes pure inspection of already settled Restore only after its Workspace admission releases. The store refuses both Forward facts and cancellation for any source overlapping a retained Restore grant, regardless of target; malformed partial selected groups stay protective. Native ordinary prompt Forward plus sibling Cancel also delays deletion until receipt disposition and fresh all-resolved cleanup. Pi handoff is supplied by the fixture, not observed model/task completion. The retained All-command fixture registers 84 scenarios across creation, Forward, Cancel, cleanup/membership and Restore, including full slots, publication faults, sibling protection, native Unix IPC and recipient worker drain through the real non-reentrant Workspace gate. Full-slot follower cases hold a real Unix response before apply, after apply, and after read-only inspection until client timeout. Fresh same-session registration/context generations reconcile only through inspection; late replies never publish readiness, repeat apply or dispatch, cancel siblings, release independent queued recipient work or delete the tab. Successful continuation retains exact scoped source-disposition proof. A separate cold-successor witness loads fresh stores and a journal binding after an issued command lost acceptance authority, then starts a fresh context/generation/epoch under supplied same-session owner publication. Original command and sibling stay pending, spent routing/canonical facts remain unchanged, and stale Restore/Forward/Cancel controls cannot replay or dispose them. No chooser is reconstructed: the protective hold is proven, not a usable cold recovery surface. Pi effects and startup registration remain supplied adapters/preconditions; actual Pi startup and live behavior were accepted in the operator's 0.52.0 live smoke. Forward completes only its own input: no Forward removes the tab, the worker's completion ACK records a durable `completedInputs` group (process-local accumulation across the group's sources), and when every known group is cancelled or completed the delayed, fully rechecked cleanup removes the tab once. Cancelled and completed groups are disjoint, cannot Restore, and a completed Forward queued prompt holds the tab until its receipt completes. The tab chooser discloses removal only when no other input remains; a failed removal no longer edits the original chooser. Source-only prompt Cancel remains unchanged. The candidate's temporary-tab chooser is the default under the owned leader epoch, not a configurable feature flag. Upgrade all prospective canonical readers/writers before live activation; the metadata (including `forwardProtocol`, `forwardedInputs` and `cleanupIssued`) is not downgrade-safe through older snapshot writers, which could drop issuance facts.
478
480
 
479
481
  - `Membership`: A positively source-linked disposable temporary Thread owns no Pi binding or A–Z slot and may contain multiple independent unassigned inputs, each with its own routing controls. Track exact journal source/group membership, including additional inputs, rather than treating the first chooser or a process-local count as the whole Thread. Serialize admission, selection, cancellation, Restore and cleanup through the existing Workspace admission and mutation owners; unreadable, incomplete, selected, accepted, running or unknown-issued evidence blocks deletion.
480
482
  - `Per-input Cancel`: Privately retain the exact original and positively commit source-only cancellation before acknowledging `Routing cancelled` and retiring its route controls. Other unassigned inputs retain their choosers and custody; cancelling one input neither delivers nor cancels siblings. No separate Cancel-and-delete action or extra confirmation dialog is required, but the chooser must disclose the conditional whole-tab deletion consequence.
@@ -521,6 +523,8 @@ All inbound updates are gated by the configured authorized user id.
521
523
  8. Remove local prompt journal authority synchronously immediately before `sendUserMessage`, so session/process replacement can lose an unstarted prompt at that narrow crash boundary but can never replay a prompt already admitted to Pi; controls and foreign forwarding retain their explicit settlement boundaries.
522
524
  9. Handle `edited_message` updates separately while the original turn is still queued and dispatch only when all safety gates are clear.
523
525
 
526
+ Attachments stream into private unique `.part` files and publish by rename only after complete, size-checked download. Windows `EPERM`/`EACCES` sharing failures allow at most six rename attempts, with abortable `50/100/200/400/800 ms` waits; no metadata/content HTTP request is replayed. Non-Windows, non-sharing and exhausted errors propagate, cancellation stops retries, and failure removes the partial file without deleting the existing target. This is attachment publication policy, not a journal/state recovery rule or strict Windows evidence.
527
+
524
528
  #### Durable Admission And Recovery
525
529
 
526
530
  Here, **durable** means recovery across ordinary process exit, crash, kill, and replacement after a successful atomic rename is visible to the filesystem. It does not promise survival across host, kernel, filesystem, storage-device, or power failure: journal and offset publication do not call `fsync`/`fdatasync`, and parent directories are not flushed. A host-level failure may therefore lose a recently acknowledged rename despite correct process-level ordering. Operators requiring that stronger boundary must place the agent directory on storage with an independently managed durability/backup policy; `0.28.0` must not be described as power-loss durable.
@@ -583,7 +587,7 @@ Dispatch rank:
583
587
 
584
588
  #### Priority, Reactions, Keep, and Skip
585
589
 
586
- Waiting prompts expose two independent dimensions:
590
+ Waiting Normal/Priority prompts expose two independent dimensions:
587
591
 
588
592
  - `Priority` / `Normal` selects the FIFO lane and therefore scheduling order.
589
593
  - `Keep` / `Skip` selects whether the prompt executes when dispatch reaches it.
@@ -598,6 +602,8 @@ Menu and reaction controls share the same canonical queue state. A menu Keep can
598
602
 
599
603
  Guest Mode is the deliberate exception to deferred Keep/Skip semantics. A guest prompt has no source message reaction surface and may share zero-valued chat/message placeholders with other guest prompts, so queue-menu callbacks address it by immutable `queueOrder` and expose an immediate `Skip`. The leader settles its durable admission, removes it while other agent work may continue, stops placeholder animation, and replaces the inline globe placeholder with non-printing content. Bot API inline messages have no delete method, so this visual clear is the supported deletion equivalent. The guest prompt never reaches a model turn.
600
604
 
605
+ A standalone `/continue` is a source-addressed control-lane prompt, not a Normal/Priority prompt. An authorized negative reaction to its original chat/message immediately settles the exact waiting continuation's receipts before removing it, even while unrelated work is active. Failed or unknown settlement retains it for the existing reaction retry path; duplicate reactions cannot settle twice. Positive reactions and removal of the negative reaction neither change its control lane nor reconstruct cancelled work. The dispatched head awaiting `agent_start` and already-started work are never cancelled; appends and reorders preserve that Pi-owned head. Synthetic model-switch continuations have no source message IDs and are not reaction-addressable by their fallback reply anchor. Reaction authorization and chat/message namespace isolation remain unchanged.
606
+
601
607
  Admission and planning validate lane contracts. Invalid lane/kind pairings fail predictably instead of being silently coerced.
602
608
 
603
609
  Prompt preparation completes downloads, inbound handlers, and binary image reads before synchronous final assembly and queue commit. Enqueue captures only intended abort-history identities before preparation; afterward it reads the current queue, folds surviving selected prompts using their current text and receipts, allocates the new turn's order, and appends without another asynchronous boundary. The handed-off head remains in place until `agent_start` consumes it, even if preparation finishes first. Concurrent arrivals, removals, and lane/reaction edits remain intact; preparation failure or stale-generation completion leaves intervening queue changes untouched.
@@ -639,7 +645,7 @@ Immediate controls:
639
645
 
640
646
  Queued controls:
641
647
 
642
- - `/continue` creates a priority Telegram-owned `continue` prompt.
648
+ - `/continue` creates a Telegram-owned `continue` prompt in the highest-priority control lane. A negative reaction to the original standalone command cancels it only while waiting, with exact durable settlement as specified above.
643
649
  - Prompt-template commands expand Telegram-safe Pi template aliases before entering the prompt queue.
644
650
  - Model-switch continuation uses the control lane when any interruptible in-flight agent run in the current Pi session must be stopped and resumed. A Telegram-owned run retains its prompt target; otherwise the exact model-menu chat/Thread/message supplies continuation and reply ownership.
645
651
 
package/docs/ui-style.md CHANGED
@@ -247,7 +247,7 @@ Rules:
247
247
  - Keep the emoji and complete sentence or heading inside the single bold span; do not bold only a fragment. A material name or phrase may receive nested italic emphasis without breaking the outer bold hierarchy—for example `<b>📡 Instance <i>Cedar</i> connected.</b>`.
248
248
  - Apply the same hierarchy to success, progress, empty, busy, unavailable, cancellation, and failure notices.
249
249
  - Once an action has settled, describe only the completed result in completed-state language. Do not append transitional copy such as “returning” or “starting”; use a separate progress surface only while work is genuinely still pending.
250
- - Callback alerts remain plain text because Telegram does not support rich text there, but still keep the relevant emoji and concise sentence.
250
+ - Transient callback toasts remain plain text with the relevant emoji and concise wording, without a terminal sentence period. The shared typed callback-answer boundary applies this on direct and follower delivery while preserving question/exclamation marks and ellipses. In-chat notices, headings, blocking alerts and explicit raw API payloads retain their own punctuation contracts.
251
251
  - Setting detail cards may include an emoji in the heading, then a colon and the current value in `<code>`.
252
252
  - Explain what the setting does and what the options mean only as much as needed.
253
253
  - Order setting value descriptions exactly like the chooser: rows top-to-bottom and values in a shared row left-to-right. Keep `(default)` on the actual default wherever it falls; default status never changes order.
package/lib/bindings.ts CHANGED
@@ -116,6 +116,7 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
116
116
  };
117
117
  const mutation = Queue.createTelegramQueueMutationController({
118
118
  ...deps.store,
119
+ hasPendingDispatch: deps.lifecycle.hasDispatchPending,
119
120
  allocateLaneOrder: deps.queue.allocateItemOrder,
120
121
  onItemsDiscarded(items, ctx) {
121
122
  if (!settleDiscardedItems(items, ctx)) {
package/lib/bus-api.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from "./bus.ts";
11
11
  import {
12
12
  buildTelegramAnswerGuestQueryBody,
13
+ formatTelegramCallbackAnswerText,
13
14
  isTelegramMessageNotModifiedError,
14
15
  } from "./telegram-api.ts";
15
16
  import type { TelegramAttachmentSource } from "./media.ts";
@@ -321,15 +322,16 @@ export function createTelegramBusAwareApiRuntime(
321
322
  callbackQueryId: string,
322
323
  text?: string,
323
324
  ): Promise<void> {
325
+ const answer = formatTelegramCallbackAnswerText(text);
324
326
  if (deps.ownsDirect()) {
325
- await deps.directRuntime.answerCallbackQuery(callbackQueryId, text);
327
+ await deps.directRuntime.answerCallbackQuery(callbackQueryId, answer);
326
328
  return;
327
329
  }
328
330
  await deps.callFollowerApi("call", [
329
331
  "answerCallbackQuery",
330
332
  {
331
333
  callback_query_id: callbackQueryId,
332
- ...(text !== undefined ? { text } : {}),
334
+ ...(answer !== undefined ? { text: answer } : {}),
333
335
  },
334
336
  ]);
335
337
  },
package/lib/extension.ts CHANGED
@@ -1146,6 +1146,10 @@ export default function (pi: Pi.ExtensionAPI) {
1146
1146
  return telegramJournalBindingRuntime.inspectSourceAbandonment(journalBindingKey, updateId);
1147
1147
  });
1148
1148
  },
1149
+ inspectRoutingInputGroupExpiry(input) {
1150
+ return telegramJournalReferenceRegistry.withReference({ referenceClass: "operator-disposition", recoveryKey: input.journalBindingKey },
1151
+ function () { return telegramJournalBindingRuntime.inspectSourceGroupExpiry(input.journalBindingKey, input.updateIds); });
1152
+ },
1149
1153
  inspectTemporaryThreadSources(_target, requiredJournalBindingKeys) {
1150
1154
  const binding = resolveTelegramUpdateJournalBinding();
1151
1155
  const botToken = configStore.getBotToken();
@@ -1814,6 +1818,7 @@ export default function (pi: Pi.ExtensionAPI) {
1814
1818
  getSyncState: telegramSyncStateRuntime.getState,
1815
1819
  setSyncState: telegramSyncStateRuntime.setState,
1816
1820
  stopPolling: lockedPollingRuntime.stop,
1821
+ captureStopPolling: lockedPollingRuntime.captureStop,
1817
1822
  suspendPolling: lockedPollingRuntime.suspend,
1818
1823
  recordRuntimeEvent,
1819
1824
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
package/lib/journal.ts CHANGED
@@ -750,11 +750,36 @@ export interface TelegramRoutingInputAuthority {
750
750
  isCurrent(): boolean;
751
751
  }
752
752
 
753
+ /** Expiry drops a donor attempt without retaining its body or claiming recipient execution/cancellation. */
754
+ export type TelegramRoutingInputExpiryResult = Omit<TelegramUpdateJournalPendingAbandonmentResult, "retainedPath">;
755
+ export type TelegramRoutingInputExpiryEvidence = Omit<TelegramUpdateJournalAbandonedPendingEvidence, "retainedPath">;
756
+
757
+ function inspectRoutingInputExpiry(file: TelegramUpdateJournalFile, journalBindingKey: string, updateId: number): TelegramRoutingInputExpiryEvidence | undefined {
758
+ const disposition = file.operatorDispositions?.find(value => value.updateId === updateId &&
759
+ "dispositionKind" in value && value.dispositionKind === "legacy-custody" && value.action === "discard" &&
760
+ value.failureId === `routing-expiry:${value.evidenceSha256}`);
761
+ if (!disposition || !("operatorAuthorityId" in disposition) || file.entries.some(entry => entry.updateId === updateId)) return undefined;
762
+ return { journalBindingKey, updateId, operatorAuthorityId: disposition.operatorAuthorityId };
763
+ }
764
+
765
+ function inspectRoutingGroupExpiry(file: TelegramUpdateJournalFile, journalBindingKey: string, updateIds: readonly number[]): TelegramRoutingInputExpiryEvidence[] | undefined {
766
+ if (!Array.isArray(updateIds) || !updateIds.length || updateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
767
+ updateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= updateIds[index - 1]!))) return undefined;
768
+ if (file.entries.some(entry => updateIds.includes(entry.updateId))) return undefined;
769
+ const expired = updateIds.map(id => inspectRoutingInputExpiry(file, journalBindingKey, id)).filter(value => value !== undefined);
770
+ if (!expired.length || expired.some(value => value.operatorAuthorityId !== expired[0]!.operatorAuthorityId)) return undefined;
771
+ // Some members may have been acknowledged earlier. Whole donor absence plus one exact expiry ends this known cohort,
772
+ // not recipient custody; fresh Thread protection still independently gates deletion.
773
+ return updateIds.map(updateId => ({ journalBindingKey, updateId, operatorAuthorityId: expired[0]!.operatorAuthorityId }));
774
+ }
775
+
753
776
  /** Package-private v1 capability; raw input custody does not expose it. */
754
777
  export interface TelegramRoutingInputJournal {
755
778
  arm(input: TelegramRoutingInputAuthority & { publishedAtMs: number }): TelegramUpdateJournalEntry[];
756
779
  select(input: TelegramRoutingInputAuthority): { issued: boolean; entries: TelegramUpdateJournalEntry[] };
757
- expire(input: Omit<TelegramRoutingInputAuthority, "entries"> & { entry: TelegramUpdateJournalEntry }): TelegramUpdateJournalPendingAbandonmentResult;
780
+ expire(input: Omit<TelegramRoutingInputAuthority, "entries"> & { entry: TelegramUpdateJournalEntry }): TelegramRoutingInputExpiryResult;
781
+ inspectExpiry(updateId: number): TelegramRoutingInputExpiryEvidence | undefined;
782
+ inspectGroupExpiry(updateIds: readonly number[]): TelegramRoutingInputExpiryEvidence[] | undefined;
758
783
  }
759
784
 
760
785
  export interface TelegramUpdateJournalStore {
@@ -3133,6 +3158,7 @@ export interface TelegramUpdateJournalBindingRuntime {
3133
3158
  getActiveRecoveryKey: () => string | undefined;
3134
3159
  /** Exact historical proof lookup; no store, admission, execution, recovery or mutation port. */
3135
3160
  inspectSourceAbandonment: (journalBindingKey: string, updateId: number) => TelegramUpdateJournalAbandonedPendingEvidence | undefined;
3161
+ inspectSourceGroupExpiry: (journalBindingKey: string, updateIds: readonly number[]) => TelegramRoutingInputExpiryEvidence[] | undefined;
3136
3162
  inspectSourceCompletion: (journalBindingKey: string, expected: TelegramUpdateJournalSourceCompletion) => TelegramUpdateJournalSourceCompletion | undefined;
3137
3163
  inspectQueuedReceipt: (journalBindingKey: string, expected: TelegramUpdateJournalQueuedCompletion) => TelegramUpdateJournalQueuedReceiptEvidence | undefined;
3138
3164
  /** In-process `/new` succession: move unclaimed predecessor pending inputs into the successor session journal. */
@@ -3264,6 +3290,9 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
3264
3290
  return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
3265
3291
  });
3266
3292
  },
3293
+ inspectSourceGroupExpiry(journalBindingKey, updateIds) {
3294
+ return inspectHistoricalSource(journalBindingKey, file => inspectRoutingGroupExpiry(file, journalBindingKey, updateIds));
3295
+ },
3267
3296
  inspectSourceCompletion(journalBindingKey, expected) {
3268
3297
  const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
3269
3298
  return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
@@ -4899,6 +4928,7 @@ function createJournalStoreCore(
4899
4928
  update: entry.update,
4900
4929
  admittedAtMs: entry.admittedAtMs,
4901
4930
  ...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
4931
+ ...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
4902
4932
  state: "queued",
4903
4933
  queueKind: receipt.queueKind,
4904
4934
  queueReceiptId: receipt.receiptId,
@@ -5844,20 +5874,59 @@ function createJournalStoreCore(
5844
5874
  });
5845
5875
  },
5846
5876
  };
5847
- const abandonRoutingPending = journal.abandonPending.bind(journal);
5848
5877
  if (isTelegramUpdateJournalLegacyFamilyVersion(version)) journal.routingInputs = {
5849
5878
  arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
5850
5879
  select(input) { return mutateRoutingInputs(input, true); },
5880
+ inspectExpiry(updateId) {
5881
+ return runMutation(readCurrent => {
5882
+ if (!isSafeNonNegativeInteger(updateId)) throw createJournalError("invalid", path, "invalid routing expiry update ID");
5883
+ return inspectRoutingInputExpiry(readCurrent().file,
5884
+ createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateId);
5885
+ });
5886
+ },
5887
+ inspectGroupExpiry(updateIds) {
5888
+ return runMutation(readCurrent => inspectRoutingGroupExpiry(readCurrent().file,
5889
+ createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateIds));
5890
+ },
5851
5891
  expire(input) {
5852
- const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
5853
- if (!lifetime || lifetime.phase !== "waiting" || lifetime.operatorUserId !== input.operatorUserId ||
5854
- !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
5855
- throw createJournalError("conflict", path, "routing input expiry lacks exact waiting authority");
5856
- }
5857
- const current = () => { const now = getNowMs(); return isSafeNonNegativeInteger(now) && now >= lifetime.expiresAtMs && input.isCurrent(); };
5858
- if (!current()) throw createJournalError("conflict", path, "routing input has not expired under current authority");
5859
- return abandonRoutingPending({ journalBindingKey: input.journalBindingKey, entry: original,
5860
- operatorAuthorityId: `telegram-owner:${input.operatorUserId}`, isCurrent: current });
5892
+ return runMutation(readCurrent => {
5893
+ const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
5894
+ const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
5895
+ if (!lifetime || original.state !== "pending" || original.inputClaim || original.inputProvenance || original.queueOwner ||
5896
+ original.queueReceiptId || original.queueHandoff || original.failure || input.journalBindingKey !== binding ||
5897
+ lifetime.operatorUserId !== input.operatorUserId || !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
5898
+ throw createJournalError("conflict", path, "routing expiry requires an exact unclaimed chooser source");
5899
+ }
5900
+ const assertCurrent = () => {
5901
+ const now = getNowMs();
5902
+ if (!isSafeNonNegativeInteger(now) || now < lifetime.expiresAtMs || !input.isCurrent())
5903
+ throw createJournalError("conflict", path, "routing source has not expired under current authority");
5904
+ };
5905
+ assertCurrent();
5906
+ const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
5907
+ const evidenceSha256 = createHash("sha256").update(JSON.stringify(original)).digest("hex");
5908
+ const failureId = `routing-expiry:${evidenceSha256}`;
5909
+ const existing = current.file.operatorDispositions?.find(value => value.updateId === original.updateId);
5910
+ const entry = current.file.entries.find(value => value.updateId === original.updateId);
5911
+ if (existing) {
5912
+ if (entry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" || existing.action !== "discard" ||
5913
+ existing.failureId !== failureId || existing.evidenceSha256 !== evidenceSha256 || existing.operatorAuthorityId !== `telegram-owner:${input.operatorUserId}`)
5914
+ throw createJournalError("conflict", path, "routing source has contradictory expiry evidence");
5915
+ assertCurrent();
5916
+ return { disposition: existing, duplicate: true, entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
5917
+ }
5918
+ if (!entry || !isDeepStrictEqual(entry, original)) throw createJournalError("conflict", path, "routing source changed before expiry");
5919
+ // A discard tombstone prevents old readers and delayed reports from replaying; no prompt-body archive is written.
5920
+ const now = getNowMs();
5921
+ const disposition: TelegramUpdateJournalLegacyCustodyDisposition = { dispositionKind: "legacy-custody", failureId,
5922
+ updateId: original.updateId, action: "discard", committedAtMs: now, authorizedAtMs: now, evidenceSha256,
5923
+ operatorAuthorityId: `telegram-owner:${input.operatorUserId}` };
5924
+ const published = publishMutation(current, current.file.entries.filter(value => value.updateId !== original.updateId), true,
5925
+ [...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId,
5926
+ (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
5927
+ assertCurrent();
5928
+ return { disposition, duplicate: false, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
5929
+ });
5861
5930
  },
5862
5931
  };
5863
5932
  if (!getInputContext) return { journal };