@llblab/pi-telegram 0.23.3 → 0.24.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.
package/README.md CHANGED
@@ -45,7 +45,7 @@ Run this inside Pi:
45
45
  /telegram-setup
46
46
  ```
47
47
 
48
- Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved token, setup offers it as the default. If no saved token exists, setup can prefill from `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN`, or `TELEGRAM_KEY`. Named profiles are optional; the ordinary `/telegram-setup` and `/telegram-connect` flow keeps using the default profile. Use `/telegram-setup <name>` only when you want an additional bot profile. Cancelling or failing named-profile token validation leaves the currently active profile and polling runtime unchanged; setup reports the profile as saved and connected only after polling startup succeeds.
48
+ Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved token, setup offers it as the default. If no saved token exists, setup can prefill from `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN`, or `TELEGRAM_KEY`. Bot/session identity persists under `profiles.default`; shared handlers and assistant/voice/time settings remain top-level. `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Use `/telegram-setup <name>` only when you want an additional bot profile. Cancelling or failing named-profile token validation leaves the currently active profile and polling runtime unchanged; setup reports the profile as saved and connected only after polling startup succeeds.
49
49
 
50
50
  ### 3. Connect this Pi instance and its active session
51
51
 
@@ -53,7 +53,7 @@ Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved tok
53
53
  /telegram-connect
