@llblab/pi-kit 0.35.0 → 0.35.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 (68) hide show
  1. package/BACKLOG.md +2 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +1 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -2
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -6
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +11 -3
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +22 -0
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +75 -16
  12. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +1 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +11 -1
  14. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +30 -19
  15. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
  17. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +61 -46
  18. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +36 -0
  21. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +80 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
  24. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +2 -1
  25. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +18 -3
  26. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +13 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +33 -12
  28. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
  29. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
  30. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
  31. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
  32. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
  33. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
  34. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +11 -0
  35. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +31 -8
  36. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +12 -5
  37. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +21 -3
  38. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +5 -1
  39. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +5 -1
  40. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +26 -13
  41. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +6 -2
  42. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +57 -12
  43. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  44. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
  45. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +6 -2
  46. package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
  47. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +13 -2
  48. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
  49. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +111 -15
  50. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -0
  51. package/node_modules/@llblab/pi-telegram/lib/bus.ts +30 -21
  52. package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
  53. package/node_modules/@llblab/pi-telegram/lib/extension.ts +64 -56
  54. package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
  55. package/node_modules/@llblab/pi-telegram/lib/logging.ts +122 -0
  56. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
  57. package/node_modules/@llblab/pi-telegram/lib/paths.ts +22 -3
  58. package/node_modules/@llblab/pi-telegram/lib/polling.ts +44 -10
  59. package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
  60. package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
  61. package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
  62. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +40 -7
  63. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -10
  64. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +6 -1
  65. package/node_modules/@llblab/pi-telegram/lib/threads.ts +37 -20
  66. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +69 -17
  67. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  68. package/package.json +2 -2
package/BACKLOG.md CHANGED
@@ -4,6 +4,8 @@ Package pins, resource order and bundled runtime ownership remain authoritative
4
4
 
5
5
  ## Carried checks
6
6
 
7
+ - **Installed 0.35.2 Telegram smoke (operator-owned):** After separately authorized kit installation/reload, connect a new Thread with every letter slot occupied and confirm it appears in seconds without the leader stalling or its followers going stale. Repeat the operator's earlier checks for leader failover after `/telegram-disconnect`, follower takeover, and collapsed repeated log records. Packed validation does not certify installed clients.
8
+ - **Installed 0.35.1 Telegram smoke (operator-owned):** After separately authorized kit installation/reload, verify Reroute removes an exhausted temporary tab and Restore keeps the new tab while deleting its unprotected predecessor. Confirm protected work survives. Guest PNG/JPEG, document and MP3 replacement plus staging cleanup remain explicitly deferred upstream live checks; verify unsupported OGG/Opus feedback separately. Upstream idle-leader acceptance and kit packaging checks do not certify this installation.
7
9
  - **Installed 0.35.0 Telegram smoke (operator-owned):** After separately authorized installation/reload of both peers, verify same-process follower session replacement keeps the Thread, letter slot and title without a second connected notice. Check shared `threads.displayMode` through Settings across profiles and confirm the retired profile key is ignored without migration. Package validation does not certify installed clients.
8
10
  - **Installed 0.34.3 typing smoke (operator-owned):** After separately authorized installation/reload of both peers, verify native typing during streaming, UI prompts and compaction without recurring local authorization churn. Check actual client versions, including iOS; successful HTTP issuance and synthetic tests do not prove client visibility. Use disposable sessions and preserve accepted work.
9
11
  - **Installed 0.34.2 smoke (operator-owned):** After separately authorized installation/reload, verify Pi 1.2 custom-message completions no longer raise the Telegram prompt-hook error, adjacent button matrices retain their rows and callbacks, and native Windows Codex CLI fallback works on an actual Windows host. Use disposable settings; package validation and synthetic SDK probes do not certify installed clients or authorize live fault injection.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.35.2: Telegram Connect Latency and Session Identity
6
+
7
+ - `Telegram connect`: Advances only the exact Telegram pin to `0.57.2`. A new Thread now connects in seconds instead of roughly 20 seconds when every letter slot is occupied: rotation no longer re-reads the shared journal namespace once per binding inside one synchronous pass, while post-fence protection checks still read fresh evidence. Other pins, package membership and resource order are unchanged.
8
+ - `Session identity`: A follower connected with `/telegram-connect` becomes leader when the old leader stops answering, instead of retrying a dead socket indefinitely, because authority now follows the session rather than the exact Pi context object. A promoted follower keeps its Thread, slot and name and still defers to a live lower-slot follower.
9
+ - `Log noise`: Expected-and-recovered failures no longer fill the log. Private-chat graceful cleanup deletes the Thread directly, the restore probe records one lifecycle event instead of an API error, bus attempts record only the first and final failure plus recovery, and repeated heartbeat, capability-probe and typing-presence failures collapse into one record per run with a recovery record. Status diagnostics print the paths of the agent directory in use.
10
+
11
+ ## 0.35.1: Telegram Guest Media and Routing Cleanup
12
+
13
+ - `Telegram cleanup`: Advances only the exact Telegram pin to `0.57.1`. Exhausted Reroute tabs and Restore's old tab are no longer blocked by unrelated targetless creation attempts, while accepted work and uncertain deletion stay protected. Fresh local menu Reroute and supported Restore hold command effects through their cleanup decision. Other pins, package membership and resource order are unchanged.
14
+ - `Guest media`: Queued photos, documents and MP3 now replace an acknowledged Guest Mode placeholder instead of being silently skipped. Confirmed failures report errors, uncertain edits are not replayed, and unsupported OGG/Opus replacement is rejected explicitly. Guest media client acceptance remains a deferred upstream follow-up, not proof supplied by kit validation.
15
+
5
16
  ## 0.35.0: Telegram Thread Continuity and Shared Settings
6
17
 
7
18
  - `Telegram continuity`: Advances only the exact Telegram pin to `0.57.0`. Same-process follower session replacement preserves the Thread, letter slot and acknowledged title without a second connected notice; cross-process handoff notices use the acknowledged title. Other pins, package membership and resource order are unchanged.
