@llblab/pi-telegram 0.53.2 → 0.54.1

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.
@@ -54,12 +54,12 @@ The operator experience:
54
54
  - Do not couple the first design to Pi sessions if live instance ownership is the better runtime truth.
55
55
  - Do not expose arbitrary group participants to prompts, controls, or artifacts.
56
56
  - Do not require Threaded Mode for classic private-chat users; classic private-chat mode remains valid and should not receive slot/thread-name guidance.
57
- - Do not implement leader election through unsafe lock stealing without heartbeats or stale-owner checks.
57
+ - Do not implement leader election through unsafe lock stealing without stale-owner proof (dead PID, or a bus-proven unresponsive owner).
58
58
 
59
59
  ## Terms
60
60
 
61
61
  - `Telegram bus`: The singleton local capability to poll Telegram updates and send Telegram API calls for one bot token.
62
- - `Leader`: The live Pi instance that currently owns the Telegram bus and calls `getUpdates`; this is an ephemeral role transferable after stale heartbeat detection.
62
+ - `Leader`: The live Pi instance that currently owns the Telegram bus and calls `getUpdates`; this is an ephemeral role transferable after its PID dies or the bus proves it unresponsive.
63
63
  - `Follower`: A live Pi instance that wants Telegram presence but routes Telegram API access through the leader.
64
64
  - `Bus lifecycle`: Transient recovery state only. Stable identity is the bus role (`leader` / `follower`); lifecycle surfaces exceptional handoff states such as `electing`, not duplicate roles with labels like `leader-active`.
65
65
  - `Agent instance`: A running Pi process/session with its own extension state, queue, active turn, model, tools, and lifecycle hooks.
@@ -80,7 +80,7 @@ tmp/pi-telegram/state.json / profiles.<profile>.transport -> polling/control own
80
80
  Threaded Mode meaning:
81
81
 
82
82
  ```text
83
- tmp/pi-telegram/state.json / profiles.<profile>.transport -> bus leader identity + heartbeat
83
+ tmp/pi-telegram/state.json / profiles.<profile>.transport -> bus leader identity (no periodic heartbeat)
84
84
  ```
85
85
 
86
86
  Followers do not poll. They register with the leader and receive routed inbound updates from it. Followers still own their local queue, active-turn state, previews, final delivery planning, model switches, and Pi lifecycle. The leader owns only Telegram transport and update fanout. Same-session context refresh preserves bus membership; a changed Pi session ID suspends the old receiver/heartbeat and requires acknowledged successor registration before readiness, without an explicit Thread disconnect. A new follower process also attempts a restore-only registration at session startup when the selected profile's local state contains a Workspace binding for the exact `cwd` and session ID. The recent-event log distinguishes missing session bindings, disabled Threaded Mode, leader-side restore refusal, and a restore that returned without connecting; none of these refusals silently provisions a new Thread. The Telegram bus belongs to the local set of cooperating visible Pi instances rather than to the first terminal session forever: if the visible terminal leader exits, a live registered follower can take over leadership.
@@ -255,13 +255,13 @@ Routing authority remains the authenticated live owner plus exact numeric Telegr
255
255
 
256
256
  ## Leader Election
257
257
 
258
- Leader election is heartbeat-gated and lock-backed. The polling owner checks exact lock ownership every second, refreshes its durable lease every two seconds, and becomes stale after eight seconds; the serialized expected-owner transaction remains the final cross-platform election authority.
258
+ Leader election is liveness-proof-gated and lock-backed. Since 0.54 the leader writes no periodic heartbeat: the transport section changes only on acquire, release, takeover or entry migration, and the owner's one- and two-second ownership checks are lock-free reads. A dead PID is stale immediately. A live PID is replaceable only with bus proof: an authenticated side-effect-free `bus.probe` to the leader endpoint that either stays unanswered for the eight-second window, or finds a refusing endpoint twice one window apart (a just-started leader binds well within it). Any reply, including an older leader rejecting the unknown kind, proves it alive; a missing endpoint proves nothing, because a live classic-mode leader has none. Classic mode therefore never takes over a live PID automatically: `/telegram-connect` asks the operator. Entries written by older releases still carry `heartbeatMs` and keep the eight-second stale rule. The serialized expected-owner transaction, binding the proof to the exact owner epoch, remains the final cross-platform election authority.
259
259
 
260
260
  1. On startup, read the Telegram lock.
261
261
  2. If no leader exists, acquire leadership and start polling.
262
262
  3. If a live leader exists, restore a remembered profile-scoped exact-`cwd` follower binding automatically; otherwise wait for explicit `/telegram-connect` rather than provisioning a new Thread from startup alone.
263
- 4. If the leader heartbeat is stale, attempt an atomic leadership takeover; ordinary `/telegram-connect` on a follower is not a leadership move while the leader is live.
264
- 5. Heartbeat acknowledgements carry the authenticated live follower-slot roster. If several followers detect stale leadership, the lowest observed live slot attempts promotion immediately; higher slots defer one bounded election grace and re-check the lock. Atomic compare/write acquisition remains the final ownership authority, and a missing lower-slot follower cannot block a higher survivor beyond that grace.
263
+ 4. If the leader PID is dead, or its re-registration fails and the bus proves it unresponsive, attempt an atomic leadership takeover; ordinary `/telegram-connect` on a follower is not a leadership move while the leader is live.
264
+ 5. Heartbeat acknowledgements carry the authenticated live follower-slot roster. If several followers detect stale or unresponsive leadership, the lowest observed live slot attempts promotion immediately; higher slots defer one bounded election grace and re-check the lock. Atomic compare/write acquisition remains the final ownership authority, and a missing lower-slot follower cannot block a higher survivor beyond that grace.
265
265
 
266
266
  Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding. Follower Bot API calls already admitted by the active Pi turn wait for that bounded re-registration and capture its new exact generation before entering transport; they do not fail merely because recovery temporarily cleared local registration, and they never replay after an ambiguous transport commit. After the grace window followers promote only when the exact observed leader lease has become stale or inactive; an unavailable IPC endpoint never authorizes replacing a still-live owner. If the exact carried target is absent from persisted bindings, the leader first runs the same synchronous visibility probe: success recovers it instead of creating another Telegram thread, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. An ambiguous absent-target probe persists only non-routable `probe-required` restoration evidence, so targetless retries, successor follower processes with the same exact Workspace claim, and leader reloads probe that exact target again instead of activating it or provisioning a speculative replacement. A carried slot survives only when that slot remains free. Every successful reuse refreshes the binding timestamp. The leader never restores persisted followers into the live registry speculatively. Absent follower records remain durable restart hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; only fresh authenticated registration creates live routing authority. This preserves real thread bindings through reload and process-absence gaps without allowing historical records or competing pollers to masquerade as live state.