54
54
  ```
55
55
 
56
- The connected Pi instance owns Telegram polling. Use `/telegram-connect <name>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the unnamed default profile keeps legacy paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers.
56
+ The connected Pi instance owns Telegram polling. Use `/telegram-connect <name>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the `default` profile keeps unsuffixed runtime paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers.
57
57
 
58
58
  ### 4. Pair your Telegram account
59
59
 
@@ -148,9 +148,9 @@ Run these inside Pi.
148
148
 
149
149
  | Command | Purpose |
150
150
  | --- | --- |
151
- | `/telegram-setup` | Save or update the default bot token |
151
+ | `/telegram-setup` / `/telegram-setup default` | Save or update `profiles.default` |
152
152
  | `/telegram-setup <profile>` | Save or update a named-profile bot token |
153
- | `/telegram-connect` | Activate the default profile and acquire its transport ownership |
153
+ | `/telegram-connect` / `/telegram-connect default` | Activate `profiles.default` and acquire its transport ownership |
154
154
  | `/telegram-connect <profile>` | Activate a named profile and acquire its transport ownership |
155
155
  | `/telegram-disconnect` | Stop polling and release ownership; in Threaded Mode, confirm deletion of this instance's current Telegram thread |
156
156
  | `/telegram-status` | Inspect connection, mode, queue, transport, and recent diagnostics |
@@ -257,7 +257,6 @@ A Telegram prompt is a normal model turn in the active Pi session and therefore
257
257
  - [Extension Sections](./docs/sections.md) — Telegram-native companion UI surfaces.
258
258
  - [Updates](./docs/updates.md) — update handler registry and callback interop.
259
259
  - [Multi-Instance Bus](./docs/multi-instance-bus.md) — leader/follower routing in Threaded Mode.
260
- - [Locks](./docs/locks.md) — singleton ownership and shared lock conventions.
261
260
  - [UI Style](./docs/ui-style.md) — menu, emoji, labels, dialogs, and inline keyboard standards.
262
261
  - [Callback Namespaces](./docs/callback-namespaces.md) — callback ownership and routing.
263
262
  - [Command Templates](./docs/command-templates.md) — handler command-template conventions.
package/docs/README.md CHANGED
@@ -20,5 +20,4 @@ Living index of project documentation in `/docs`.
20
20
  - [multi-instance-bus.md](./multi-instance-bus.md) — Optional multi-instance Telegram bus architecture: profile-scoped transport, leader/follower routing, thread targets, instance slots, manual follower registration, and recovery semantics
21
21
  - [sections.md](./sections.md) — Telegram Extension Sections Standard: registration contract, context ports, callback routing, navigation hierarchy, and demo reference for pi extensions that want Telegram UI surfaces
22
22
  - [voice.md](./voice.md) — Voice integration guide: detection, reply policy, STT/TTS provider registration, provider-owned conversion, and transparent interception
23
- - [locks.md](./locks.md) — Shared `locks.json` standard for singleton extension ownership
24
23
  - [ui-style.md](./ui-style.md) — Inline UI style guide for buttons, toggles, tabs, option lists, cards, and dialogs
@@ -63,7 +63,7 @@ The repository uses a **Flat Domain DAG**:
63
63
  - `index.ts`: composition root for live ports, session-state ports, transport adapters, and lifecycle registration. It exposes cross-domain wiring but does not own mutable domain state or reusable adapters.
64
64
  - `api`: Bot API helpers, retries, uploads/downloads, temp cleanup, byte limits, chat actions, lazy token clients, and API error recording.
65
65
  - `config` / `setup`: `telegram.json`, bot token setup, named bot/session profiles, first-user pairing, authorization, env fallback, atomic persistence, effective config views, and live config accessors.
66
- - `locks` / `polling`: serialized singleton lock storage, exact-owner epoch exposure, process-global reload generations, lock-aware polling lifecycle/takeover/follower registration, and the cohesive classic-vs-Threaded capability state/monitor/observation/polling orchestration. Polling also owns long-poll controller state, offset admission/persistence, and poll-loop wiring.
66
+ - `locks` / `polling`: extension-local transport owner storage, exact-owner epoch exposure, process-global reload generations, owner-aware polling lifecycle/takeover/follower registration, and the cohesive classic-vs-Threaded capability state/monitor/observation/polling orchestration. Polling also owns long-poll controller state, offset admission/persistence, and poll-loop wiring.
67
67
  - `bus` / `bus-api` / `bus-leader` / `bus-follower` / `ownership` / `target`: Threaded Mode multi-instance bus contracts, profile-scoped process/endpoint identity, local leader/follower IPC, leader-only orchestration, follower-side manual registration/session runtime, follower-routed Bot API calls, live message ownership, and `{ chatId, threadId? }` target identity. `bus` owns shared protocol, process identity, profile-aware local endpoints, and IPC primitives; `bus-leader` owns leader runtime, leader envelope handling, activation scheduling, and leader polling/server/prune orchestration; `bus-follower` owns process-stable manual-follower keys plus this Pi instance's follower-side registration, heartbeat, one-sequence authenticated client assembly, forwarded-update adaptation/receiving, recovery retry defaults, and routed API caller without any process spawning.
68
68
  - `sync`: demand-driven Telegram reconciliation, mutable sync-slice state, nested provisioning activity, and local assumption policy. It does not own a complete Telegram bot read-model; Bot API lacks a complete topic/thread listing surface. It owns sync slices, invalidation triggers, config-persist invalidation sequencing, stale-topic API recovery adaptation, observation intake, status/debug freshness, and reconciliation scheduling across bot identity, pairing assumptions, live target bindings, reservations, and transport health after meaningful observable signals. It should call narrower domain primitives rather than letting `index.ts`, `threads`, or `status` accumulate cross-cutting reconciliation policy.
69
69
  - `thread-reconciler`: Threaded Mode control-plane planning for Telegram thread/tab lifecycle. It owns the reconciliation state machine (`stable`, `provisioning`, `sync-required`, `cleanup-required`), pure plans, proof-before-delete rules, pending-provision protection, fresh-creation grace windows, leader-epoch checks, and the single policy authority for destructive thread cleanup actions. It excludes live Telegram API calls, inbound routing, menu rendering, and direct persistence.
@@ -103,7 +103,9 @@ Mirrored domain regressions live in `/tests/*.test.ts`. Shared test fixtures sho
103
103
 
104
104
  ## Configuration And Ownership
105
105
 
106
- Telegram configuration lives in `~/.pi/agent/telegram.json`. Polling ownership lives separately in `~/.pi/agent/locks.json` under `@llblab/pi-telegram`.
106
+ Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identity (`botToken`, `botUsername`, `botId`, `allowedUserId`, `lastUpdateId`) persists only under `profiles.default` or `profiles.<name>`; shared handlers and assistant/voice/time settings stay top-level. Authoritative transport ownership lives separately in the pi-telegram-private `~/.pi/agent/tmp/telegram/owners.json` store. Its top-level slots are `default` and validated named profile names; unrelated extensions never read or write this file.
107
+
108
+ `telegram.json` is one global cross-instance configuration document. Ordinary reads rely on atomic publication and do not take the mutation guard. Every cooperating Pi instance persists only its recursive delta from the snapshot it loaded, merges that delta into the latest disk document inside `telegram.json.transaction`, and publishes atomically only when the semantic result differs; a no-op merge adopts the newer disk snapshot in memory without replacing the file. Unrelated global and profile changes therefore survive stale writers, while `lastUpdateId` additionally merges monotonically. Two serialized writers changing the same leaf use commit order, so the later local delta wins. A non-transactional external editor cannot participate in that conflict protocol: it should write through same-directory atomic replacement while Pi is idle, then let instances reload; an editor racing the transaction may lose its same-leaf change and must retry from the resulting file.
107
109
 
108
110
  ### Setup Flow
109
111
 
@@ -113,29 +115,38 @@ Telegram configuration lives in `~/.pi/agent/telegram.json`. Polling ownership l
113
115
  2. Otherwise use the first supported Telegram token environment variable.
114
116
  3. Otherwise show the example placeholder.
115
117
 
116
- `ctx.ui.input()` only supports placeholder text, so setup uses `ctx.ui.editor()` when a real default must appear already filled in. Persisted config is written through a private temp file plus atomic rename and left with `0600` permissions.
118
+ `ctx.ui.input()` only supports placeholder text, so setup uses `ctx.ui.editor()` when a real default must appear already filled in. Bare and explicit `default` setup/connect commands address the same `profiles.default` entry. Persisted config is written through a private temp file plus atomic rename and left with `0600` permissions. On first load, legacy root identity moves into `profiles.default` in that same serialized atomic transaction when no conflicting canonical value exists; identical duplicates collapse, complementary fields merge, and conflicts reject the load without modifying the file.
117
119
 
118
120
  ### Runtime Ownership
119
121
 
120
- - `/telegram-connect` acquires or moves singleton polling ownership before polling starts.
121
- - `/telegram-disconnect` stops polling and releases ownership. In Threaded Mode it first names the current thread in a destructive confirmation, then tears down the disconnecting instance's bound Telegram thread: leaders delete their own thread directly, and followers send an authenticated exact-generation disconnect envelope and wait for confirmed leader cleanup before unregistering. Unconfirmed cleanup keeps binding/routing state available for an explicit retry.
122
- - Session start schedules Telegram polling resume asynchronously only when the existing lock already points at the current `pid`/`cwd`, or when a stale same-`cwd` lock can be safely replaced after process restart. Startup and `/resume` should not wait on Telegram leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
123
- - Pi `print`/`json` run modes stay passive: they do not start or resume Telegram polling even if a lock is present. Older Pi runtimes without `ctx.mode` keep the previous compatibility behavior.
124
- - Inherited child sessions that see the same `telegram.json` but do not own the `pid`/`cwd` lock must not auto-start polling or call `getUpdates` unless the operator force-takes ownership.
125
- - Session replacement suspends polling/watchers without releasing ownership so the next session-start hook in the same process can resume. A registered follower snapshots its assigned target into a short-lived same-process handoff, stops the old receiver/heartbeat, and automatically re-registers the new session context through the live leader without marking or replacing its Telegram thread.
126
- - Live polling owners require explicit takeover confirmation.
127
- - Long-lived polling timers use snapshotted ownership context and stop local polling when the lock no longer points at their own process.
128
- - `locks.json` owns only external Telegram control/polling. Local extension and accepted queue state remain per Pi instance when ownership moves, but previews, final delivery, dispatch transport mutations, and other delayed work stop until exact direct or follower authority becomes valid again; ownership loss never permits delivery through replacement transport.
122
+ - `/telegram-connect` acquires or moves the active profile's owner slot before polling starts. `/telegram-disconnect` stops polling and releases only that exact slot. In Threaded Mode disconnect first names the current thread in a destructive confirmation, then tears down the disconnecting instance's bound Telegram thread: leaders delete their own thread directly, and followers send an authenticated exact-generation disconnect envelope and wait for confirmed leader cleanup before unregistering. Unconfirmed cleanup keeps binding/routing state available for an explicit retry.
123
+ - Session start schedules polling resume asynchronously only when the owner slot already points at the current `pid`/`cwd`, or when a stale same-`cwd` owner can be safely replaced after process restart. Startup and `/resume` do not wait on leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
124
+ - Pi `print`/`json` run modes stay passive. Inherited child sessions that share `telegram.json` but do not own the exact `pid`/`cwd` slot must not poll or call `getUpdates` unless the operator force-takes ownership.
125
+ - Session replacement suspends polling/watchers without releasing ownership so the next session in the same process can resume. A registered follower snapshots its assigned target into a short-lived same-process handoff, stops the old receiver/heartbeat, and re-registers through the live leader without marking or replacing its Telegram thread.
126
+ - Live external owners require explicit takeover confirmation. Long-lived timers compare against snapshotted owner identity and stop local transport work when the slot no longer matches.
127
+ - `owners.json` owns only Telegram transport control. Local extension and accepted queue state remain per Pi instance when ownership moves, but previews, final delivery, dispatch transport mutations, and delayed Bot API work fail closed until exact direct or follower authority becomes valid again.
128
+ - Exact ownership remains checked every second, while the durable owner heartbeat refresh runs every two seconds and becomes stale after eight seconds. This keeps replacement detection responsive while halving steady-state atomic `owners.json` rewrites without changing the cross-platform file-transaction authority. Every acquisition, refresh, release, takeover, and stale recovery serializes through the sibling `owners.json.transaction` guard. The guard publishes one private generation-named owner record atomically, validates filename/payload generation agreement, fences stale recovery and delayed release against replacement-owner ABA, and fails closed on malformed state, unverifiable ownership, contention timeout, or unsupported filesystem behavior. The JSON store publishes through a private same-directory temporary file and atomic rename; atomic payload replacement does not replace transaction serialization.
129
+ - `owners.json` is authoritative and private. `state.json` remains an observable snapshot, `logs.jsonl` remains diagnostics, and followers remain authenticated bus registrations rather than ownership-file writers.
130
+
131
+ ### Persistence I/O Baseline
132
+
133
+ The three runtime files have different authority and write pressure. Preserve that distinction when optimizing them:
134
+
135
+ - `owners.json` is safety-critical transport authority. Acquire, release, takeover, stale recovery, and two-second leader lease refresh mutate it. The steady-state baseline is one cached atomic rewrite every two seconds per active profile, or 43,200 refreshes/day; one-second ownership checks are read-only. Every mutation serializes the full cross-process read/check/write through `owners.json.transaction`.
136
+ - `state.json` combines recovery-critical thread/capability state with observational runtime projections. Every explicit thread-store `persist()` builds a semantic snapshot, but an unchanged payload skips temporary-file creation and rename after ignoring `writtenAtMs`; changed snapshots retain the full atomic replacement path. Diagnostics scheduling coalesces requests across a bounded 100 ms window. Only the exact transport owner commits; non-owners reload current disk state instead of publishing.
137
+ - `logs.jsonl` is fail-soft observational evidence, never routing authority. Runtime events admitted in one JavaScript turn batch by captured profile path into one size check, one profile-wide file transaction, and one append while preserving event order. Batching adds no timer or shutdown-loss window; separate profiles remain isolated, and one failed group does not drop another. Scope reset and rotation retain their serialized copy/replace path. The 5 MiB value is a rotation threshold: an authorized writer rotates between batched records before the next record crosses it, so overshoot is bounded to one admitted record plus reset metadata; a writer without reset authority defers rotation to the owner.
138
+
139
+ This baseline counts write-producing code paths rather than filesystem implementation details that vary between ext4, APFS, NTFS, and network-backed home directories. Optimization evidence should compare these deterministic triggers first, then use platform smoke evidence for rename, named-pipe, crash, and cleanup behavior. Recovery-critical `state.json` fields are `bot`, `identities`, `reservations`, `pendingProvisions`, `syncObservations`, and `threads`; `runtime`, `liveRoster`, `diagnostics`, and `writtenAtMs` are observational and may use bounded coalescing when authority checks remain unchanged.
129
140
 
130
- Deleting `locks.json` resets runtime ownership without deleting Telegram configuration.
141
+ Version `0.24.0` intentionally does not read or migrate the former agent-level `locks.json`; upgrading resets Telegram ownership. Run `/telegram-connect` when a fresh owner is not elected automatically. Delete `~/.pi/agent/tmp/telegram/owners.json` and its stale transaction guard, when no owner process is live, to reset only pi-telegram transport ownership without deleting configuration, diagnostics, or other extensions' state.
131
142
 
132
143
  ### Threaded Mode Multi-Instance Bus
133
144
 
134
145
  Telegram private-chat Threaded Mode is the public switch for multi-instance Telegram operation. Classic single-DM polling is the base mode. When Telegram private-chat threads are available for the bot, the bridge enables the local leader/follower bus automatically; when threads are unavailable or later disabled, the bridge returns to classic single-DM polling as a first-class mode.
135
146
 
136
- Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes singleton locks, diagnostics logs, state files, thread/bus owner keys, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the default profile. A different selected profile is a parallel bot runtime: its locks, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and from other named profiles while shared bridge settings remain top-level/global. The default profile preserves legacy state, log, socket, and named-pipe paths for compatibility.
147
+ Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes the `owners.json` slot, diagnostics logs, state files, thread/bus ownership, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the `default` slot. A different selected profile is a parallel bot runtime: its owner slot, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and other named profiles while shared bridge settings remain top-level/global.
137
148
 
138
- Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; profile bot/session fields and observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup is limited to stale scratch files and cannot redirect live traffic.
149
+ Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup is limited to stale scratch files and cannot redirect live traffic.
139
150
 
140
151
  When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping: it preserves the follower thread binding and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
141
152
 
@@ -71,16 +71,16 @@ The operator experience:
71
71
 
72
72
  In Threaded Mode, the lock means "this instance is the current Telegram bus leader" rather than "this instance is the only usable Telegram extension".
73
73
 
74
- Classic lock meaning:
74
+ Classic ownership meaning:
75
75
 
76
76
  ```text
77
- locks.json / @llblab/pi-telegram -> polling/control owner
77
+ tmp/telegram/owners.json / <profile-slot> -> polling/control owner
78
78
  ```
79
79
 
80
80
  Threaded Mode meaning:
81
81
 
82
82
  ```text
83
- locks.json / @llblab/pi-telegram -> bus leader identity + heartbeat
83
+ tmp/telegram/owners.json / <profile-slot> -> bus leader identity + 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. Pi session replacement (`new`) changes follower agent context, not bus membership: a registered follower preserves its registration and refreshes the live context instead of disconnecting. 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.
@@ -147,7 +147,7 @@ A registered instance exposes:
147
147
 
148
148
  ## Leader Election
149
149
 
150
- Leader election is heartbeat-gated and lock-backed:
150
+ 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.
151
151
 
152
152
  1. On startup, read the Telegram lock.
153
153
  2. If no leader exists, acquire leadership and start polling.
@@ -161,9 +161,9 @@ Followers first try to re-register after leader reload or unknown-heartbeat resp
161
161
 
162
162
  Implemented transport:
163
163
 
164
- ### Local IPC endpoint under agent temp dir
164
+ ### Local IPC endpoint with bounded native paths
165
165
 
166
- Leader opens a local Node `net` endpoint: a Unix-domain socket under the agent temp directory on Unix-like platforms, or a deterministic Windows named pipe (`\\.\pipe\pi-telegram-...`) on native Windows. On Unix, each server listens on a private generation socket and atomically publishes the stable profile path as a relative symlink; delayed shutdown closes only its private path and cannot remove a replacement generation's link. Followers register, heartbeat, and exchange routed events. The transport boundary owns endpoint derivation, socket-vs-pipe detection, bounded operation-aware retry policy, timeout/transient IPC error classification, endpoint reachability probes, and request-scoped transport events. Follower registration uses a longer registration-specific response timeout than ordinary heartbeat/forwarding calls because the leader may need to provision a Telegram thread before it can return the assigned target; timing out that handshake leaves a visible tab with no follower heartbeat. Keep this handshake to the true critical path: create/reuse the target, persist the live binding, and return it. Connected notices and replaced-thread reconciliation cleanup are non-critical and should run after registration so a follower becomes routable before Telegram client/server UI convergence work finishes.
166
+ Leader opens a local Node `net` endpoint: a Unix-domain socket under the agent temp directory on Unix-like platforms, or a deterministic Windows named pipe (`\\.\pipe\pi-telegram-...`) on native Windows. A filesystem-style endpoint supplied by legacy state or a transport harness normalizes deterministically to the same Windows pipe boundary before listen/connect. On Unix, an endpoint that would exceed conservative domain-socket pathname limits maps to a private user-scoped, hash-derived path under the OS temp directory; ordinary agent paths remain under `tmp/telegram`. On Unix, each server listens on a private generation socket and atomically publishes the stable profile path as a relative symlink; delayed shutdown closes only its private path and cannot remove a replacement generation's link. Followers register, heartbeat, and exchange routed events. The transport boundary owns endpoint derivation, socket-vs-pipe detection, bounded operation-aware retry policy, timeout/transient IPC error classification, endpoint reachability probes, and request-scoped transport events. Follower registration uses a longer registration-specific response timeout than ordinary heartbeat/forwarding calls because the leader may need to provision a Telegram thread before it can return the assigned target; timing out that handshake leaves a visible tab with no follower heartbeat. Keep this handshake to the true critical path: create/reuse the target, persist the live binding, and return it. Connected notices and replaced-thread reconciliation cleanup are non-critical and should run after registration so a follower becomes routable before Telegram client/server UI convergence work finishes.
167
167
 
168
168
  Pros:
169
169
 
@@ -200,7 +200,7 @@ Current portability audit:
200
200
 
201
201
  - Local bus transport: adapted. Unix-like platforms use filesystem socket paths; native Windows uses named pipes so no POSIX socket pathname is required.
202
202
  - Bus endpoint permissions: Unix sockets/directories use `chmod`; Windows named-pipe endpoints skip POSIX chmod/unlink path handling because the pipe is not a filesystem node.
203
- - Shared lock/config/state/temp files: path construction uses `path.join`/`path.resolve` under the Pi agent directory. File permission calls remain best-effort private-mode hardening; native Windows may emulate POSIX modes, so broad Windows ACL auditing is outside this extension's current local-bus baseline.
203
+ - Ownership/config/state/temp files: path construction uses `path.join`/`path.resolve` under the Pi agent directory. File permission calls remain best-effort private-mode hardening; native Windows may emulate POSIX modes, so broad Windows ACL auditing is outside this extension's current local-bus baseline.
204
204
  - Process liveness: lock ownership uses `process.kill(pid, 0)`, which Node supports on Windows for existence checks. Cross-user permission failures are treated as alive, matching Unix semantics.
205
205
  - Shell/provider commands: outbound handler command templates remain operator-configured and platform-dependent; Threaded Mode bus portability does not guarantee every configured STT/TTS/shell provider is Windows-native.
206
206
  - Manual follower identity: process ids are used as local liveness/profile hints only, not cross-machine identifiers.
@@ -323,15 +323,21 @@ Target-scoped state requirements:
323
323
 
324
324
  There is no public `telegram.json` switch for the bus. Telegram private-chat Threaded Mode is the runtime switch: when Telegram exposes threads for the bot, the bridge enables the local bus; when Telegram runs as an ordinary private DM, the bridge uses classic private-chat flow as the base mode.
325
325
 
326
- Typical config remains just bot identity and authorization:
326
+ Typical config remains just bot identity and authorization, stored in the canonical default profile:
327
327
 
328
328
  ```json
329
329
  {
330
- "botToken": "...",
331
- "allowedUserId": 123456789
330
+ "profiles": {
331
+ "default": {
332
+ "botToken": "...",
333
+ "allowedUserId": 123456789
334
+ }
335
+ }
332
336
  }
333
337
  ```
334
338
 
339
+ Named bots use sibling `profiles.<name>` entries. Shared bridge settings remain top-level.
340
+
335
341
  Rules:
336
342
 
337
343
  - 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.
@@ -345,8 +351,8 @@ Rules:
345
351
 
346
352
  Current state under the agent dir:
347
353
 
348
- - `locks.json`: current bus leader identity, capability secret, heartbeat, and cleanup fencing epoch. The local bus endpoint is derived from the agent directory by default; legacy `busSocketPath` entries are tolerated but are not required.
349
- - `tmp/telegram/state.json`: volatile extension+bot observable/debug snapshot, not routing authority. It writes `source: "snapshot"` and `writtenAtMs` so consumers do not confuse it with an authoritative database. Every process on one Telegram profile reads this shared path, but only the active transport lock owner may persist it; followers become writers only after promotion. Status-only persistence refreshes disk-backed bindings before serialization so an already-loaded stale view cannot erase newer leader records. It mirrors `/telegram-status`-style projections: top-level `bot` stores bot-wide capability state such as `threadMode: "unknown" | "enabled" | "disabled"`, `runtime` identifies leader/follower role and process status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors status/debug signals, `threads` stores current routeable bindings, `bot.lastSlot` stores the compact slot cursor used when all current threads are gone, and `reservations` records short-lived slot collision guards.
354
+ - `tmp/telegram/owners.json`: authoritative extension-local transport owners keyed by `default` or named profile. Each owner contains the bus leader identity, capability secret, heartbeat, generation, and cleanup fencing epoch. Mutations serialize through `owners.json.transaction`; followers never write owner slots. The local bus endpoint is derived from the agent directory by default; legacy `busSocketPath` entry fields are tolerated inside current owner records but are not required.
355
+ - `tmp/telegram/state.json`: volatile extension+bot observable/debug snapshot, not routing authority. It writes `source: "snapshot"` and `writtenAtMs` so consumers do not confuse it with an authoritative database. Every process on one Telegram profile reads this shared path, but only the active transport owner may persist it; followers become writers only after promotion. Status-only persistence refreshes disk-backed bindings before serialization so an already-loaded stale view cannot erase newer leader records. It mirrors `/telegram-status`-style projections: top-level `bot` stores bot-wide capability state such as `threadMode: "unknown" | "enabled" | "disabled"`, `runtime` identifies leader/follower role and process status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors status/debug signals, `threads` stores current routeable bindings, `bot.lastSlot` stores the compact slot cursor used when all current threads are gone, and `reservations` records short-lived slot collision guards.
350
356
  - Local bus endpoints: Unix-like platforms expose stable `tmp/telegram/bus.sock` and `tmp/telegram/followers/*` symlinks backed by private generation sockets; native Windows uses deterministic named pipes under `\\.\pipe\pi-telegram-...`. These are transient IPC endpoints, not durable routing state.
351
357
 
352
358
  The bridge must not keep a separate durable `telegram-targets.json` history. `state.json` retains current stable manual-follower bindings as restart hints, but they never authorize routing without a matching authenticated live registration. Stale/offline/failed observations are not reusable delivery authority. `sync` remains event-driven assumption reconciliation rather than a full Telegram bot-state mirror because Bot API exposes no complete thread listing surface. Non-current routeable thread bindings are pruned during load/persist; old session records must not be retained just to compute the next slot because `bot.lastSlot` is the only durable cursor. Previous-process leader bindings are treated as occupied TTL-bounded reservations until Telegram confirms deletion: reload/startup may close/delete/probe the old thread, known reservations are retried proactively on leader startup, and if Telegram still accepts the old thread id, the new leader should provision the next free slot (`B`, `C`, …) rather than creating a duplicate same-letter tab or blocking startup on Telegram UI convergence. Routing must use live current threads/follower registry, never reservations. The bus leader provisions its own thread during bus startup/connect and provisions follower threads on `follower.register`; registered followers also live in the leader's in-memory registry and communicate over the local bus socket. The live follower registry can resolve a follower by exact `{ chatId, threadId? }`; the leader uses that target ownership to forward message and edited-message updates to followers, and the follower receiver accepts those updates in addition to callbacks and reactions. Terminal status and `[telegram|thread:name]` resolve the matching current-instance identity through the same target-aware path, preferring registered local metadata over stale shared bindings. Media album grouping and split-text coalescing keys include the thread target, queue reaction mutations can scope by chat/thread to avoid cross-target message-id collisions, active-turn target is exposed for lifecycle cleanup and local direct-tool defaults, transport reply dedup is chat/thread-scoped, stored menu state is keyed by chat/message so callback state lookup cannot collide across chats, and generated button turns plus section prompt/open actions preserve the callback thread target. `telegram_message` and immediate `telegram_attach` delivery can also carry an explicit `thread_id` with `chat_id`; when a follower is registered, their default direct-tool target is the assigned thread target and the bus-aware API runtime routes the send through the leader instead of calling Bot API transport locally.
@@ -78,12 +78,16 @@ Configuration lives in `~/.pi/agent/telegram.json` unless `PI_CODING_AGENT_DIR`
78
78
  Stable config keys:
79
79
 
80
80
  ```ts
81
- interface TelegramConfig {
82
- botToken?: string;
81
+ interface TelegramBotProfile {
82
+ botToken: string;
83
83
  botUsername?: string; // runtime-managed
84
84
  botId?: number; // runtime-managed
85
85
  allowedUserId?: number;
86
86
  lastUpdateId?: number; // runtime-managed
87
+ }
88
+
89
+ interface TelegramConfig {
90
+ profiles?: Record<string, TelegramBotProfile>; // includes `default`
87
91
  inboundHandlers?: TelegramInboundHandlerConfig[];
88
92
  attachmentHandlers?: TelegramInboundHandlerConfig[]; // compatibility alias
89
93
  outboundHandlers?: TelegramOutboundHandlerConfig[];
@@ -103,6 +107,10 @@ interface TelegramConfig {
103
107
  }
104
108
  ```
105
109
 
110
+ Bot/session identity always persists under `profiles.<name>`. The ordinary setup path uses `profiles.default`; `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Named profiles use the same shape. Shared handlers plus `assistant`, `voice`, and `time` remain top-level. On the first `0.24.0` load, unambiguous legacy root identity moves atomically into `profiles.default`; identical duplicates collapse, complementary fields merge, and conflicting values fail closed without modifying the file.
111
+
112
+ The file is global across Pi instances. Cooperating instances serialize recursive delta merges through `telegram.json.transaction`, preserve unrelated global/profile changes from newer disk snapshots, and merge `lastUpdateId` monotonically. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
113
+
106
114
  Hidden/default semantics are represented by absence:
107
115
 
108
116
  - `assistant.proactivePush` defaults to `true`; omit it to keep projection enabled, or set it explicitly to `false` to disable it. When enabled, each completed public assistant text block from local or autonomous work is projected to the authorized Telegram target once and in source order. This includes visible intermediate commentary/checkpoints and the final block. It excludes token deltas, hidden reasoning, tool calls/arguments/results, Telegram-owned turns, empty blocks, and stale authority. Projection uses the configured Rich or HTML assistant renderer and binds admitted work to the exact target, profile/token transport generation, direct leader epoch or follower registration generation, and session generation. The old top-level `proactivePush` key is ignored; move the setting manually under `assistant`.
package/docs/sections.md CHANGED
@@ -436,7 +436,7 @@ Available programmatically via `getTelegramSectionDiagnostics()`. Main-menu/sett
436
436
 
437
437
  - [Callback Namespaces](./callback-namespaces.md): defines `section:` as pi-telegram-owned prefix. Sections use namespaced callbacks but authors never hand-roll them
438
438
  - [Updates](./updates.md): raw update interception for direct Telegram update access. Sections are the structured UI layer above
439
- - [Extension Locks](../docs/locks.md) (polling): same identity key rules (`package.json/name` canonical id)
439
+ - [Architecture](./architecture.md#configuration-and-ownership): pi-telegram transport ownership is extension-local and independent from section registration identity
440
440
  - [Command Templates](./command-templates.md): sections do not execute command templates by default. UI registration + callback routing, not shell execution
441
441
 
442
442
  ## 15. Demo Extension
package/docs/updates.md CHANGED
@@ -136,7 +136,7 @@ This means:
136
136
 
137
137
  ## Ownership semantics
138
138
 
139
- The handler registry is ownership-agnostic and does not interact with the `locks.json` singleton lock documented in [Locks](./locks.md). When the locked polling runtime stops `pi-telegram`'s `getUpdates` loop, for example after ownership is moved to another pi process, handlers stop receiving updates because no updates are being fetched. They are not unregistered.
139
+ The handler registry is ownership-agnostic and does not interact with the extension-local transport owner slots documented in [Architecture](./architecture.md#configuration-and-ownership). When the polling runtime loses its `owners.json` slot and stops `getUpdates`, handlers stop receiving updates because no updates are being fetched; they are not unregistered.
140
140
 
141
141
  If a layered extension needs to react to ownership changes, it should observe `pi-telegram` lifecycle events through the standard pi extension hooks rather than through the handler registry.
142
142
 
package/lib/bindings.ts CHANGED
@@ -204,15 +204,7 @@ export function registerTelegramCommandsAndTools({
204
204
  );
205
205
  }
206
206
  await configStore.load();
207
- const latestConfig = configStore.getStoredConfig();
208
- configStore.activateProfile(undefined);
209
- configStore.set({
210
- ...latestConfig,
211
- profiles: {
212
- ...(latestConfig.profiles ?? {}),
213
- [profileName]: profile,
214
- },
215
- });
207
+ configStore.setProfile(profileName, profile);
216
208
  configStore.activateProfile(profileName);
217
209
  await onTransportChanged?.();
218
210
  await persistConfig(configStore.get());
@@ -35,21 +35,17 @@ export interface TelegramBusTransportRetryPolicyOverrides {
35
35
 
36
36
  export type TelegramBusTransportOperation = "registration" | "operation";
37
37
 
38
- export const TELEGRAM_BUS_REGISTRATION_RETRY: TelegramBusTransportRetryPolicy = {
39
- attempts: 10,
40
- delayMs: 150,
41
- };
38
+ export const TELEGRAM_BUS_REGISTRATION_RETRY: TelegramBusTransportRetryPolicy =
39
+ {
40
+ attempts: 10,
41
+ delayMs: 150,
42
+ };
42
43
 
43
44
  export const TELEGRAM_BUS_OPERATION_RETRY: TelegramBusTransportRetryPolicy = {
44
45
  attempts: 3,
45
46
  delayMs: 100,
46
47
  };
47
48
 
48
- export const TELEGRAM_BUS_PIPE_REGISTRATION_RETRY =
49
- TELEGRAM_BUS_REGISTRATION_RETRY;
50
-
51
- export const TELEGRAM_BUS_PIPE_OPERATION_RETRY = TELEGRAM_BUS_OPERATION_RETRY;
52
-
53
49
  export function getTelegramBusPipePath(input: {
54
50
  agentDir: string;
55
51
  scope: string;
@@ -200,13 +196,11 @@ export function createTelegramBusTransportTimeoutError(
200
196
 
201
197
  export function delayTelegramBusTransportRetry(ms: number): Promise<void> {
202
198
  return new Promise((resolve) => {
203
- const timer = setTimeout(resolve, ms);
204
- timer.unref?.();
199
+ setTimeout(resolve, ms);
205
200
  });
206
201
  }
207
202
 
208
- export interface TelegramBusTransportProbeResult
209
- extends TelegramBusTransportEndpointDiagnostics {
203
+ export interface TelegramBusTransportProbeResult extends TelegramBusTransportEndpointDiagnostics {
210
204
  reachable: boolean;
211
205
  error?: TelegramBusTransportErrorInfo;
212
206
  }
@@ -238,7 +232,6 @@ export function probeTelegramBusEndpoint(input: {
238
232
  ),
239
233
  });
240
234
  }, timeoutMs);
241
- timeout.unref?.();
242
235
  socket.once("connect", () => settle({ ...diagnostics, reachable: true }));
243
236
  socket.once("error", (error) =>
244
237
  settle({
package/lib/bus.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * cross-instance forwarding helpers, and the live follower registry model.
6
6
  */