package/README.md CHANGED
@@ -18,7 +18,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
18
18
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.13.1` | OpenAI/legacy Codex subscription quota and Business credit status, with persistent priority Fast toggle mirrored in Telegram |
19
19
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.9.0` | Visible continuation scheduling and bounded worker Skills through compiled, manifest-owned resources |
20
20
  | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.26.4` | Scoped context/memory compiler with step-independent current memory, stable Passive/Active modes and visible blocked-state status |
21
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.57.0` | Telegram companion with per-prompt Steer, queued controls, Reroute/Restore, recoverable connections, previews, follower Threads, files, and voice |
21
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.57.2` | Telegram companion with per-prompt Steer, queued controls, Reroute/Restore, recoverable connections, previews, follower Threads, files, and voice |
22
22
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
23
23
 
24
24
  Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
@@ -77,9 +77,10 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
77
77
  - Runtime files live under `<agent-dir>/tmp/pi-telegram` (releases before 0.52.0 used `tmp/telegram`, which a new release never writes, migrates or deletes). The older `owners.json` is read-only evidence: a live fresh owner there blocks acquisition so two release generations never poll one bot, and only its identity is exposed, never its bus endpoint or secret. Only `state.json` and `logs.jsonl` are persistent root files: the per-profile `transport` section of `state.json` is the sole transport-owner authority, `workspace` owns canonical Workspace/Restore evidence and `admission` its leases; the `runtime` section and `logs.jsonl` never grant routing authority. Each section changes only through the shared transaction under its own domain authority (nonleader admission remains process/birth-owned, not transport-owned); Workspace/runtime publication captures exact session authority, and acquisition, refresh, release, takeover, and irreversible leader work fence the exact owner/epoch. Ordinary reads and section mutations still refuse a damaged shared envelope without repair. Explicit `/telegram-connect` additionally grants one profile-admitted reset of matching untargeted unfinished Thread-creation metadata before Workspace identity allocation; it may leave an unknown Telegram tab behind, but never deletes a known target, Workspace binding or journal receipt. Automatic startup/re-registration and Restore-only admission cannot grant that reset. Known pending targets retain their exact binding key and slot; fresh provisioning uses unique operation IDs so late recovery evidence cannot attach to a replacement attempt. Follower reset intent uses authenticated `explicit-connect-recovery-v1` registration. Only a non-election leader start (`/telegram-connect` or session auto-start) runs the operator-approved optimistic reset: under the shared transaction guard it replaces damaged envelope/transport/Workspace/admission evidence with an empty envelope, accepting loss of every profile's runtime continuity; filesystem access errors still refuse (see [Damaged-State Reset](./docs/architecture.md#damaged-state-reset-operator-approved)). Use the [session journal contract](./docs/multi-instance-bus.md#session-owned-journal-storage) for owners-named polling continuity and the explicitly approved optimistic session-family sweep; that sweep is not Thread deletion or receipt settlement.
78
78
  - Threaded Mode has exactly one live leader per bot profile. Followers are real operator-started Pi processes and must authenticate/register over local IPC; Telegram never spawns hidden Pi processes. A live but unreachable owner does not authorize split-brain polling.
79
79
  - Local IPC is a trust boundary, not merely a private socket. Unknown, stale, mismatched-generation, or unauthorized requests must not inject prompts, callbacks, API sends, artifacts, liveness, or bindings. Follower registration identity may prepare a binding, but inbound generation authority is published only after successful preparation for the same generation and current context; pending or failed startup cannot append into a retained old journal. Readiness also binds the active Pi context and supplied session generation. Same-session refresh awaits binding preparation without re-registering; a changed session ID cannot publish readiness until its exact leader registration is acknowledged. Reusing a context object cannot carry readiness across a session-generation or ID change. Registration requests capture session authority before asynchronous startup and fence every publication/finalization against the current attempt. Stop or supersession invalidates that attempt; obsolete cleanup cannot erase a newer registration or a refreshed context.
80
+ - Session identity, not Pi context object identity, decides authority. Pi builds a fresh context object for every event, command, and tool call, so compare sessions through their stored generation and ID, never by object, and treat a fresh object of the same session as the same authority. How an instance came online — explicit `/telegram-connect`, session auto-restore, election promotion, or a same-process handoff — may decide only that it is online and which existing binding, slot, and target it takes; every post-online behavior, including election eligibility, promotion, routing, delivery, status, and Thread display, must read the same acknowledged registration state and must not branch on that mechanism. Only the documented connect-time gates (remembered-binding restore admission, restore/recovery envelope flags, capability negotiation, and predecessor handoff) may observe it. An in-flight registration survives a fresh same-session context and is dropped only by a replaced session generation or ID.
80
81
  - Protocol compatibility is independent from package version. Registration negotiates protocol version, runtime build, and canonical capabilities before target provisioning or live publication. `durable-follower-admission-v1` gates source forwarding; `queue-handoff-v1` independently gates semantic queue transfer for every participant and is advertised only with exact source/recipient journal-binding composition. `follower.register`, capability-gated restore-only `follower.restoreWorkspace` and side-effect-free liveness `bus.probe` are bootstrap requests; other requests require exact live-registry generation authority, and `bus.ack` is response-only. `thread-display-mode-v1` gates follower display-setting requests; the leader serializes the shared preference write and owns title application.
81
82
  - Long-lived timers, pollers, watchers, receivers, heartbeats, background delivery, and deferred dispatch are session-bound. Shutdown cancels/drains diagnostics publications and fences deferred status reads before they can acquire guards or repair files. Replacement stops stale activity and makes late work inert; teardown must recheck the captured session generation even when context identity is reused. Same-process handoff may preserve exact profile/target identity but never stale Pi context or cross-profile authority. Follower heartbeat and leader-to-follower Restore/live-rebind control reply deadlines align with the eight-second leader stale-liveness window rather than the generic one-second local-RPC default; an ordinary multi-second Pi/TUI event-loop stall or a slower Windows named pipe is not registration or recipient loss. Participating source observations must hold a reference for the actual read, including pending-mutation/count queries against a stopped worker's retained source; a scoped observation never restores receipt readiness or execution authority. A donor cancellation resumed after remote handoff awaits must also hold a source reference; only the existing exact journal CAS may cancel, never undo accepted recipient custody. Stable source keys do not certify captured callable lifetimes: snapshot prepared worker capabilities at construction and replace them on source-handle renewal without replaying unsettled input. Aborting a durable update generation does not release that `update_id`: replacement replay waits for its actual handler settlement, and effectful handlers use the shared execution fence immediately before commit and after awaited delegation. Admission also rechecks that fence after the default handler returns, before its outcome can settle custody; a stopped handler's ordinary return is not completion authority. Internal clones explicitly carry the hidden fence; reroute forwarding, thread-store mutation, cleanup, and Bot API boundaries retain the originating generation.
82
- - Runtime state is event-driven reconciliation of local assumptions against Telegram signals, not a complete bot read-model and not permission to query Telegram on every action. Destructive thread cleanup goes through `thread-reconciler` with current proof and leader fencing. Fresh Workspace Thread creation derives its initial Bot API title from the active display mode before issuance; the stable generated `threadName` remains separate from the acknowledged `displayTitle`.
83
+ - Repeated identical diagnostics in one phase collapse into one run record plus one outcome record; the logging domain owns that recording policy, and other domains receive it as a structural port rather than growing a second owner. Runtime state is event-driven reconciliation of local assumptions against Telegram signals, not a complete bot read-model and not permission to query Telegram on every action. Destructive thread cleanup goes through `thread-reconciler` with current proof and leader fencing. Fresh Workspace Thread creation derives its initial Bot API title from the active display mode before issuance; the stable generated `threadName` remains separate from the acknowledged `displayTitle`.
83
84
 
84
85
  ### 4.3 Durable Admission And Settlement
85
86
 
@@ -90,7 +91,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
90
91
  - Foreign forwarding settles as `accepted`, `retryable`, or `terminal-rejected`. Only an authenticated acknowledgement carrying the expected `deliveryId` and `sourceUpdateId` releases leader journal authority. Negative, missing, stale, mismatched, or capacity-failed settlement remains durable; callback error answers are side effects only. Exception: the operator-approved fixed chooser deadline may discard its exact unclaimed donor pending source without a body archive or delivery-success claim. Accepted recipient queue/work and bound Threads are untouched; old donor controls and late reports cannot replay it. The lifetime and temporary cleanup contract lives in `docs/architecture.md`.
91
92
  - A forwarding delivery id is stable across registration replacement and derives from envelope kind, source `update_id`, and stable recipient binding. Runtime instance and registration generation remain separate attempt fences. Persisted message ownership carries the stable binding so replay can rebind only to its current authenticated registration.
92
93
  - A queued receipt persists its acquiring runtime instance, OS pid/process-birth identity, session generation, acquisition id, and acquisition time. Only exact authority may settle or discard it. Cached presence is not current execution proof: prepared custody must revalidate the exact queued owner/group without recovery and refuse offers or uncertain reads. Completion requires an exact removal acknowledgement, never merely `!ready`; a retained acknowledgement permits local cleanup only, not replay. Same-process session replacement may reconstruct the claim and the original process may settle after transport ownership moves; a foreign process may neither replay nor settle it through generic removal or a copied acquisition id.
93
- - Startup and elapsed time are not owner-death proof; queued authority has no time lease. Dead-owner cleanup groups the complete receipt and transactionally rechecks pid liveness plus process-birth identity: only an absent PID or mismatched stable Linux/macOS/Windows birth proof discards all session-owned sources without replay; a matching proof is `alive`, while inaccessible birth metadata is `unverifiable`, and both non-dead outcomes keep authority queued. Under actual `A`–`Z` allocation pressure, retirement may invoke that journal-owned CAS only for a current inactive binding after complete strict source inspection, no local work/live owner/delivery authority, whole unoffered receipt groups, and a preflight proving every grouped owner dead. It must then recapture all protection before preparing deletion; partial progress never grants deletion authority. The live-transfer contract is authenticated offer → exact-generation bounded payload staging → recipient CAS acceptance → exact receipt-and-owner ACK → donor removal → recipient readiness. The offer freezes donor settlement/recovery; controls rebuild local closures; negative/mismatched pre-acceptance ACK cancels only an unaccepted offer and retains donor work; a lost post-acceptance ACK cannot cancel recipient authority and leaves donor memory frozen for explicit reconciliation.
94
+ - Startup and elapsed time are not owner-death proof; queued authority has no time lease. Dead-owner cleanup groups the complete receipt and transactionally rechecks pid liveness plus process-birth identity: only an absent PID or mismatched stable Linux/macOS/Windows birth proof discards all session-owned sources without replay; a matching proof is `alive`, while inaccessible birth metadata is `unverifiable`, and both non-dead outcomes keep authority queued. Under actual `A`–`Z` allocation pressure, retirement may invoke that journal-owned CAS only for a current inactive binding after complete strict source inspection, no local work/live owner/delivery authority, whole unoffered receipt groups, and a preflight proving every grouped owner dead. It must then recapture all protection before preparing deletion; partial progress never grants deletion authority. Fully resolved, positively unbound temporary-tab cleanup may independently recover historical v1 queued receipts under its profile admission and journal source serialization: complete strict namespace/reference inspection, whole unoffered receipt groups, fresh process-death proof and the existing journal CAS are required. Alive/unverifiable owners and unsupported custody remain protected; this recovery never runs from read-only protection checks, and every ordinary target/source protection is recaptured before deletion. The live-transfer contract is authenticated offer → exact-generation bounded payload staging → recipient CAS acceptance → exact receipt-and-owner ACK → donor removal → recipient readiness. The offer freezes donor settlement/recovery; controls rebuild local closures; negative/mismatched pre-acceptance ACK cancels only an unaccepted offer and retains donor work; a lost post-acceptance ACK cannot cancel recipient authority and leaves donor memory frozen for explicit reconciliation.
94
95
  - Execution failures persist bounded diagnostics and attempt state as `retry-wait`, except that an exact Telegram HTTP 400 stale/deleted-thread API failure with a proven `{chatId, threadId}` terminally settles the currently executing source after best-effort shared binding invalidation. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity. Missing snapshots left by the retired broad temp cleanup rebuild only from a complete provably empty segment chain, while revisionless snapshots may recover from a validated later segment predecessor; otherwise the transaction-locked reset deletes damaged segments before publishing a fresh journal, with accepted input loss and informational evidence, not a quarantine copy. Unsupported versions remain untouched; strict protection reads never reset. Startup best-effort removes obsolete current-runtime root/session `recovery/` folders without touching pre-0.52.0 storage.
95
96
  - Business connection chats are a separate namespace even when their chat/message IDs match bot-chat IDs. Default DM routing must never infer private-queue deletion intent from `deleted_business_messages`; raw companion handlers remain separate owners.
96
97
  - Clock-bearing chooser sources survive cold spending until their fixed deadline. A restarted owner revives one in place only while its saved phase is still `waiting`, its recorded chooser location is known, its tab stays unbound and live threads exist; that waiting phase is the proof no selection or effect ran. Selected, expired or bound sources keep the protective hold, and new-world forgetting preserves their positively unbound temporary frame. Expiry is a donor discard, not recipient cancellation or source execution completion. Confirmed expiry releases worker custody before UI/cleanup awaits; metadata retries retain no prompt bodies and cannot repeat an issued delete.
@@ -2,9 +2,4 @@
2
2
 
3
3
  _This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
4
4
 
5
- - [ ] **Connection recovery live acceptance.** Verify operator-run leader/follower reconnect after interrupted creation on updated peers. Known target reuse and unknown-target abandonment have separate local regressions; do not infer live recovery from them. Also verify a long-deleted follower tab, follower `/start` Restore with negotiated menu-alias support, and leader Restore confirmation on the first click plus separately proven old-tab cleanup. Record leftover-tab behavior, exact peer capabilities and diagnostics; local regressions and release authorization do not replace this live evidence.
6
- - [ ] **Steer follow-up live acceptance.** Release authorization explicitly defers these checks beyond 0.56.0; do not treat automated coverage or the accepted busy-run smoke as live proof for unrun scenarios. Preserve the [queue and dispatch contract](./docs/architecture.md#queue-and-dispatch-safety).
7
- - [ ] **Operator scenarios.** Verify final selector/counter rendering; idle packets, native pending input, compaction, model switching, abort/Continue, complete media groups, Reroute/Restore, negotiated leader/follower transfer, reply/preview ownership and session-generation replacement. Record actual Pi/OS/Telegram versions, peer capabilities and redacted outcomes. Reload and disposable live tests require operator intent; do not manipulate runtime files to obtain acceptance.
8
- - [ ] **Restore recipient refusal after leadership change.** Diagnose a retained routing chooser refusing the selected current leader with unavailable recipient authority. Identify the exact context/session/Workspace mismatch before proposing recovery; do not treat an absent follower registry as the cause of a leader-target refusal, bypass fences or mutate live state.
9
- - [ ] **Post-reload diagnostics.** Investigate recurring typing-authority refusals and Workspace publication retries observed alongside successful leader/follower reconnection. Establish whether they are expected fenced stale work or affect current-session delivery; successful registration alone does not establish error-free operation.
10
- - [ ] **Native Windows scenarios.** Verify named-pipe custody/ACK, journal/atomic rename, reused-context lifecycle and the phone scenarios on an actual Windows host. Record skipped tests and missing platform evidence explicitly; a hosted CI result does not establish phone or full live-runtime acceptance.
5
+ No open tasks.
@@ -4,6 +4,24 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.57.2: Connect Latency and Session Identity
8
+
9
+ - `Connect latency`: A new Thread no longer waits about 20 seconds when all 26 letter slots are occupied. Slot rotation read the complete journal namespace and every shared journal once per binding inside one synchronous pass, freezing the leader's event loop and staling healthy follower heartbeats. One occupancy snapshot now reads that binding-independent evidence once, and every fresh snapshot still re-reads it so post-fence checks see newly arrived work.
10
+ - `Session identity`: Follower promotion, Thread-name authority and in-flight registration now compare the session instead of the exact context object. A follower connected manually with `/telegram-connect` becomes leader when the old leader stops answering, instead of rescheduling promotion forever while registration retried a dead socket. Pi's fresh per-event context objects no longer change behavior; a follower with no live session still waits.
11
+ - `Bus retries`: A leader reload no longer logs a line for every follower bus attempt, and an absent leader no longer repeats identical heartbeat, capability-probe or typing-presence failures every interval or tick. Each request records its first failure, one `client-recovered` event when a later attempt succeeds, and its final failure; a repeated failure records once per run plus a recovery record with the suppressed count. Detection and recovery observation are unchanged.
12
+ - `Restore probe`: The follower restore probe now treats Telegram's `message thread not found` as its own expected answer instead of an API error. It still marks the target stale, re-creates the Thread and records one `topic-lifecycle` event with the old and new thread ids. Unexpected API failures keep their diagnostics.
13
+ - `Graceful shutdown`: Graceful cleanup in a private chat now deletes the Thread directly instead of first calling `closeForumTopic`, which Telegram rejects outside forums. Rebind protection still runs immediately before deletion, so a Thread re-bound during cleanup remains protected; forum cleanups still close before deleting.
14
+ - `Status diagnostics`: The connection status prints the state and log paths of the agent directory actually in use, so a second profile started with `PI_CODING_AGENT_DIR` no longer points at the default profile's files. Stored paths resolve through symlinked home directories before they are abbreviated. Fixes #349.
15
+ - `Slow-call diagnostics`: Follower API calls slower than 1.5 seconds record one diagnostic with the method, the elapsed time and the split between waiting for registration and transport. A late reply can now be attributed to recovery waiting or to delivery instead of staying unexplained. This is observation only.
16
+ - `Delivery coverage`: Automated checks now assert that commentary is delivered while its tool call is still running and that a final answer is delivered before automatic compaction completes, for every activity mode with draft previews on and off. Both paths already behaved this way; the coverage prevents regressions.
17
+
18
+
19
+ ## 0.57.1: Guest Media and Routing Cleanup
20
+
21
+ - `Guest attachments`: Queued photos, documents and MP3 audio now replace the acknowledged Guest Mode placeholder instead of being silently skipped. Upload failures and confirmed edit rejections report errors; uncertain edits are not replayed or overwritten. OGG/Opus attachments are rejected before queueing on this path, and voice markup explains the limitation. Live-client media acceptance remains a deferred follow-up. Fixes #346.
22
+ - `Routing cleanup`: Exhausted Reroute tabs and a restored instance's old tab are no longer blocked by unrelated creation attempts with unknown targets. Uncertain creation records stay intact; binding, slot, owner and concrete or recovered target conflicts still protect. Temporary cleanup can recover whole historical receipts from proven-dead owners; live/unverifiable owners, handoff offers and damaged sources remain protected. Idle leader Reroute and Restore passed operator live checks.
23
+ - `Command ordering`: Fresh local menu Reroute waits for exact source removal and a temporary-tab cleanup attempt before rendering. Restore confirms the new binding, decides old-tab cleanup, then releases held input. Busy work retains protection, and uncertain deletion never repeats. Other Reroute commands, follower delivery and sources without fresh completion readiness retain their existing paths.
24
+
7
25
  ## 0.57.0: Thread Continuity and Shared Settings
8
26
 
9
27
  - `Config`: Thread display now lives in shared top-level `threads.displayMode`, beside the other Settings preferences; profiles hold only bot identity and the paired owner. The old `profiles.<name>.threadDisplayMode` key is not migrated and is ignored, so configs without the new key use the Letters default until Settings saves a choice.
@@ -269,7 +269,7 @@ interface TelegramLifecycleBindingDeps {
269
269
  shutdownGenerativeAppLiveSurfaces?: () => void;
270
270
  resolveAutomaticThreadCleanupEnabled?: () => boolean | Promise<boolean>;
271
271
  buttonActionStore: OutboundHandlers.TelegramButtonActionStore;
272
- callMultipart: OutboundHandlers.TelegramVoiceReplySenderDeps["sendMultipart"];
272
+ callMultipart: (...args: Parameters<TelegramApi.TelegramBridgeApiRuntime["callMultipart"]>) => Promise<unknown>;
273
273
  sendChatAction: NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendChatAction"]>;
274
274
  sendRecordVoiceAction: NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendRecordVoiceAction"]>;
275
275
  sendMarkdownReply: Queue.TelegramAgentEndHookRuntimeDeps<Queue.PendingTelegramTurn, Pi.ExtensionContext, Pi.AgentEndEvent["messages"][number], Keyboard.TelegramInlineKeyboardMarkup>["sendMarkdownReply"];
@@ -278,6 +278,7 @@ interface TelegramLifecycleBindingDeps {
278
278
  onSteerTurnEnd?: TelegramQueueBindingRuntime<Pi.ExtensionContext>["onTurnEnd"];
279
279
  onPromptHandedOff?: (turn: Queue.PendingTelegramTurn, ctx: Pi.ExtensionContext) => void;
280
280
  answerGuestQuery: TelegramApi.TelegramBridgeApiRuntime["answerGuestQuery"];
281
+ editGuestInlineMessage?: TelegramApi.TelegramBridgeApiRuntime["editGuestInlineMessage"];
281
282
  deleteMessage: TelegramApi.TelegramBridgeApiRuntime["deleteMessage"];
282
283
  sendGuestReply: NonNullable<Queue.TelegramAgentEndHookRuntimeDeps<Queue.PendingTelegramTurn, Pi.ExtensionContext, Pi.AgentEndEvent["messages"][number], Keyboard.TelegramInlineKeyboardMarkup>["sendGuestReply"]>;
283
284
  editGuestReply?: Queue.TelegramAgentEndHookRuntimeDeps<Queue.PendingTelegramTurn, Pi.ExtensionContext, Pi.AgentEndEvent["messages"][number], Keyboard.TelegramInlineKeyboardMarkup>["editGuestReply"];
@@ -300,5 +301,5 @@ interface TelegramLifecycleBindingDeps {
300
301
  /** Settled-session hint for owners that wait on agent idleness, such as old-tab cleanup after a live rebind. */
301
302
  onSessionSettled?: (ctx: Pi.ExtensionContext) => void;
302
303
  }
303
- export declare function registerTelegramLifecycleRuntimeHooks({ pi, publicationRuntime, activityRuntime, activityVerbosityRuntime, diagnostics, assistantOutputRuntime, sessionLifecycleRuntime, configStore, abort, typing, lifecycle, activeTurnRuntime, telegramQueueStore, modelSwitchController, previewRuntime, promptDispatchRuntime, deferredQueueDispatchRuntime, modelContextAvailabilityRuntime, disconnectOnQuit, onSessionStarted, shutdownGenerativeAppLiveSurfaces, resolveAutomaticThreadCleanupEnabled, buttonActionStore, callMultipart, sendChatAction, sendRecordVoiceAction, sendMarkdownReply, sendTextReply, dispatchNextQueuedTelegramTurn, onSteerTurnEnd, onPromptHandedOff, answerGuestQuery, deleteMessage, sendGuestReply, editGuestReply, stopGuestPlaceholder, preparePreviewDelivery, finalizeMarkdownPreview, proactivePushTargetGetter, getAssistantRenderingMode, recordMessageOwnership, canSendAgentActivity, isSessionContextActive, getSessionGeneration, isTurnTransportActive, updateStatus, recordRuntimeEvent, onSessionSettled, }: TelegramLifecycleBindingDeps): void;
304
+ export declare function registerTelegramLifecycleRuntimeHooks({ pi, publicationRuntime, activityRuntime, activityVerbosityRuntime, diagnostics, assistantOutputRuntime, sessionLifecycleRuntime, configStore, abort, typing, lifecycle, activeTurnRuntime, telegramQueueStore, modelSwitchController, previewRuntime, promptDispatchRuntime, deferredQueueDispatchRuntime, modelContextAvailabilityRuntime, disconnectOnQuit, onSessionStarted, shutdownGenerativeAppLiveSurfaces, resolveAutomaticThreadCleanupEnabled, buttonActionStore, callMultipart, sendChatAction, sendRecordVoiceAction, sendMarkdownReply, sendTextReply, dispatchNextQueuedTelegramTurn, onSteerTurnEnd, onPromptHandedOff, answerGuestQuery, editGuestInlineMessage, deleteMessage, sendGuestReply, editGuestReply, stopGuestPlaceholder, preparePreviewDelivery, finalizeMarkdownPreview, proactivePushTargetGetter, getAssistantRenderingMode, recordMessageOwnership, canSendAgentActivity, isSessionContextActive, getSessionGeneration, isTurnTransportActive, updateStatus, recordRuntimeEvent, onSessionSettled, }: TelegramLifecycleBindingDeps): void;
304
305
  export {};
@@ -657,7 +657,7 @@ export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, pe
657
657
  },
658
658
  });
659
659
  }
660
- export function registerTelegramLifecycleRuntimeHooks({ pi, publicationRuntime, activityRuntime, activityVerbosityRuntime, diagnostics, assistantOutputRuntime, sessionLifecycleRuntime, configStore, abort, typing, lifecycle, activeTurnRuntime, telegramQueueStore, modelSwitchController, previewRuntime, promptDispatchRuntime, deferredQueueDispatchRuntime, modelContextAvailabilityRuntime, disconnectOnQuit, onSessionStarted, shutdownGenerativeAppLiveSurfaces, resolveAutomaticThreadCleanupEnabled, buttonActionStore, callMultipart, sendChatAction, sendRecordVoiceAction, sendMarkdownReply, sendTextReply, dispatchNextQueuedTelegramTurn, onSteerTurnEnd, onPromptHandedOff, answerGuestQuery, deleteMessage, sendGuestReply, editGuestReply, stopGuestPlaceholder, preparePreviewDelivery, finalizeMarkdownPreview, proactivePushTargetGetter, getAssistantRenderingMode, recordMessageOwnership, canSendAgentActivity, isSessionContextActive = () => true, getSessionGeneration, isTurnTransportActive, updateStatus, recordRuntimeEvent, onSessionSettled, }) {
660
+ export function registerTelegramLifecycleRuntimeHooks({ pi, publicationRuntime, activityRuntime, activityVerbosityRuntime, diagnostics, assistantOutputRuntime, sessionLifecycleRuntime, configStore, abort, typing, lifecycle, activeTurnRuntime, telegramQueueStore, modelSwitchController, previewRuntime, promptDispatchRuntime, deferredQueueDispatchRuntime, modelContextAvailabilityRuntime, disconnectOnQuit, onSessionStarted, shutdownGenerativeAppLiveSurfaces, resolveAutomaticThreadCleanupEnabled, buttonActionStore, callMultipart, sendChatAction, sendRecordVoiceAction, sendMarkdownReply, sendTextReply, dispatchNextQueuedTelegramTurn, onSteerTurnEnd, onPromptHandedOff, answerGuestQuery, editGuestInlineMessage, deleteMessage, sendGuestReply, editGuestReply, stopGuestPlaceholder, preparePreviewDelivery, finalizeMarkdownPreview, proactivePushTargetGetter, getAssistantRenderingMode, recordMessageOwnership, canSendAgentActivity, isSessionContextActive = () => true, getSessionGeneration, isTurnTransportActive, updateStatus, recordRuntimeEvent, onSessionSettled, }) {
661
661
  const agentEndResetter = Runtime.createTelegramAgentEndResetter({
662
662
  abort,
663
663
  typing,
@@ -677,7 +677,7 @@ export function registerTelegramLifecycleRuntimeHooks({ pi, publicationRuntime,
677
677
  recordOwnership: recordMessageOwnership,
678
678
  recordRuntimeEvent,
679
679
  });
680
- const sendGuestAttachment = async (turn, attachment, caption) => {
680
+ const sendGuestAttachment = async (turn, attachment, caption, options) => {
681
681
  const stagingTarget = proactivePushTargetGetter();
682
682
  const stagingChatId = stagingTarget?.chatId;
683
683
  if (stagingChatId === undefined) {
@@ -685,11 +685,19 @@ export function registerTelegramLifecycleRuntimeHooks({ pi, publicationRuntime,
685
685
  }
686
686
  await OutboundAttachments.deliverTelegramGuestCachedAttachment({
687
687
  guestQueryId: turn.guestQueryId,
688
+ guestInlineMessageId: turn.guestInlineMessageId,
689
+ editGuestInlineMessage,
690
+ isDeliveryActive: options?.isDeliveryActive,
688
691
  stagingChatId,
689
692
  stagingTarget,
690
693
  attachment,
691
694
  caption,
692
- sendMultipart: callMultipart,
695
+ sendMultipart: (method, fields, fileField, path, name) => callMultipart(method, fields, fileField, path, name, {
696
+ assertAuthority() {
697
+ if (options?.isDeliveryActive?.() === false)
698
+ throw new Error("Guest attachment upload lost authority.");
699
+ },
700
+ }),
693
701
  answerGuestQuery: (guestQueryId, result) => answerGuestQuery(guestQueryId, undefined, { result }),
694
702
  answerGuestText: (guestQueryId, text) => answerGuestQuery(guestQueryId, text),
695
703
  fallbackText: caption ||
@@ -339,9 +339,9 @@ export function createTelegramBusAwareApiRuntime(deps) {
339
339
  ? deps.directRuntime.answerGuestQueryForInlineMessage(guestQueryId, text, options)
340
340
  : rejectTelegramDirectOwnership("answerGuestQueryForInlineMessage");
341
341
  },
342
- editGuestInlineMessage(inlineMessageId, content) {
342
+ editGuestInlineMessage(inlineMessageId, content, options) {
343
343
  return deps.ownsDirect()
344
- ? deps.directRuntime.editGuestInlineMessage(inlineMessageId, content)
344
+ ? deps.directRuntime.editGuestInlineMessage(inlineMessageId, content, options)
345
345
  : rejectTelegramDirectOwnership("editGuestInlineMessage");
346
346
  },
347
347
  async deleteMessage(chatId, messageId) {
@@ -131,6 +131,18 @@ export interface TelegramBusFollowerClientRuntimeDeps<TMessage = unknown> {
131
131
  recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
132
132
  timeoutMs?: number;
133
133
  }
134
+ /** Structural port for run-collapsing diagnostics, implemented by the logging domain. */
135
+ export interface TelegramFailureRunLogPort {
136
+ record: (error: unknown, details?: Record<string, unknown>) => boolean;
137
+ settled: () => TelegramFailureRunSummaryPort | undefined;
138
+ reset: () => void;
139
+ }
140
+ export interface TelegramFailureRunSummaryPort {
141
+ signature: string;
142
+ failedAttempts: number;
143
+ suppressedFailures: number;
144
+ failedForMs: number;
145
+ }
134
146
  export interface TelegramBusFollowerApiCallerDeps {
135
147
  socketPath: TelegramBusSocketPathSource;
136
148
  instanceId: string;
@@ -140,10 +152,18 @@ export interface TelegramBusFollowerApiCallerDeps {
140
152
  waitForRegistrationGeneration?: (timeoutMs?: number) => Promise<string | undefined>;
141
153
  getNowMs?: () => number;
142
154
  timeoutMs?: number;
155
+ /** Receives one diagnostic per call slower than `slowCallMs`; observation only. */
156
+ recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
157
+ slowCallMs?: number;
143
158
  }
144
159
  export interface TelegramBusFollowerRegistrationRuntimeDeps<TContext extends {
145
160
  cwd?: string;
146
161
  }> {
162
+ /**
163
+ * Collapses one sustained heartbeat failure run. Optional at this unit boundary, where an absent port records
164
+ * every failure; the runtime assembly requires it so production always supplies the logging domain's owner.
165
+ */
166
+ failureLog?: TelegramFailureRunLogPort;
147
167
  instanceId: string;
148
168
  createRequestId: () => string;
149
169
  protocolIdentity: TelegramBusProtocolIdentity;
@@ -291,6 +311,8 @@ export interface TelegramBusFollowerRuntimeAssemblyPorts<TContext extends {
291
311
  cwd?: string;
292
312
  }> {
293
313
  instanceId: string;
314
+ /** Collapses one sustained heartbeat failure run; the composition root supplies the logging domain's owner. */
315
+ failureLog: TelegramFailureRunLogPort;
294
316
  registrationState: TelegramBusFollowerRegistrationState;
295
317
  recordRuntimeEvent: (category: string, error: unknown, details?: Record<string, unknown>) => void;
296
318
  receiver: Omit<TelegramBusForwardedUpdateReceiverRuntimeDeps<TContext>, "instanceId" | "recordRuntimeEvent" | "getRegistrationGeneration">;
@@ -23,6 +23,7 @@ import * as WorkspaceIdentity from "./workspace-identity.js";
23
23
  const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
24
24
  const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
25
25
  const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30_000;
26
+ const TELEGRAM_BUS_FOLLOWER_API_SLOW_CALL_MS = 1_500;
26
27
  const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30_000;
27
28
  export const TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS = TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS;
28
29
  const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS = TELEGRAM_BUS_REGISTRATION_RETRY.attempts;
@@ -213,6 +214,7 @@ export function createTelegramBusFollowerRuntimeAssembly(ports) {
213
214
  });
214
215
  registration = createTelegramBusFollowerRegistrationRuntime({
215
216
  ...ports.registration,
217
+ failureLog: ports.failureLog,
216
218
  ...sharedRuntimeDeps,
217
219
  startReceiving: receiver.start,
218
220
  stopReceiving: receiver.stop,
@@ -677,6 +679,7 @@ export function createTelegramBusFollowerClientRuntime(deps) {
677
679
  instanceId: deps.instanceId,
678
680
  getAuthSecret: deps.getApiAuthSecret,
679
681
  getRegistrationGeneration: deps.getRegistrationGeneration,
682
+ recordRuntimeEvent: deps.recordRuntimeEvent,
680
683
  }),
681
684
  agentMessages: createTelegramBusAgentMessageClient({
682
685
  ...sharedClientDeps,
@@ -947,11 +950,13 @@ export function createTelegramBusFollowerSelectedMenuCaller(deps) {
947
950
  export function createTelegramBusFollowerApiCaller(deps) {
948
951
  const getNowMs = deps.getNowMs ?? Date.now;
949
952
  const timeoutMs = deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
950
- return async (method, args, localOptions) => {
953
+ const slowCallMs = deps.slowCallMs ?? TELEGRAM_BUS_FOLLOWER_API_SLOW_CALL_MS;
954
+ const caller = async (method, args, localOptions, timing) => {
951
955
  const assertion = localOptions?.assertAuthority;
952
956
  const assertCurrent = () => assertTelegramApiCallAuthority(assertion, false);
953
957
  assertCurrent();
954
958
  const registration = await resolveTelegramBusFollowerRegistration(deps, timeoutMs);
959
+ timing.registeredAtMs = getNowMs();
955
960
  assertCurrent();
956
961
  const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
957
962
  let response;
@@ -999,6 +1004,42 @@ export function createTelegramBusFollowerApiCaller(deps) {
999
1004
  }
1000
1005
  throw new Error(message ?? "Telegram bus API call failed.");
1001
1006
  };
1007
+ return async (method, args, localOptions) => {
1008
+ const startedAtMs = getNowMs();
1009
+ const timing = {};
1010
+ let outcome = "ok";
1011
+ try {
1012
+ return await caller(method, args, localOptions, timing);
1013
+ }
1014
+ catch (error) {
1015
+ outcome = "error";
1016
+ throw error;
1017
+ }
1018
+ finally {
1019
+ const elapsedMs = getNowMs() - startedAtMs;
1020
+ if (elapsedMs >= slowCallMs) {
1021
+ const apiMethod = (method === "call" || method === "callMultipart") &&
1022
+ typeof args[0] === "string"
1023
+ ? args[0]
1024
+ : method;
1025
+ try {
1026
+ deps.recordRuntimeEvent?.("bus", `Telegram bus follower API call was slow (${elapsedMs}ms).`, {
1027
+ phase: "follower-api-slow",
1028
+ method: apiMethod,
1029
+ elapsedMs,
1030
+ registrationWaitMs: (timing.registeredAtMs ?? getNowMs()) - startedAtMs,
1031
+ ...(timing.registeredAtMs === undefined
1032
+ ? {}
1033
+ : { transportMs: getNowMs() - timing.registeredAtMs }),
1034
+ outcome,
1035
+ });
1036
+ }
1037
+ catch {
1038
+ // Diagnostics cannot change the API call outcome.
1039
+ }
1040
+ }
1041
+ }
1042
+ };
1002
1043
  }
1003
1044
  async function resolveTelegramBusFollowerRegistration(deps, timeoutMs) {
1004
1045
  const current = deps.getRegistrationGeneration();
@@ -1447,10 +1488,16 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1447
1488
  const promoteToLeader = async (reason, ctx, binding, election) => {
1448
1489
  if (!isCurrent())
1449
1490
  return;
1450
- const activeCtx = deps.getActiveContext?.();
1451
- if (deps.getActiveContext && activeCtx !== ctx) {
1452
- scheduleRecovery(reason, ctx, binding);
1453
- return;
1491
+ if (deps.getActiveContext) {
1492
+ // Pi hands every event and command a fresh context object, so a manually connected follower never holds
1493
+ // the stored session object. Promote through the live session context instead of rescheduling: heartbeat
1494
+ // failures re-request recovery faster than the retry grace, so a reschedule-only path never fires.
1495
+ const activeCtx = deps.getActiveContext();
1496
+ if (!activeCtx) {
1497
+ scheduleRecovery(reason, ctx, binding);
1498
+ return;
1499
+ }
1500
+ ctx = activeCtx;
1454
1501
  }
1455
1502
  deps.setLifecyclePhase("electing");
1456
1503
  safeUpdateStatus(ctx);
@@ -1629,6 +1676,8 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1629
1676
  (!deps.registrationState ||
1630
1677
  deps.registrationState.getGeneration() === generation);
1631
1678
  const stopHeartbeat = () => {
1679
+ // A stopped heartbeat ends the current failure run, so a later run logs its own first failure.
1680
+ heartbeatFailureLog.reset();
1632
1681
  if (!heartbeatInterval)
1633
1682
  return;
1634
1683
  clearInterval(heartbeatInterval);
@@ -1659,6 +1708,22 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1659
1708
  }
1660
1709
  });
1661
1710
  };
1711
+ /** A sustained leader outage repeats one identical failure every interval; the port collapses the run. */
1712
+ const heartbeatFailureLog = deps.failureLog ?? {
1713
+ record(error) {
1714
+ try {
1715
+ deps.recordRuntimeEvent?.("bus", error, {
1716
+ phase: "follower-heartbeat",
1717
+ });
1718
+ }
1719
+ catch {
1720
+ // Diagnostics cannot replace heartbeat recovery.
1721
+ }
1722
+ return true;
1723
+ },
1724
+ settled: () => undefined,
1725
+ reset: () => undefined,
1726
+ };
1662
1727
  const sendHeartbeat = async () => {
1663
1728
  const leaderSocketPath = activeLeaderSocketPath;
1664
1729
  const registrationGeneration = activeRegistrationGeneration;
@@ -1681,6 +1746,7 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1681
1746
  if (!isCurrentHeartbeat())
1682
1747
  return;
1683
1748
  if (response?.kind === "bus.ack" && response.ok) {
1749
+ heartbeatFailureLog.settled();
1684
1750
  const heartbeatResult = isRecord(response.result)
1685
1751
  ? response.result
1686
1752
  : undefined;
@@ -1707,14 +1773,7 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1707
1773
  catch (error) {
1708
1774
  if (!isCurrentHeartbeat())
1709
1775
  return;
1710
- try {
1711
- deps.recordRuntimeEvent?.("bus", error, {
1712
- phase: "follower-heartbeat",
1713
- });
1714
- }
1715
- catch {
1716
- // Diagnostics cannot replace heartbeat recovery.
1717
- }
1776
+ heartbeatFailureLog.record(error);
1718
1777
  if (!heartbeatContext)
1719
1778
  return;
1720
1779
  try {
@@ -1956,10 +2015,10 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1956
2015
  return false;
1957
2016
  },
1958
2017
  setContext(ctx) {
2018
+ // Pi hands every event a fresh context object. Only a replaced session drops an in-flight attempt.
1959
2019
  if (registrationAttempt &&
1960
- (registrationAttempt.ctx !== ctx ||
1961
- registrationAttempt.sessionGeneration !==
1962
- deps.getSessionGeneration?.() ||
2020
+ (registrationAttempt.sessionGeneration !==
2021
+ deps.getSessionGeneration?.() ||
1963
2022
  registrationAttempt.sessionId !== deps.getSessionId?.(ctx)))
1964
2023
  registrationAttempt = undefined;
1965
2024
  activeContext = ctx;
@@ -66,7 +66,7 @@ export interface TelegramBusFollowerTargetProvisionerDeps {
66
66
  getLiveFollowers?: () => readonly TelegramBusFollowerView[];
67
67
  isFollowerProcessAlive?: (pid: number) => boolean;
68
68
  topicTargetStore: Threads.TelegramTopicTargetStore;
69
- callApi: <TResponse>(method: string, body: Record<string, unknown>) => Promise<TResponse>;
69
+ callApi: <TResponse>(method: string, body: Record<string, unknown>, options?: TelegramApiCallOptions) => Promise<TResponse>;
70
70
  getCurrentLeaderEpoch?: () => number | string | undefined;
71
71
  getSyncState: () => Sync.TelegramSyncState;
72
72
  setSyncState: (state: Sync.TelegramSyncState) => void;
@@ -1200,7 +1200,9 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
1200
1200
  message_thread_id: connectedAnnouncement.target.threadId,
1201
1201
  text: connectedAnnouncement.text,
1202
1202
  parse_mode: connectedAnnouncement.parseMode,
1203
- });
1203
+ },
1204
+ // A deleted Thread is this probe's expected answer; the recovery below records it.
1205
+ { isExpectedError: Threads.isTelegramTopicTargetStaleError });
1204
1206
  assertRestoreRegistration();
1205
1207
  if (recoverableTarget || probeRequiredRecord) {
1206
1208
  const activatedRecord = deps.topicTargetStore.upsert({
@@ -1240,7 +1242,15 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
1240
1242
  if (Threads.isTelegramTopicTargetStaleError(error)) {
1241
1243
  deps.topicTargetStore.markStaleByTarget(result.target, "deleted", error instanceof Error ? error.message : String(error));
1242
1244
  await deps.topicTargetStore.persist();
1245
+ const staleTarget = result.target;
1243
1246
  result = await provisionTarget();
1247
+ deps.recordRuntimeEvent("telegram", "Telegram follower restore probe found its Thread deleted; re-created it.", {
1248
+ phase: "topic-lifecycle-restore-probe-recreated",
1249
+ instanceId: registration.instanceId,
1250
+ chatId: staleTarget.chatId,
1251
+ staleThreadId: staleTarget.threadId,
1252
+ threadId: result.target.threadId,
1253
+ });
1244
1254
  connectedAnnouncement =
1245
1255
  createTelegramBusInstanceLifecycleAnnouncement({
1246
1256
  target: result.target,
@@ -1960,32 +1960,43 @@ export async function sendTelegramBusLocalEnvelope(options) {
1960
1960
  };
1961
1961
  const attempts = Math.max(1, resolvedOptions.retry?.attempts ?? 1);
1962
1962
  const delayMs = Math.max(0, resolvedOptions.retry?.delayMs ?? 0);
1963
+ // Log the first failure, then only the outcome: a reloading leader would otherwise log every attempt.
1963
1964
  for (let attempt = 1;; attempt += 1) {
1964
1965
  try {
1965
- return await sendTelegramBusLocalEnvelopeOnce(resolvedOptions);
1966
+ const response = await sendTelegramBusLocalEnvelopeOnce(resolvedOptions);
1967
+ if (attempt > 1)
1968
+ resolvedOptions.recordTransportEvent?.("client-recovered", {
1969
+ ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
1970
+ ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
1971
+ attempt,
1972
+ attempts,
1973
+ });
1974
+ return response;
1966
1975
  }
1967
1976
  catch (error) {
1968
1977
  const info = classifyTelegramBusTransportError(error);
1969
- resolvedOptions.recordTransportEvent?.("client-failed", {
1970
- ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
1971
- ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
1972
- attempt,
1973
- attempts,
1974
- ...info,
1975
- });
1976
- if (error instanceof TelegramBusLocalAuthorityError ||
1978
+ const final = error instanceof TelegramBusLocalAuthorityError ||
1977
1979
  attempt >= attempts ||
1978
- !isRetryableTelegramBusTransportError(error)) {
1980
+ !isRetryableTelegramBusTransportError(error);
1981
+ if (attempt === 1 || final)
1982
+ resolvedOptions.recordTransportEvent?.("client-failed", {
1983
+ ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
1984
+ ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
1985
+ attempt,
1986
+ attempts,
1987
+ ...info,
1988
+ });
1989
+ if (final)
1979
1990
  throw error;
1980
- }
1981
- resolvedOptions.recordTransportEvent?.("client-retry", {
1982
- ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
1983
- ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
1984
- attempt,
1985
- attempts,
1986
- delayMs,
1987
- ...info,
1988
- });
1991
+ if (attempt === 1)
1992
+ resolvedOptions.recordTransportEvent?.("client-retry", {
1993
+ ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
1994
+ ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
1995
+ attempt,
1996
+ attempts,
1997
+ delayMs,
1998
+ ...info,
1999
+ });
1989
2000
  await delayTelegramBusTransportRetry(delayMs);
1990
2001
  }
1991
2002
  }
@@ -627,6 +627,8 @@ export interface TelegramSelectedCommandAdmission {
627
627
  /** Independent exact recipient/target lifetime, including inside captured menu transport adapters. */
628
628
  assertRecipientCurrent(): void;
629
629
  reportCompleted(): boolean;
630
+ /** Optional recipient release barrier after acceptance; rejection suppresses effects without replay. */
631
+ beforeEffects?(): Promise<void>;
630
632
  }
631
633
  export declare function createTelegramCommandHandler<TMessage extends TelegramCommandRuntimeMessage, TContext>(deps: TelegramCommandRuntimeDeps<TMessage, TContext>): ((commandName: string | undefined, message: TMessage, ctx: TContext, commandArgs?: string) => Promise<boolean>) & {
632
634
  /** Command-only producer execution; Routing must supply the captured plan and released exact-source authority. */