267
267
 
@@ -269,7 +269,7 @@ Followers first try to re-register after leader reload or unknown-heartbeat resp
269
269
 
270
270
  ### Protocol identity and compatibility
271
271
 
272
- The session-native local wire contract has protocol version `2`, independent from the npm package version. Follower registration and the leader acknowledgement carry `{ protocolVersion, runtimeBuild, capabilities }`. Capability names are canonical, unique, and sorted. A leader rejects missing or mismatched protocol identity before provisioning a target or publishing the follower into live routing; a strict follower likewise rejects an acknowledgement without compatible leader identity. Different package builds remain compatible when their protocol versions agree. `durable-follower-admission-v1` gates source forwarding, while `queue-handoff-v1` independently gates live semantic queue transfer; every participant in a routed handoff must advertise it. `workspace-follower-auto-connect-v1` gates restore-only startup admission. Session identity is part of protocol v2 rather than an optional capability. Every cwd-scoped v2 registration carries the exact bounded session ID; missing identity is rejected before provisioning or publication. Reconnect, replacement, and promotion preserve it. Protocol-v1 peers are rejected by the base version check, avoiding mixed semantic branches. `thread-display-mode-v1` gates exact-generation follower display-setting requests and Letters/Directories require compatible connected followers; returning to Names allows legacy peers. Registration checks the current display-mode requirement before provisioning and again before live publication. The leader serializes config persistence and title application. `workspace-thread-rename-v1` independently gates follower rename requests whose exact registration generation is checked before the leader mutates Telegram and persists the Workspace binding. `session-replacement-intent-v1` gates Telegram `/new` from a follower Thread: followers never write `state.json`, so `follower.publishSessionReplacement` asks the leader to CAS-publish the durable intent only when the live registration generation, leader epoch, Profile, exact `cwd`, source session, Thread target, and the leader's own Workspace binding slot/name all match, with a bounded unexpired TTL. The intent records the source runtime instance; only a successor registration from that instance or its authenticated same-process handoff (`previousInstanceId`) may re-key the binding, and `follower.settleSessionReplacement` claims it once only after the leader's store shows that successor session bound to the same target. A missing capability, stale generation, or mismatch fails the command closed.
272
+ The session-native local wire contract has protocol version `3`, independent from the npm package version. Version 3 moved journal source serialization from the `telegram.json` transaction to the journal-owned `runtime/journals.transaction`; because v2 peers would not exclude v3 journal writers, the base version check rejects them instead of mixing lock semantics. Follower registration and the leader acknowledgement carry `{ protocolVersion, runtimeBuild, capabilities }`. Capability names are canonical, unique, and sorted. A leader rejects missing or mismatched protocol identity before provisioning a target or publishing the follower into live routing; a strict follower likewise rejects an acknowledgement without compatible leader identity. Different package builds remain compatible when their protocol versions agree. `durable-follower-admission-v1` gates source forwarding, while `queue-handoff-v1` independently gates live semantic queue transfer; every participant in a routed handoff must advertise it. `workspace-follower-auto-connect-v1` gates restore-only startup admission. Session identity has been part of the base protocol since v2 rather than an optional capability. Every cwd-scoped registration carries the exact bounded session ID; missing identity is rejected before provisioning or publication. Reconnect, replacement, and promotion preserve it. Older-protocol peers are rejected by the base version check, avoiding mixed semantic branches. `thread-display-mode-v1` gates exact-generation follower display-setting requests and Letters/Directories require compatible connected followers; returning to Names allows legacy peers. Registration checks the current display-mode requirement before provisioning and again before live publication. The leader serializes config persistence and title application. `workspace-thread-rename-v1` independently gates follower rename requests whose exact registration generation is checked before the leader mutates Telegram and persists the Workspace binding. `session-replacement-intent-v1` gates Telegram `/new` from a follower Thread: followers never write `state.json`, so `follower.publishSessionReplacement` asks the leader to CAS-publish the durable intent only when the live registration generation, leader epoch, Profile, exact `cwd`, source session, Thread target, and the leader's own Workspace binding slot/name all match, with a bounded unexpired TTL. The intent records the source runtime instance; only a successor registration from that instance or its authenticated same-process handoff (`previousInstanceId`) may re-key the binding, and `follower.settleSessionReplacement` claims it once only after the leader's store shows that successor session bound to the same target. A missing capability, stale generation, or mismatch fails the command closed.
273
273
 
274
274
  The current root advertises `workspace-restore-v1`; all participating peers and potential leader successors must be upgraded before operator-controlled use. The retired `leader.replaceFollowerTarget` envelope is no longer parsed or produced, even with current authentication and registration generation. Its `leader.workspaceRestore` envelope carries only an operation ID, exact recipient registration generation and `apply`/`inspect` mode; the receiver derives binding and destination from the retained intent, not caller-supplied target data. Authentication and an explicitly enabled receiver are mandatory. The controller checks both peers' capability and protocol compatibility, captures live registration/session/slot/endpoint authority, sends once without transport retry, then validates the request, operation, recipient, observed target, slot and boolean readiness against the current registration. A lost response leaves the durable issuance consumed; a separate read-only inspection can prove readiness without applying again. Neither the controller nor receiver settles source input or writes follower-owned canonical state. A successor registration/process may inspect only its actual same-session canonical binding and local target; readiness records the new observer separately without changing the original issuance. An old local target cannot be repaired through inspection, and successor apply is rejected. Interrupted startup, terminal source/cleanup settlement and live behavior were accepted in the operator's 0.52.0 live smoke.
275
275
 
@@ -287,7 +287,7 @@ As part of staging, the exact recipient presents the token and atomically receiv
287
287
 
288
288
  ### Live-Rebind Channel
289
289
 
290
- The 0.53.0 channel preserves protocol v2 and negotiates live rebinding independently from package version. Release 0.53.0 advertises the following capabilities; an instance still running an older build does not acquire them until it is reloaded. Compatible package versions alone cannot supply missing capabilities:
290
+ The 0.53.0 channel preserved protocol v2 and negotiates live rebinding independently from package version; 0.54 moves the base protocol to v3 without changing these capabilities. Release 0.53.0 advertises the following capabilities; an instance still running an older build does not acquire them until it is reloaded. Compatible package versions alone cannot supply missing capabilities:
291
291
 
292
292
  - `live-thread-rebind-save-v1`, `live-thread-rebind-apply-v1`, `live-thread-rebind-settle-v1`: Exact recipient save/apply/release and source settlement through the shared channel.