7
7
 
8
- import { randomBytes } from "node:crypto";
8
+ import { createHash, randomBytes } from "node:crypto";
9
9
  import {
10
10
  chmodSync,
11
11
  existsSync,
@@ -23,7 +23,7 @@ import {
23
23
  type Server,
24
24
  type Socket,
25
25
  } from "node:net";
26
- import { platform as getPlatform } from "node:os";
26
+ import { platform as getPlatform, tmpdir } from "node:os";
27
27
  import { basename, dirname, join } from "node:path";
28
28
 
29
29
  import {
@@ -33,6 +33,7 @@ import {
33
33
  getTelegramBusEndpointDiagnostics,
34
34
  getTelegramBusFollowerEndpoint,
35
35
  getTelegramBusLeaderEndpoint,
36
+ getTelegramBusPipePath,
36
37
  getTelegramBusTransportRetryPolicy,
37
38
  isTelegramBusPipePath,
38
39
  isRetryableTelegramBusTransportError,
@@ -43,8 +44,6 @@ import {
43
44
  import type { TelegramTarget } from "./target.ts";
44
45
  import { resolveAgentDir } from "./paths.ts";
45
46
 
46
- export type TelegramBusRole = "leader" | "follower";
47
-
48
47
  export interface TelegramBusProcessRuntime {
49
48
  instanceId: string;
50
49
  manualFollowerOwnerId: string;
@@ -524,10 +523,38 @@ export interface TelegramBusLocalServer {
524
523
 
525
524
  export type TelegramBusSocketPathSource = string | (() => string);
526
525
 
526
+ const TELEGRAM_BUS_MAX_DIRECT_UNIX_ENDPOINT_BYTES = 80;
527
+
527
528
  export function resolveTelegramBusSocketPath(
528
529
  source: TelegramBusSocketPathSource,
530
+ platform: NodeJS.Platform | string = getPlatform(),
529
531
  ): string {
530
- return typeof source === "function" ? source() : source;
532
+ const endpoint = typeof source === "function" ? source() : source;
533
+ if (platform === "win32") {
534
+ if (isTelegramBusPipePath(endpoint)) return endpoint;
535
+ return getTelegramBusPipePath({
536
+ agentDir: dirname(endpoint),
537
+ scope: basename(endpoint),
538
+ });
539
+ }
540
+ const ownerScope = process.getuid?.() ?? "user";
541
+ const fallbackDir = join(tmpdir(), `pi-telegram-${ownerScope}`);
542
+ if (
543
+ dirname(endpoint) === fallbackDir &&
544
+ /^[0-9a-f]{16}\.sock$/u.test(basename(endpoint))
545
+ ) {
546
+ return endpoint;
547
+ }
548
+ if (
549
+ Buffer.byteLength(endpoint) <= TELEGRAM_BUS_MAX_DIRECT_UNIX_ENDPOINT_BYTES
550
+ ) {
551
+ return endpoint;
552
+ }
553
+ const digest = createHash("sha256")
554
+ .update(endpoint)
555
+ .digest("hex")
556
+ .slice(0, 16);
557
+ return join(fallbackDir, `${digest}.sock`);
531
558
  }
532
559
 
533
560
  export interface TelegramBusLocalServerDeps {
@@ -617,7 +644,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
617
644
  deps.recordRuntimeEvent?.(
618
645
  "bus",
619
646
  response?.kind === "bus.ack"
620
- ? response.message ?? "Follower rejected forwarded Telegram update."
647
+ ? (response.message ?? "Follower rejected forwarded Telegram update.")
621
648
  : "Follower returned no forwarding acknowledgement.",
622
649
  {
623
650
  phase: "foreign-update-forward-rejected",
@@ -885,7 +912,7 @@ export function createTelegramBusLocalServer(
885
912
  const endpointGeneration = randomBytes(8).toString("hex");
886
913
  const listenPath = usesWindowsPipe
887
914
  ? socketPath
888
- : join(dirname(socketPath), `.pi-telegram-${endpointGeneration}.sock`);
915
+ : join(dirname(socketPath), `.pt-${endpointGeneration}.sock`);
889
916
  activeSocketPath = socketPath;
890
917
  activeListenPath = listenPath;
891
918
  deps.recordTransportEvent?.(
@@ -1110,7 +1137,6 @@ function sendTelegramBusLocalEnvelopeOnce(
1110
1137
  ),
1111
1138
  );
1112
1139
  }, timeoutMs);
1113
- timeout.unref?.();
1114
1140
  socket.setEncoding("utf8");
1115
1141
  socket.once("connect", () => {
1116
1142
  socket.write(encodeTelegramBusEnvelope(options.envelope));
@@ -1130,16 +1156,20 @@ function sendTelegramBusLocalEnvelopeOnce(
1130
1156
  export async function sendTelegramBusLocalEnvelope(
1131
1157
  options: TelegramBusLocalClientOptions,
1132
1158
  ): Promise<TelegramBusEnvelope | undefined> {
1133
- const attempts = Math.max(1, options.retry?.attempts ?? 1);
1134
- const delayMs = Math.max(0, options.retry?.delayMs ?? 0);
1159
+ const resolvedOptions = {
1160
+ ...options,
1161
+ socketPath: resolveTelegramBusSocketPath(options.socketPath),
1162
+ };
1163
+ const attempts = Math.max(1, resolvedOptions.retry?.attempts ?? 1);
1164
+ const delayMs = Math.max(0, resolvedOptions.retry?.delayMs ?? 0);
1135
1165
  for (let attempt = 1; ; attempt += 1) {
1136
1166
  try {
1137
- return await sendTelegramBusLocalEnvelopeOnce(options);
1167
+ return await sendTelegramBusLocalEnvelopeOnce(resolvedOptions);
1138
1168
  } catch (error) {
1139
1169
  const info = classifyTelegramBusTransportError(error);
1140
- options.recordTransportEvent?.("client-failed", {
1141
- ...getTelegramBusEndpointDiagnostics(options.socketPath),
1142
- ...getTelegramBusEnvelopeDiagnostics(options.envelope),
1170
+ resolvedOptions.recordTransportEvent?.("client-failed", {
1171
+ ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
1172
+ ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
1143
1173
  attempt,
1144
1174
  attempts,
1145
1175
  ...info,
@@ -1147,9 +1177,9 @@ export async function sendTelegramBusLocalEnvelope(
1147
1177
  if (attempt >= attempts || !isRetryableTelegramBusTransportError(error)) {
1148
1178
  throw error;
1149
1179
  }
1150
- options.recordTransportEvent?.("client-retry", {
1151
- ...getTelegramBusEndpointDiagnostics(options.socketPath),
1152
- ...getTelegramBusEnvelopeDiagnostics(options.envelope),
1180
+ resolvedOptions.recordTransportEvent?.("client-retry", {
1181
+ ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
1182
+ ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
1153
1183
  attempt,
1154
1184
  attempts,
1155
1185
  delayMs,
@@ -1408,8 +1438,7 @@ function parseForwardMessageEnvelope(
1408
1438
  (value.forwardCommentBatchPosition === "comment" ||
1409
1439
  value.forwardCommentBatchPosition === "forward")
1410
1440
  ? {
1411
- forwardCommentBatchPosition:
1412
- value.forwardCommentBatchPosition,
1441
+ forwardCommentBatchPosition: value.forwardCommentBatchPosition,
1413
1442
  }
1414
1443
  : {}),
1415
1444
  sentAtMs: value.sentAtMs,