293
293
  - `live-thread-rebind-command-set-v1`: One `selectedCommand: { name, target }` descriptor and `observe-command` / `command-observed` completion observation, rather than separate status/control wire forms. Retired descriptors and capability names refuse without a compatibility shim.
@@ -502,7 +502,7 @@ Named bots use sibling `profiles.<name>` entries. Shared bridge settings remain
502
502
 
503
503
  Rules:
504
504
 
505
- - Classic mode is selected by Telegram capability: when private-chat threads are unavailable or disabled, the polling owner uses ordinary single-DM behavior and blocked instances do not register as followers. During a live downgrade from Threaded Mode, the current bus leader becomes the classic polling owner after two 2.5-second capability-monitor probes and followers disconnect; if classic polling restore fails transiently, later monitor ticks retry the restore instead of allowing a follower takeover. Followers must not turn the downgrade into a takeover while active thread bindings prove the singleton owner was already established by the bus leader.
505
+ - Classic mode is selected by Telegram capability: when private-chat threads are unavailable or disabled, the polling owner uses ordinary single-DM behavior and blocked instances do not register as followers. During a live downgrade from Threaded Mode, the current bus leader becomes the classic polling owner after two 2.5-second capability-monitor probes and followers disconnect; if classic polling restore fails transiently, later monitor ticks retry the restore instead of allowing a follower takeover. Followers must not turn the downgrade into a takeover while active thread bindings prove the singleton owner was already established by the bus leader. A follower that loses its leader reads the leader-persisted `threadMode`; when it is `disabled`, the follower halts recovery and goes offline with one diagnostic instead of retrying registration, keeping its Thread binding. It stays offline after Threaded Mode returns until the operator runs `/telegram-connect`. A deliberate local stop halts recovery the same way, and `/telegram-disconnect` without a live leader still stops locally, reporting the Thread as kept rather than deleted.
506
506
  - Telegram private-chat Threaded Mode enables local leader/follower behavior automatically. The leader owns `getUpdates`; registered followers route Telegram API work through the leader. `/telegram-connect` registers as follower when a live leader exists and does not offer manual takeover in that state. The TUI status bar keeps `telegram connected`, `telegram leader`, or `telegram follower` as stable transport identity during work; active work and queued items share the green Queue count instead of replacing that identity with an `active` processing label. Follower registration is unique by live profile/target: a reload or session replacement must replace stale registry entries rather than leaving multiple routable ids for one Telegram thread, and fallback target ownership must not classify leader records as followers.
507
507
  - The thread chat is the owner's private bot DM (`allowedUserId`); no `topics.chatId` config is needed. Thread names are assigned by the bridge from a baked compact per-slot palette. There is no agent-facing `telegram_rename_thread` tool and no separate user-facing slash command for manual thread renames.
508
508
  - Thread reuse is extension-owned through profile-scoped Workspace bindings; there is no separate `topics` config surface in the active private-chat thread model. Leader/follower record keys remain live role projections, while normalized exact-`cwd` bindings preserve identity and deterministic same-directory process suffixes across role and process changes.
@@ -513,7 +513,7 @@ Rules:
513
513
 
514
514
  Current state under the agent dir:
515
515
 
516
- - `tmp/pi-telegram/state.json` `profiles.<profile>.transport`: authoritative transport owner for `default` or a named profile, holding the bus leader identity, capability secret, heartbeat, generation, cleanup fencing epoch and polling-journal pointer. Mutations serialize through `runtime/state.json.transaction`; followers never write it. The local bus endpoint is derived from the agent directory; a `busSocketPath` field is tolerated but not required.
516
+ - `tmp/pi-telegram/state.json` `profiles.<profile>.transport`: authoritative transport owner for `default` or a named profile, holding the bus leader identity, capability secret, generation, cleanup fencing epoch and polling-journal pointer. Mutations serialize through `runtime/state.json.transaction`; followers never write it. The local bus endpoint is derived from the agent directory; a `busSocketPath` field is tolerated but not required.
517
517
  - `profiles.<profile>.workspace`: canonical Workspace/thread state; `profiles.<profile>.runtime`: volatile observable/debug projection, never routing authority, with `source: "snapshot"` and `writtenAtMs`. Every process on one profile reads this shared file, but only the active transport owner may publish either section; followers become writers only after promotion. Runtime publication never touches the canonical section, so a stale status writer cannot erase leader records. The `runtime` section mirrors `/telegram-status`-style projections: `runtime` identifies leader/follower role, lifecycle activity, and the exact polling phase/progress snapshot; `liveRoster` mirrors followers/current targets/reservations; `diagnostics` mirrors status/debug signals. The `workspace` section keeps top-level `bot` capability state such as `threadMode: "unknown" | "enabled" | "disabled"`; `threads` stores current routeable bindings; `workspaceBindings` stores dormant exact-`cwd` target/name/slot reuse hints; `bot.lastSlot` stores the compact slot cursor used when all current threads are gone; and `reservations` records short-lived slot collision guards.
518
518
  - Local bus endpoints: Unix-like platforms expose stable `tmp/pi-telegram/runtime/bus.<hash>.sock` (leader) and `runtime/f.<hash>.sock` (follower) symlinks backed by colocated private generation sockets, with bounded private external shortening only for over-long paths; native Windows uses deterministic named pipes under `\\.\pipe\pi-telegram-...`. These are transient IPC endpoints, not durable routing state.
519
519
 
@@ -528,13 +528,13 @@ All files containing routing, chat ids, thread ids, or process details use priva
528
528
  ### Leader exits cleanly
529
529
 
530
530
  - Leader stops polling and marks itself offline.
531
- - Followers detect missing heartbeat.
531
+ - Follower heartbeats go unanswered and the released or dead owner is stale.
532
532
  - One follower promotes itself after jitter/tie-break.
533
533
  - New leader resumes `getUpdates` from the persisted offset if safe.
534
534
 
535
535
  ### Leader crashes
536
536
 
537
- - Followers detect stale heartbeat.
537
+ - Followers see a dead PID, or prove a hung leader unresponsive over the bus.
538
538
  - One follower promotes itself.
539
539
  - Some updates may be delayed or skipped depending on offset persistence; dispatcher design must define this explicitly.
540
540
 
@@ -606,7 +606,7 @@ Leader command Restore now binds `updates.bindTelegramUpdateCompletionAcceptance
606
606
 
607
607
  A source-journal completion hint now also selects a ready leader Restore with retained completed or queued acceptance and unissued cleanup. Fresh admission and strict scoped ACK queries precede executor adoption. A read-only canonical observation requires one exact active binding/session/CWD/slot/target and one current leader owner with matching instance, CWD and profile key; local target equality alone cannot re-key the operation. The existing inspect-only controller preserves the original recipient/grants and observes the same-session current leader without applying identity, invoking a handler or sending follower RPC. After its await, immutable proofs are read again before settling only their proven originals. Live fences remain in storage authority callbacks; canonical observations stay outside the snapshot transaction and are repeated through cleanup awaits and before retirement. An owner change after close retains issued cleanup rather than deleting or retiring. Exact already-retained local readiness needs no republication. Native cold cases cover same-instance/successor continuation, partial/missing/foreign evidence, wrong local session/CWD/target/owner, post-await proof/recipient changes, both canonical publication rename boundaries and unknown/issued cleanup, preserving source bytes throughout. Queued ACK continuation uses the same pre-adoption proof and canonical-owner fences. After recipient inspection it rereads unproven scopes and publishes `queue-completed`, grouping only matching receipts/kinds; admission-only, missing or partial proof cannot release another original. Native single-/multi-receipt cases preserve issued grants and source bytes across canonical publication faults, with or without a prior admission fact. Producer execution, successor startup/ownership and accepted-work clearance are distinct preconditions; these fixtures do not prove actual interrupted Pi startup. Scoped ACKs still never expire, evict or release.
608
608
 
609
- The worker now has a prepared optional `beforeQueueReceiptPublished(receipt, owner, ctx, isCurrent)` barrier. Exact durable queue admission retains the original, but neither cached readiness nor its downstream observer publishes until the asynchronous acceptance publisher succeeds. Publication requires a captured exact receipt-inspection capability before and after the await, with current process/session/context/binding checks. Receipt and owner arguments are detached; grouped reports and concurrent snapshot refresh share one pending publication. Same-process cold receipt reconstruction uses that barrier without running the original handler. Callback failures hold queued work; readiness observers remain diagnostic after successful publication. Ordinary commit failures still release deferred claims synchronously. `waitForDrain` covers in-flight publication. Serialized production journal bindings now compose `inspectQueuedReceipt(expected)` with their existing strict private sibling and derive `isQueueReceiptCurrent` from the exact active recovery key. The read-only v1 observer requires every canonical ordered source ID, receipt/kind, full owner/acquisition and absence of a handoff offer. It returns detached source digests of the retained **queued** originals plus normalized-owner SHA-256; those digests are not pre-queue pending hashes or removal ACKs. Query mismatch yields no proof; malformed, corrupt, foreign or unprepared evidence throws without repair, rebinding or source mutation. Inspection uses lock-only source/config serialization, not writer admission, so observing exact accepted work does not borrow persistence permission. Native fixtures cover whole groups, owner/acquisition mismatch, offers, retained byte continuity, cold/detached proof, post-await completion/corruption and read-only authority. The production worker now wires routing's `beforeQueueReceiptPublished` publisher. It selects only retained operations overlapping that exact active-journal receipt, then reacquires fresh profile Workspace admission; ordinary receipts remain a no-op for Restore policy. A selected operation must be ready with its issued routing, exact current executor/operator and leader recipient. Canonical read-only binding/owner checks and live session/CWD/slot/target establish recipient authority before each `queued` acceptance. Exact receipt/source and owner-hash evidence is retained in the existing operation before readiness. Storage's authority predicate checks only live fences to avoid reacquiring its held snapshot transaction; canonical CAS plus pre/post snapshot reads preserve binding protection. Lost publication reply reconciles only an identical retained request/executor/operator/proof. After publication the publisher re-reads the whole receipt, and after admission returns it rechecks captured recipient guards; changed proof or authority suppresses readiness without rolling back accepted local queue work. No apply, prompt enqueue, deletion or cleanup is repeated by this publisher. Same-process worker reconstruction can republish only acceptance from its still-exact receipt; it is not actual interrupted Pi startup or a foreign-session successor grant.
609
+ The worker now has a prepared optional `beforeQueueReceiptPublished(receipt, owner, ctx, isCurrent)` barrier. Exact durable queue admission retains the original, but neither cached readiness nor its downstream observer publishes until the asynchronous acceptance publisher succeeds. Publication requires a captured exact receipt-inspection capability before and after the await, with current process/session/context/binding checks. Receipt and owner arguments are detached; grouped reports and concurrent snapshot refresh share one pending publication. Same-process cold receipt reconstruction uses that barrier without running the original handler. Callback failures hold queued work; readiness observers remain diagnostic after successful publication. Ordinary commit failures still release deferred claims synchronously. `waitForDrain` covers in-flight publication. Serialized production journal bindings now compose `inspectQueuedReceipt(expected)` with their existing strict private sibling and derive `isQueueReceiptCurrent` from the exact active recovery key. The read-only v1 observer requires every canonical ordered source ID, receipt/kind, full owner/acquisition and absence of a handoff offer. It returns detached source digests of the retained **queued** originals plus normalized-owner SHA-256; those digests are not pre-queue pending hashes or removal ACKs. Query mismatch yields no proof; malformed, corrupt, foreign or unprepared evidence throws without repair, rebinding or source mutation. Inspection uses lock-only journal source serialization, not writer admission, so observing exact accepted work does not borrow persistence permission. Native fixtures cover whole groups, owner/acquisition mismatch, offers, retained byte continuity, cold/detached proof, post-await completion/corruption and read-only authority. The production worker now wires routing's `beforeQueueReceiptPublished` publisher. It selects only retained operations overlapping that exact active-journal receipt, then reacquires fresh profile Workspace admission; ordinary receipts remain a no-op for Restore policy. A selected operation must be ready with its issued routing, exact current executor/operator and leader recipient. Canonical read-only binding/owner checks and live session/CWD/slot/target establish recipient authority before each `queued` acceptance. Exact receipt/source and owner-hash evidence is retained in the existing operation before readiness. Storage's authority predicate checks only live fences to avoid reacquiring its held snapshot transaction; canonical CAS plus pre/post snapshot reads preserve binding protection. Lost publication reply reconciles only an identical retained request/executor/operator/proof. After publication the publisher re-reads the whole receipt, and after admission returns it rechecks captured recipient guards; changed proof or authority suppresses readiness without rolling back accepted local queue work. No apply, prompt enqueue, deletion or cleanup is repeated by this publisher. Same-process worker reconstruction can republish only acceptance from its still-exact receipt; it is not actual interrupted Pi startup or a foreign-session successor grant.
610
610
 
611
611
  Native full-capacity leader Restore cases exercise positive queued publication, both rename sides, a forged returned intent with no proof, missing/unreadable receipt or a changed source digest on proof reread, authority loss, recipient change before publication/after the proof reread/after admission, and same-process worker reconstruction. Each original stays queued under its durable owner, exactly one prompt is accepted, and uncertain acceptance never publishes readiness or triggers cleanup. A native two-original media-group producer now exercises the same contract through its real unbound-group debounce, shared chooser/Restore selection, journal and worker. At full A–Z capacity, it relocates the same binding/slot and enqueues one combined prompt with one full receipt. A failed or unproven second acceptance retains the first proof but holds the entire receipt; lost post-rename reply reconciles both. A changed second digest on reread or an actual native whole-group handoff offer withholds readiness without undoing accepted work. Same-process worker reconstruction completes partial proof, preserves the first acceptance byte-for-byte, and emits one readiness observation without journal mutation or another enqueue/apply. Duplicate acceptance publication leaves canonical bytes unchanged. These are grouped prompt fixtures, not all remaining control/local outcomes or actual Pi startup acceptance.
612
612
 
@@ -643,7 +643,7 @@ This inspection authorizes no reload/restart, live-state mutation, fault injecti
643
643
  ### Split brain
644
644
 
645
645
  - Two leaders calling `getUpdates` is the main safety failure.
646
- - Lock heartbeat/takeover must be atomic enough to prevent this under normal local concurrency.
646
+ - Lock takeover must be atomic enough to prevent this under normal local concurrency.
647
647
  - Persistent competing `getUpdates` clients trigger a full transport stand-down even when the local lock still appears owned; the stopped runtime preserves accepted local work and releases only its exact ownership. See [Runtime Ownership](./architecture.md#runtime-ownership) for the threshold, diagnostic, and reconnect contract.
648
648
 
649
649
  ## Security Boundaries
@@ -657,7 +657,7 @@ This inspection authorizes no reload/restart, live-state mutation, fault injecti
657
657
 
658
658
  ## Acceptance Criteria
659
659
 
660
- - [x] The lock semantics are redesigned as Telegram bus leadership with heartbeat and stale takeover rules.
660
+ - [x] The lock semantics are redesigned as Telegram bus leadership with liveness proof and stale takeover rules.
661
661
  - [x] A first-class `TelegramTarget` can represent classic private chats and thread destinations.
662
662
  - [x] The bridge can run in classic mode with unchanged private-chat behavior.
663
663
  - [x] A live Pi instance can register as a follower when another live instance is leader.
@@ -676,7 +676,7 @@ Open live client and native Windows evidence gates for future behavior belong in
676
676
  - Bus semantics are the feature frame: Telegram threads are one Telegram UI substrate for a local multi-instance bus.
677
677
  - `TelegramTarget` and target-key helpers represent classic private chats and thread destinations.
678
678
  - Outbound ports, previews, replies, voice, attachments, chat actions, menus, sections, buttons, queue mutations, and direct local delivery carry target metadata where needed.
679
- - The transport lock distinguishes live bus leadership from ordinary classic ownership through heartbeat, leader epoch, and stale takeover rules.
679
+ - The transport lock distinguishes live bus leadership from ordinary classic ownership through leader epoch, liveness proof, and stale takeover rules.
680
680
  - The leader records live follower registration, heartbeat, thread identity, slot, and target mapping; followers do not poll `getUpdates`.
681
681
  - Local IPC is the default internal bus. Registered followers receive normalized inbound updates and send allowlisted, target-scoped Bot API calls through the leader.
682
682
  - Thread targets are current-state bindings, not historical delivery addresses. Stable restart hints require a fresh authenticated follower registration before they become live routing authority; stale/offline/failed entries remain reconciliation evidence only.
@@ -385,6 +385,8 @@ export function createTelegramManualFollowerProfileKeyResolver(input: {
385
385
 
386
386
  export interface TelegramBusFollowerElection {
387
387
  expectedOwner?: TelegramLockEntry;
388
+ /** The expected owner is bus-proven unresponsive; the lock CAS still requires it to be the current owner. */
389
+ unresponsive?: boolean;
388
390
  }
389
391
 
390
392
  export type TelegramBusFollowerPromotionHandler<TContext> = (
@@ -568,6 +570,10 @@ export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
568
570
  scheduleRetry?: (retry: () => void, delayMs: number) => void;
569
571
  getActiveContext?: () => TContext | undefined;
570
572
  promotionGraceMs?: number;
573
+ /** Bus liveness proof for a live-PID leader that no longer answers; replaces the retired file heartbeat. */
574
+ proveLeaderUnresponsive?: (owner: TelegramLockEntry) => Promise<boolean>;
575
+ /** Shared bot state says Threaded Mode is off: followers cannot exist, so recovery goes offline instead. */
576
+ isThreadModeDisabled?: () => boolean | Promise<boolean>;
571
577
  recordRuntimeEvent: (
572
578
  category: string,
573
579
  error: unknown,
@@ -755,7 +761,7 @@ export function createTelegramBusFollowerRuntimeAssembly<
755
761
  ...ports.recovery,
756
762
  registrationState: ports.registrationState,
757
763
  recordRuntimeEvent: ports.recordRuntimeEvent,
758
- getRegistrationRuntime: () => registration,
764
+ getRegistrationRuntime: () => baseRegistration,
759
765
  });
760
766
  registration = createTelegramBusFollowerRegistrationRuntime({
761
767
  ...ports.registration,
@@ -768,6 +774,15 @@ export function createTelegramBusFollowerRuntimeAssembly<
768
774
  const baseRegistration = registration;
769
775
  registration = {
770
776
  ...baseRegistration,
777
+ // Public stop is deliberate (disconnect, suspension); recovery keeps the base stop for promotion.
778
+ stop() {
779
+ recovery.halt();
780
+ baseRegistration.stop();
781
+ },
782
+ registerWithLeader(ctx, leader, options) {
783
+ recovery.resume();
784
+ return baseRegistration.registerWithLeader(ctx, leader, options);
785
+ },
771
786
  async setContext(ctx) {
772
787
  const sessionGeneration = ports.registration.getSessionGeneration?.();
773
788
  if (ports.registration.isContextActive?.(ctx) === false) return;
@@ -2202,9 +2217,18 @@ export function createTelegramBusFollowerRegistrationState(
2202
2217
  };
2203
2218
  }
2204
2219
 
2220
+ /** Heartbeat-failure recovery that a deliberate local stop halts until the next deliberate registration. */
2221
+ export type TelegramBusFollowerHeartbeatRecoveryHandler<TContext> = ((
2222
+ error: unknown,
2223
+ ctx: TContext,
2224
+ ) => Promise<void>) & { halt: () => void; resume: () => void };
2225
+
2205
2226
  export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2206
2227
  deps: TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext>,
2207
- ): (error: unknown, ctx: TContext) => Promise<void> {
2228
+ ): TelegramBusFollowerHeartbeatRecoveryHandler<TContext> {
2229
+ // Promotion stops registration internally and must keep retrying; only an operator stop or a confirmed
2230
+ // switch to classic mode halts recovery, so it never re-registers behind a disconnect.
2231
+ let halted = false;
2208
2232
  const promotionGraceMs =
2209
2233
  deps.promotionGraceMs ?? TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS;
2210
2234
  const sleep =
@@ -2246,6 +2270,10 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2246
2270
  binding?.target ? { target: binding.target } : undefined,
2247
2271
  );
2248
2272
  if (!restored) return false;
2273
+ if (halted) {
2274
+ deps.getRegistrationRuntime().stop();
2275
+ return false;
2276
+ }
2249
2277
  deps.setLifecyclePhase(undefined);
2250
2278
  safeUpdateStatus(ctx);
2251
2279
  deps.recordRuntimeEvent(
@@ -2273,6 +2301,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2273
2301
  binding: TelegramBusFollowerPromotedBinding,
2274
2302
  ) => {
2275
2303
  const retry = () => {
2304
+ if (halted) return;
2276
2305
  const activeCtx = deps.getActiveContext
2277
2306
  ? deps.getActiveContext()
2278
2307
  : fallbackCtx;
@@ -2372,11 +2401,31 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2372
2401
  ctx: TContext,
2373
2402
  carriedBinding?: TelegramBusFollowerPromotedBinding,
2374
2403
  ): Promise<void> => {
2375
- if (promotionPending) return;
2404
+ if (promotionPending || halted) return;
2376
2405
  promotionPending = true;
2377
2406
  deps.registrationState.beginRecovery();
2378
2407
  const initialBinding = carriedBinding ?? snapshotBinding();
2379
2408
  try {
2409
+ let threadModeDisabled = false;
2410
+ try {
2411
+ threadModeDisabled = (await deps.isThreadModeDisabled?.()) === true;
2412
+ } catch (stateError) {
2413
+ deps.recordRuntimeEvent("bus", stateError, {
2414
+ phase: "follower-thread-mode-read",
2415
+ });
2416
+ }
2417
+ if (threadModeDisabled) {
2418
+ halted = true;
2419
+ deps.getRegistrationRuntime().stop();
2420
+ deps.setLifecyclePhase(undefined);
2421
+ safeUpdateStatus(ctx);
2422
+ deps.recordRuntimeEvent(
2423
+ "bus",
2424
+ "Telegram follower went offline: Threaded Mode is off. Run /telegram-connect after turning it back on.",
2425
+ { phase: "follower-thread-mode-disabled" },
2426
+ );
2427
+ return;
2428
+ }
2380
2429
  const state = deps.getLeaderState();
2381
2430
  if (state.kind === "active-elsewhere") {
2382
2431
  clearRegisteredState(ctx);
@@ -2410,6 +2459,13 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2410
2459
  ) {
2411
2460
  return;
2412
2461
  }
2462
+ if (await deps.proveLeaderUnresponsive?.(graceState.lock)) {
2463
+ await promoteToLeader(error, ctx, initialBinding, {
2464
+ expectedOwner: graceState.lock,
2465
+ unresponsive: true,
2466
+ });
2467
+ return;
2468
+ }
2413
2469
  deps.setLifecyclePhase(undefined);
2414
2470
  safeUpdateStatus(ctx);
2415
2471
  deps.recordRuntimeEvent(
@@ -2445,7 +2501,14 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2445
2501
  promotionPending = false;
2446
2502
  }
2447
2503
  };
2448
- return recover;
2504
+ return Object.assign(recover, {
2505
+ halt() {
2506
+ halted = true;
2507
+ },
2508
+ resume() {
2509
+ halted = false;
2510
+ },
2511
+ });
2449
2512
  }
2450
2513
 
2451
2514
  export function createTelegramBusFollowerRegistrationRuntime<
package/lib/bus-leader.ts CHANGED
@@ -3371,6 +3371,14 @@ export function createTelegramBusLeaderEnvelopeHandler(
3371
3371
  return createUnauthorizedBusAck(envelope.requestId);
3372
3372
  }
3373
3373
  switch (envelope.kind) {
3374
+ case "bus.probe":
3375
+ // Liveness only: no registry, routing, journal or Bot API effect.
3376
+ return {
3377
+ kind: "bus.ack" as const,
3378
+ requestId: envelope.requestId,
3379
+ ok: true,
3380
+ protocol: deps.protocolIdentity,
3381
+ };
3374
3382
  case "follower.register":
3375
3383
  case "follower.restoreWorkspace": {
3376
3384
  const compatibility = getTelegramBusProtocolCompatibility({
package/lib/bus.ts CHANGED
@@ -45,7 +45,12 @@ import {
45
45
  type TelegramBusTransportRetryPolicy,
46
46
  } from "./bus-transport.ts";
47
47
  import type { TelegramThreadDisplayMode } from "./config.ts";
48
- import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS } from "./locks.ts";
48
+ import {
49
+ isSameTelegramLockOwner,
50
+ TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS,
51
+ type TelegramLockEntry,
52
+ type TelegramLockState,
53
+ } from "./locks.ts";
49
54
  import { resolveAgentDir } from "./paths.ts";
50
55
  import { getTelegramProcessBirthIdentity } from "./process-identity.ts";
51
56
  import {
@@ -131,7 +136,8 @@ export function createTelegramBusAuthSecret(): string {
131
136
  return randomBytes(32).toString("base64url");
132
137
  }
133
138
 
134
- const TELEGRAM_BUS_PROTOCOL_VERSION = 2 as const;
139
+ // v3: journal source serialization left the config transaction; mixed v2/v3 peers would not exclude each other.
140
+ const TELEGRAM_BUS_PROTOCOL_VERSION = 3 as const;
135
141
  export const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION =
136
142
  "durable-follower-admission-v1" as const;
137
143
  export const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF =
@@ -1222,6 +1228,11 @@ export type TelegramBusEnvelope = (
1222
1228
  args: unknown[];
1223
1229
  sentAtMs: number;
1224
1230
  }
1231
+ | {
1232
+ /** Side-effect-free leader liveness probe; any acknowledgement proves a responsive event loop. */
1233
+ kind: "bus.probe";
1234
+ requestId: string;
1235
+ }
1225
1236
  | {
1226
1237
  kind: "bus.ack";
1227
1238
  requestId: string;
@@ -1269,7 +1280,8 @@ export function getTelegramBusEnvelopeTrafficClass(
1269
1280
  ): TelegramBusEnvelopeTrafficClass {
1270
1281
  if (
1271
1282
  envelope.kind === "follower.register" ||
1272
- envelope.kind === "follower.restoreWorkspace"
1283
+ envelope.kind === "follower.restoreWorkspace" ||
1284
+ envelope.kind === "bus.probe"
1273
1285
  )
1274
1286
  return "bootstrap";
1275
1287
  if (envelope.kind === "bus.ack") return "response";
@@ -1579,6 +1591,9 @@ export function parseTelegramBusEnvelope(
1579
1591
  case "follower.callApi":
1580
1592
  envelope = parseCallApiEnvelope(value, requestId);
1581
1593
  break;
1594
+ case "bus.probe":
1595
+ envelope = { kind, requestId };
1596
+ break;
1582
1597
  case "bus.ack":
1583
1598
  envelope = parseAckEnvelope(value, requestId);
1584
1599
  break;
@@ -1590,6 +1605,112 @@ export function parseTelegramBusEnvelope(
1590
1605
  return envelope;
1591
1606
  }
1592
1607
 
1608
+ /**
1609
+ * One leader liveness observation. `silent` (connected or connecting, no answer within the window) and
1610
+ * `unreachable` (an endpoint that refuses connections: nobody listens) can support a takeover. A missing endpoint is
1611
+ * `unknown`: a live classic-mode leader has no socket at all. Any bytes or an orderly close prove the peer's event
1612
+ * loop handled the connection, so even an older leader rejecting the unknown kind is `responsive`.
1613
+ */
1614
+ export type TelegramBusLeaderProbeResult =
1615
+ | "responsive"
1616
+ | "silent"
1617
+ | "unreachable"
1618
+ | "unknown";
1619
+
1620
+ export function probeTelegramBusLeader(input: {
1621
+ socketPath: TelegramBusSocketPathSource;
1622
+ secret?: string;
1623
+ timeoutMs?: number;
1624
+ }): Promise<TelegramBusLeaderProbeResult> {
1625
+ const timeoutMs = input.timeoutMs ?? TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS;
1626
+ return new Promise((resolve) => {
1627
+ let settled = false;
1628
+ let socket: Socket | undefined;
1629
+ const finish = (result: TelegramBusLeaderProbeResult) => {
1630
+ if (settled) return;
1631
+ settled = true;
1632
+ clearTimeout(timer);
1633
+ socket?.destroy();
1634
+ resolve(result);
1635
+ };
1636
+ const timer = setTimeout(() => finish("silent"), timeoutMs);
1637
+ let connected = false;
1638
+ try {
1639
+ socket = createConnection(resolveTelegramBusSocketPath(input.socketPath));
1640
+ } catch {
1641
+ finish("unknown");
1642
+ return;
1643
+ }
1644
+ socket.once("connect", () => {
1645
+ connected = true;
1646
+ socket!.write(
1647
+ encodeTelegramBusEnvelope({
1648
+ kind: "bus.probe",
1649
+ requestId: `probe:${randomBytes(8).toString("hex")}`,
1650
+ ...(input.secret ? { auth: input.secret } : {}),
1651
+ }),
1652
+ );
1653
+ });
1654
+ socket.once("data", () => finish("responsive"));
1655
+ socket.once("end", () => finish("responsive"));
1656
+ socket.once("error", (error) => {
1657
+ const code = (error as NodeJS.ErrnoException).code;
1658
+ finish(
1659
+ !connected && code === "ECONNREFUSED" ? "unreachable" : "unknown",
1660
+ );
1661
+ });
1662
+ });
1663
+ }
1664
+
1665
+ /**
1666
+ * Takeover evidence replacing the retired file heartbeat: a full silent window, or an unreachable endpoint
1667
+ * confirmed again after one more window (a just-started leader binds its socket well within it). The owner must
1668
+ * stay the same throughout; the lock acquisition then CAS-checks that exact owner.
1669
+ */
1670
+ export async function proveTelegramBusLeaderUnresponsive(input: {
1671
+ probe: () => Promise<TelegramBusLeaderProbeResult>;
1672
+ isSameOwner: () => boolean;
1673
+ sleep: (ms: number) => Promise<void>;
1674
+ windowMs?: number;
1675
+ }): Promise<boolean> {
1676
+ const first = await input.probe();
1677
+ if (first === "silent") return input.isSameOwner();
1678
+ if (first !== "unreachable") return false;
1679
+ await input.sleep(input.windowMs ?? TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS);
1680
+ if (!input.isSameOwner()) return false;
1681
+ const second = await input.probe();
1682
+ return (second === "silent" || second === "unreachable") && input.isSameOwner();
1683
+ }
1684
+
1685
+ /** Composition-ready takeover proof for one profile's leader lock and endpoint. */
1686
+ export function createTelegramBusLeaderUnresponsivenessProof(deps: {
1687
+ getLeaderState: () => TelegramLockState;
1688
+ getLeaderSocketPath: () => string;
1689
+ probe?: typeof probeTelegramBusLeader;
1690
+ sleep?: (ms: number) => Promise<void>;
1691
+ }): (owner: TelegramLockEntry) => Promise<boolean> {
1692
+ const probe = deps.probe ?? probeTelegramBusLeader;
1693
+ const sleep =
1694
+ deps.sleep ??
1695
+ ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
1696
+ return (owner) =>
1697
+ proveTelegramBusLeaderUnresponsive({
1698
+ probe: () =>
1699
+ probe({
1700
+ socketPath: owner.busSocketPath ?? deps.getLeaderSocketPath(),
1701
+ secret: owner.busSecret,
1702
+ }),
1703
+ isSameOwner: () => {
1704
+ const state = deps.getLeaderState();
1705
+ return (
1706
+ state.kind === "active-elsewhere" &&
1707
+ isSameTelegramLockOwner(state.lock, owner)
1708
+ );
1709
+ },
1710
+ sleep,
1711
+ });
1712
+ }
1713
+
1593
1714
  interface TelegramBusLocalServer {
1594
1715
  start: () => Promise<void>;
1595
1716
  stop: () => Promise<void>;
package/lib/config.ts CHANGED
@@ -289,12 +289,6 @@ export interface TelegramConfigStore {
289
289
  assertExecutionCurrent?: () => void,
290
290
  commitIfOwned?: (commit: () => void) => boolean,
291
291
  ) => Promise<boolean>;
292
- /** Lock-only serialization for trusted synchronous source operations, not authorization.
293
- * Does not read/adopt config. Acquire required Workspace admission first; never
294
- * acquire owners or nest config admission here. Do not pass async callbacks:
295
- * returned promises are not protected after their synchronous prefix.
296
- */
297
- withSourceSerialization: <T>(operation: () => T) => T;
298
292
  /** Trusted synchronous publication only; caller acquires Workspace admission before this config transaction. */
299
293
  withPairingAdmission: <T>(
300
294
  profileName: string,
@@ -779,8 +773,6 @@ export function createTelegramConfigStore(
779
773
  nextConfig.allowedUserId = userId;
780
774
  setEffectiveConfig(nextConfig);
781
775
  },
782
- withSourceSerialization: (operation) =>
783
- withTelegramFileTransaction(`${configPath}.transaction`, operation),
784
776
  withPairingAdmission: (profileName, tokenSha256, publish) =>
785
777
  withPersistedPairingProfile(
786
778
  profileName,
package/lib/extension.ts CHANGED
@@ -181,13 +181,15 @@ export default function (pi: Pi.ExtensionAPI) {
181
181
  },
182
182
  getProfileName: configStore.getActiveProfileName,
183
183
  });
184
+ const withTelegramJournalSourceSerialization =
185
+ Journal.createTelegramJournalSourceSerialization();
184
186
  const telegramJournalBindingRuntime =
185
187
  Journal.createTelegramUpdateJournalBindingRuntime({
186
188
  base: {
187
189
  getProfileName: configStore.getActiveProfileName,
188
190
  getBotToken: configStore.getBotToken,
189
191
  getBotId: getTelegramBotId,
190
- withSourceSerialization: configStore.withSourceSerialization,
192
+ withSourceSerialization: withTelegramJournalSourceSerialization,
191
193
  onRecovery(event) {
192
194
  recordRuntimeEvent(
193
195
  "recovery",
@@ -259,6 +261,11 @@ export default function (pi: Pi.ExtensionAPI) {
259
261
  return telegramLeaderJournalPath.createJournalPath();
260
262
  },
261
263
  });
264
+ const proveTelegramLeaderUnresponsive =
265
+ Bus.createTelegramBusLeaderUnresponsivenessProof({
266
+ getLeaderState: lockRuntime.getState,
267
+ getLeaderSocketPath: getTelegramBusSocketPath,
268
+ });
262
269
  const telegramSessionContextStore =
263
270
  Lifecycle.createTelegramSessionContextStore<Pi.ExtensionContext>({
264
271
  getIdentity(ctx) {
@@ -1368,7 +1375,7 @@ export default function (pi: Pi.ExtensionAPI) {
1368
1375
  botToken,
1369
1376
  botId: getTelegramBotId(),
1370
1377
  });
1371
- return configStore.withSourceSerialization(function () {
1378
+ return withTelegramJournalSourceSerialization(function () {
1372
1379
  return Journal.isTelegramThreadCleanupJournalNamespaceClear({
1373
1380
  directory: Paths.resolveTelegramTempDir(),
1374
1381
  profile,
@@ -1988,6 +1995,11 @@ export default function (pi: Pi.ExtensionAPI) {
1988
1995
  },
1989
1996
  recovery: {
1990
1997
  getLeaderState: lockRuntime.getState,
1998
+ proveLeaderUnresponsive: proveTelegramLeaderUnresponsive,
1999
+ async isThreadModeDisabled() {
2000
+ await threadStore.refresh?.();
2001
+ return threadStore.getBotState().threadMode === "disabled";
2002
+ },
1991
2003
  setLifecyclePhase: telegramBusFollowerControlState.setLifecyclePhase,
1992
2004
  updateStatus,
1993
2005
  promoteToLeader: promoteTelegramBusFollowerToLeader,
@@ -2347,6 +2359,7 @@ export default function (pi: Pi.ExtensionAPI) {
2347
2359
  const threadAwarePollingPorts = telegramThreadCapabilityRuntime.pollingPorts;
2348
2360
  const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
2349
2361
  lock: lockRuntime,
2362
+ proveOwnerUnresponsive: proveTelegramLeaderUnresponsive,
2350
2363
  resetDamagedState() {
2351
2364
  return Locks.resetDamagedTelegramRuntimeState(
2352
2365
  Paths.resolveTelegramStatePath(),
package/lib/journal.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  import {
47
47
  decodeTelegramSessionDirectoryName,
48
48
  getTelegramProfilePathSuffix,
49
+ resolveTelegramRuntimeDir,
49
50
  TELEGRAM_DEFAULT_PROFILE_NAME,
50
51
  } from "./paths.ts";
51
52
  import {
@@ -4889,6 +4890,21 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
4889
4890
  return runtime;
4890
4891
  }
4891
4892
 
4893
+ /**
4894
+ * Journal-owned cross-family source serializer, lock-only and never authorization. Its guard lives
4895
+ * in the runtime service directory, so journal work never touches `telegram.json` or its
4896
+ * transaction. Acquire Workspace admission and any sender (config) admission first; never acquire
4897
+ * owners inside it. Callbacks must be synchronous: a returned promise is unprotected after its
4898
+ * synchronous prefix.
4899
+ */
4900
+ export function createTelegramJournalSourceSerialization(
4901
+ getTransactionPath: () => string = () =>
4902
+ join(resolveTelegramRuntimeDir(), "journals.transaction"),
4903
+ ): <T>(operation: () => T) => T {
4904
+ return (operation) =>
4905
+ withTelegramFileTransaction(getTransactionPath(), operation);
4906
+ }
4907
+
4892
4908
  export function createTelegramUpdateJournalStore(
4893
4909
  options: TelegramUpdateJournalStoreOptions,
4894
4910
  ): TelegramUpdateJournalStore {
@@ -7217,10 +7233,17 @@ function createJournalStoreCore(
7217
7233
  serializedBytes: published.serializedBytes,
7218
7234
  };
7219
7235
  });
7236
+ // Sender admission is the outer config authority; journal serialization nests inside it.
7237
+ const serializedAppend = (preApprovalExcluded?: boolean) =>
7238
+ withSourceSerialization
7239
+ ? withSourceSerialization(() =>
7240
+ appendWithEvidence(preApprovalExcluded),
7241
+ )
7242
+ : appendWithEvidence(preApprovalExcluded);
7220
7243
  const append = () => {
7221
7244
  if (withPairedAdmission) {
7222
7245
  const result = withPairedAdmission(normalizedUpdates, () =>
7223
- appendWithEvidence(),
7246
+ serializedAppend(),
7224
7247
  );
7225
7248
  if (!result.admitted)
7226
7249
  throw createJournalError(
@@ -7231,10 +7254,8 @@ function createJournalStoreCore(
7231
7254
  return result.value;
7232
7255
  }
7233
7256
  if (withPairingAdmission)
7234
- return withPairingAdmission(appendWithEvidence);
7235
- return withSourceSerialization
7236
- ? withSourceSerialization(() => appendWithEvidence())
7237
- : appendWithEvidence();
7257
+ return withPairingAdmission(serializedAppend);
7258
+ return serializedAppend();
7238
7259
  };
7239
7260
  const admittedAppend = () => {
7240
7261
  if (!workspaceAdmission) return append();