@llblab/pi-telegram 0.20.1 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -79,7 +79,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
79
79
  - In Threaded Mode, exactly one live leader owns `getUpdates` and direct Telegram Bot API calls for one bot token. `/telegram-connect` means join the local Telegram organism: if no live leader exists the instance becomes leader; if a live leader exists the terminal-visible instance registers as follower; ordinary connect must not offer manual takeover while the leader heartbeat is live. Leadership is an ephemeral transport role, not permanent ownership by the first terminal-visible Pi instance. Followers must be started by the operator as real Pi processes, register over the local bus, heartbeat, receive only their routed inbound updates, and send outbound replies/previews/files/chat-actions through the leader transport; Telegram must not spawn hidden Pi processes, launch follower subprocesses, or expose a slash command that creates a new local Pi instance. If a leader reloads while followers stay alive, followers must treat rejected/unknown/unauthorized heartbeats as a registration-loss signal and optimistically re-register with the currently active leader before considering promotion; after a short grace window without successful re-registration, a live follower may promote itself and force-acquire transport leadership so the organism degrades forward instead of staying split-brain. Terminal status must not remain `follower` while the leader registry can no longer route that follower's thread. Heartbeat pruning is liveness bookkeeping, not permission to delete, mark offline, or send a Telegram-visible disconnected notice for a follower's thread; preserve active follower tabs/bindings across transient reload/reconnect incidents and reserve destructive teardown/offline transitions for explicit disconnect or confirmed reconciliation cases. If the leader dies, a live follower should eventually be able to take over through stale-leader election/recovery so the local multi-instance organism keeps running. Explicit `/telegram-disconnect` is a manual thread teardown: a leader deletes its own bound thread before releasing transport ownership, while a follower asks the leader to delete its assigned thread through scoped bus API before unregistering. Status is reactive to the effective runtime role and bot capability: classic/non-thread mode shows ordinary `telegram connected`, active bus leadership shows `telegram leader`, and registered followers show `telegram follower`; stable multi-instance truth belongs in the bus role (`leader`/`follower`), while lifecycle is reserved for transient recovery state such as `electing` and must not duplicate stable roles with labels like `leader-active`. When leadership or Threaded Mode availability changes at runtime, the status line and `/telegram-status` must converge promptly instead of preserving a stale role label.
80
80
  - Runtime routing binds to a live `instanceId`; current thread identity should use explicit owner records (`leader`, `manual-follower`, `pending-topic`) rather than hiding ownership direction in legacy string keys. Compatibility keys may be derived internally, but stale session records must not be treated as live owners. A live instance owns at most one active bound thread; provisioning a new target for the same `instanceId` must remove older current-state bindings and close duplicate Telegram threads when possible. A reload creates a new runtime `instanceId`; if the previous leader slot is still known/alive, keep it occupied in reservations and assign the new leader the next free slot instead of creating a duplicate same-letter tab or blocking startup on Telegram UI convergence.
81
81
  - Follower registration readiness should stay on the smallest critical path that makes the follower routable: create or reuse the Telegram target, persist the active binding, mark target-bindings fresh, and return the target to the follower. Manual follower identity must survive a Pi process reload in the same terminal so reload is not treated as a new follower/thread; do not key durable follower ownership only by the short-lived Pi process id. Telegram-visible connected notices, duplicate/replaced thread cleanup, and other reconciliation work are important but non-critical; run them after registration and record slow/failing background steps as runtime events rather than delaying follower usability.
82
- - Instance slots are extension-owned ordering metadata. Allocate new slots monotonically `A` → `B` → ... → `Z` → `A` when a new Telegram thread is truly needed. Reclaim only current pending bindings; do not reclaim inactive stale/offline/failed history because it may point at closed/deleted Telegram tabs. Preserve a slot on current binding/thread reuse. If all current bindings disappear, continue from the compact `bot.lastSlot` cursor in `state.json` instead of retaining old session/thread records. TTL-pruned `pendingProvisions` are scratchpad state: they reserve in-flight provisioning slots and protect cleanup planning, not durable routing history. The alphabet cap is a feature — compact single-letter tabs are scannable.
82
+ - Instance slots are extension-owned ring-order metadata. Allocate fresh slots from the `bot.lastSlot` cursor as `A` → `B` → ... → `Z` → `A`, allowing the cursor to move to lower letters after wraparound. Only current live/recovering bindings plus unexpired pending provisions and explicit reservations occupy slots; historical records must not block allocation indefinitely. Preserve a slot on ordinary current binding/thread reuse without advancing or rewinding the fresh-allocation cursor. Follower bindings may survive only through a bounded leader-reload re-registration grace window, after which records without a live owner leave current `state.json` without implying destructive Telegram thread deletion. When post-grace follower compaction removes the binding represented by `bot.lastSlot`, realign the cursor to the newest-created remaining live binding in the same reconciliation pass; preserve a cursor that still belongs to a live binding and continue skipping unexpired pending/reserved slots. Do not reinterpret an otherwise orphaned cursor outside compaction because normal thread deletion may intentionally preserve ring sequence. The alphabet cap is a feature — compact single-letter tabs are scannable.
83
83
  - Instance thread names are provisioner-authored identity metadata, not model-authored output. Fresh Threaded Mode threads select one of five curated compact 4-6 letter Latin names for the assigned slot letter using provisioning timestamp entropy, then create the Telegram thread with that thread name immediately. The title is the thread name itself, not `{slot} {threadName}` or `{slot} — {threadName}`. Do not register or reintroduce an agent-facing thread rename tool; removing the extra rename prompt/tool turn is an intentional simplicity constraint. Thread names must mirror into terminal status and live diagnostics from the bound thread record; do not hide a valid baked/custom thread name behind old slot-prefix validation. A follower that later becomes leader keeps its existing name; leadership changes are transport role changes, not identity resets.
84
84
  - Telegram private-chat Threaded Mode treats `All` as an aggregate/control surface, not a durable `General` thread and not a process launcher. Leader activation creates/reuses a leader thread in the private bot DM (chat = allowedUserId) proactively. If all threads are closed and the owner writes from `All`, a first new unbound thread should be reclaimed by the leader when the leader lacks an active bound thread. Later unknown threads require explicit routing or restore; creating another Pi instance is a manual terminal action followed by `/telegram-connect`.
85
85
  - Unbound thread detection: when a message arrives from the owner in an unknown `message_thread_id` while Threaded Mode is active, the default runtime first reclaims it for the leader if the leader has no active bound thread and routes the prompt locally. Later unknown threads are preserved by default and receive an in-thread reroute chooser that can send the captured prompt to a live thread or restore a stale leader/follower binding through explicit restore actions; destructive cleanup happens only after explicit user confirmation and through `thread-reconciler`. If the operator wants another Pi instance, they start Pi in a terminal and run `/telegram-connect`; Telegram-originated hidden auto-spawn and `/thread`-style process launch flows are intentionally absent. If Threaded Mode is unavailable, the message is processed normally through classic routing.
@@ -134,6 +134,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
134
134
  ## 6.2 Validation Hotspots
135
135
 
136
136
  - Treat queue handling, compaction interaction, and lifecycle-hook state transitions as regression-prone areas; validate them after changing dispatch logic
137
+ - Keep the standard `npm run typecheck` gate strict about unused locals and parameters so stale imports and abandoned adapters fail validation instead of accumulating silently
137
138
  - Route important runtime failures through the recent runtime event recorder so `/telegram-status` remains useful for post-mortem debugging, not just transient status-bar errors
138
139
  - Treat remaining Markdown-to-HTML rendering as Telegram UI/compat output work, not generic Markdown rendering or assistant reply delivery
139
140
  - Preserve literal code content in Telegram rendering
@@ -175,6 +176,7 @@ The canonical detailed ownership map lives in [`docs/architecture.md`](./docs/ar
175
176
  - Keep the local `index.ts` plus `/lib/*.ts` import graph acyclic; `tests/invariants.test.ts` guards this boundary plus shared-bucket bans, empty interface-extension shell regressions, pi SDK centralization, source-only entrypoint Node-runtime/local-adapter/process/direct-pi access avoidance, runtime-domain isolation, structural leaf-domain import isolation, menu/model boundary drift, Telegram API/config default coupling, structural update/media coupling to Telegram API transport shapes, and attachment coupling to queue/inbound media/Telegram API helpers as domains keep evolving
176
177
  - Do not reintroduce shared bucket domains such as `lib/constants.ts`, `lib/types.ts`, `lib/globals.ts`, or broad global-augmentation files; constants, registry keys, state interfaces, and concrete transport shapes should stay in their owning domains, and `index.ts` should not grow new shared magic constants
177
178
  - Keep remaining `index.ts` code focused on cross-domain adapter wiring that needs live extension state, pi callbacks, Telegram API ports, or status updates; do not extract one-off closures solely to reduce line count
179
+ - Treat every meaningful `index.ts` change as a composition-pressure checkpoint: if the touched cluster owns domain-specific mutable state, identity, lifecycle, fallback policy, or a testable port boundary, move that cluster into its owning domain in the same release when safe. Keep inline wiring only when extraction would merely relocate one-off adapters or hide cross-domain state behind a broad dependency bag
178
180
  - Domain-specific queue planning, preview transport/controller behavior, UI/compat rendering, Telegram API transport, menu state, and command behavior should stay in their owning domains instead of moving to `/lib/runtime.ts` solely to shrink `index.ts`
179
181
  - Prefer narrow structural runtime ports in domains that only store or route pi-compatible values; direct pi SDK/model imports should stay centralized in `/lib/pi.ts`, while domains that actively register pi hooks/tools/commands should consume those concrete contracts through the adapter
180
182
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.2: Live Thread Reality
4
+
5
+ - `[Profiles]` Named `/telegram-connect <profile>` now verifies the persisted profile before stopping polling or changing session-local profile activation. Impact: a mistyped or missing profile leaves the current bot, transport ownership, and polling runtime untouched instead of disconnecting a healthy session.
6
+ - `[Profiles]` Follower target replacement now resolves its fallback binding key from the active profile at replacement time instead of capturing the default-profile key during extension construction. Impact: named-profile thread restore cannot write a missing-record fallback into another bot profile's identity namespace.
7
+ - `[Profiles]` Setup now returns explicit success, cancellation, unavailable/busy, validation-failure, and polling-failure outcomes. Named-profile setup validates in an isolated temporary config and commits/stops the previous runtime only after token validation; `saved and connected` appears only after polling startup succeeds. Impact: cancellation and invalid tokens leave the active profile and transport untouched, while polling failures cannot produce false success copy.
8
+ - `[Diagnostics]` Compact status and `telegram_help` now render the active profile's real `state.<profile>.json` and `logs.<profile>.jsonl` filenames while retaining the legacy unsuffixed paths for the default profile. Impact: named-profile debugging no longer points operators and agents at another bot runtime's evidence.
9
+ - `[Validation]` Removed stale turn-helper imports and enabled TypeScript's `noUnusedLocals` and `noUnusedParameters` checks in the standard typecheck gate. Impact: abandoned imports and adapters now fail local and CI validation instead of silently accumulating.
10
+ - `[Composition]` Moved profile-scoped bus process identity and live endpoint resolution into the existing `bus` domain; moved manual-follower key resolution, promoted-binding transfer, and receiver/recovery/registration assembly into `bus-follower`; and consolidated provisioner, reconciliation, API-proxy, and server runtime assembly inside `bus-leader`. Impact: hotfix-sensitive profile isolation and bus lifecycle wiring stay with established owners instead of repeated construction-time closures and circular initialization in the composition root, reducing `index.ts` from 1,398 to 1,314 lines without adding another domain.
11
+ - `[Validation]` Added direct follower and leader assembly regressions, kept entrypoint/import architecture invariants green, and passed the full test suite (1 platform skip). Impact: composition compression is behavior-preserving and the remaining entrypoint surface stays explicit cross-domain wiring rather than being hidden behind a generic runtime bag.
12
+ - `[Live Linux]` A short leader `/reload` smoke auto-started from the retained lock in 652 ms, reused Dawn at the same thread target and slot `D`, restored the local bus server, and removed 17 historical follower bindings after recovery grace. Impact: lock-driven leader reload, same-thread preservation, and delayed non-destructive roster compaction are confirmed live; the smoke also exposed a surviving historical `lastSlot: "Z"` cursor that is corrected below.
13
+ - `[Thread Slots]` When follower-binding reality reconciliation removes stale records, it now realigns a cursor owned by those removed records to the newest-created remaining live binding in the same persisted compaction. It preserves a cursor that still belongs to live state, leaves pending/reserved slots as allocation guards, and does not reinterpret orphaned cursors from ordinary thread deletion. Impact: current-version compaction cannot recreate the observed historical `Z` cursor beside Dawn-only `D`; the next fresh follower starts from `E`.
14
+ - `[Thread Slots]` Converted `bot.lastSlot` from a max-letter watermark into a true ring cursor that advances through `Z → A`, starts allocation from the latest fresh slot, and ignores higher occupied letters when the cursor points earlier in the ring. Impact: wrapped histories no longer pin allocation at `Z`, and a live `D` cursor can assign the next free follower slot from `E`.
15
+ - `[Thread Identity]` Limited cursor updates to fresh/new-target records while preserving same-target binding reuse without cursor drift. Impact: reconciliation and reload upserts cannot silently rewrite the fresh-allocation sequence.
16
+ - `[Live State]` Replaced historical follower-registry restoration with a bounded leader-reload grace window followed by non-destructive reconciliation against the live bus roster. Followers now carry their last known target while re-registering so live processes reuse the same thread, while dead current-state records and identities leave `state.json` without deleting Telegram tabs. Impact: runtime truth compacts stale slot occupancy after recovery has had time to converge.
17
+ - `[Validation]` Added ring-wrap, cursor-priority, dead-follower reality, delayed reconciliation, and same-target follower re-registration regressions while keeping the existing slot reuse, pending provision, reservation, and alphabet-cap coverage green.
18
+
3
19
  ## 0.20.1: Profile IPC Isolation Hotfix
4
20
 
5
21
  - `[Runtime Isolation]` Profile-scoped Threaded Mode leader and follower IPC endpoints on Unix and Windows while preserving the default profile's legacy socket and named-pipe paths. Impact: parallel named-profile runtimes no longer contend for, unlink, or connect to another bot profile's local bus transport.
package/README.md CHANGED
@@ -41,7 +41,7 @@ Run this inside Pi:
41
41
  /telegram-setup
42
42
  ```
43
43
 
44
- 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.
44
+ 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.
45
45
 
46
46
  ### 3. Connect this Pi session
47
47
 
@@ -52,7 +52,7 @@ The repository uses a **Flat Domain DAG**:
52
52
  - `api`: Bot API helpers, retries, uploads/downloads, temp cleanup, byte limits, chat actions, lazy token clients, and API error recording.
53
53
  - `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.
54
54
  - `locks` / `polling`: singleton lock storage and status labels, lock-aware polling lifecycle/takeover/follower-registration orchestration, classic-vs-Threaded polling switching, Threaded Mode capability probes/monitoring, long-poll controller state, offset persistence, and poll-loop wiring.
55
- - `bus` / `bus-api` / `bus-leader` / `bus-follower` / `ownership` / `target`: Threaded Mode multi-instance bus contracts, 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 and local IPC primitives; `bus-leader` owns leader runtime, leader envelope handling, activation scheduling, and leader polling/server/prune orchestration; `bus-follower` owns this Pi instance's follower-side registration, heartbeat, forwarded-update receiver, and routed API caller without any process spawning.
55
+ - `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, forwarded-update receiver, and routed API caller without any process spawning.
56
56
  - `sync`: demand-driven Telegram reconciliation 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, 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.
57
57
  - `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.
58
58
  - `threads`: Telegram UI thread/tab binding state mapped to Bot API `message_thread_id` / `ForumTopic` transport. Owns current thread target state, slot allocation from the current extension state, baked compact thread-name selection, current binding persistence, and primitive thread provision helpers. It should not persist stale/offline/failed target history, own destructive cleanup policy, grow into the general Telegram synchronization domain, or expose a rename tool.
@@ -138,7 +138,7 @@ The routing identity split is deliberate:
138
138
 
139
139
  - Live routing owner: `instanceId` from the currently registered follower/leader runtime. A live instance may have only one active bound thread; provisioning a new target removes older current-state bindings for the same `instanceId` and closes duplicate Telegram threads when possible.
140
140
  - Current binding owner: explicit `owner` metadata (`leader`, `manual-follower`, or API-level pending thread creation) plus cwd/thread-name metadata; string compatibility keys are derived internally and must not be the persisted source of ownership truth.
141
- - Instance slot: extension-owned single-letter `A`-`Z` ordering metadata. New instances advance monotonically through the alphabet and wrap after `Z` only to a free slot; closed lower slots are not backfilled out of order, and live concurrent instances are capped to available alphabet slots rather than duplicating occupied letters. The compact `bot.lastSlot` cursor is durable across reloads/live-test history, so after it reaches `Z` a later new thread may intentionally become `A` again if `A` is currently free.
141
+ - Instance slot: extension-owned single-letter `A`-`Z` ordering metadata. New instances advance through the alphabet and wrap after `Z` only to a free slot; live concurrent instances are capped to available alphabet slots rather than duplicating occupied letters. The compact `bot.lastSlot` cursor persists while its binding remains live/recovering, including true `Z → A` wraparound. When post-grace follower compaction removes the binding represented by the cursor, the same reconciliation pass realigns it to the newest-created remaining live binding so removed historical followers cannot dictate fresh allocation; unexpired pending provisions and reservations remain collision guards. Other thread deletion paths may intentionally preserve an orphaned cursor to continue ring sequence.
142
142
  - Instance thread name: durable human-facing identity metadata that replaces slot-only thread titles. Fresh threads choose one baked 4-6 letter Latin-word name from the assigned slot's curated palette using provisioning timestamp entropy and create the Telegram thread with that title immediately. Telegram-originated prompt prefixes expose this thread identity label, never follower/leader roles or generic seeds. Bare slot letters are fallback/legacy labels only; agents are not asked to name or rename threads.
143
143
  - Telegram destination: `TelegramTarget` as `{ chatId, threadId? }`, where `threadId` is Telegram `message_thread_id` for UI thread targets.
144
144
 
@@ -155,7 +155,7 @@ Leader election is heartbeat-gated and lock-backed:
155
155
  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.
156
156
  5. If several followers detect stale leadership, atomic compare/write lock acquisition ensures only one becomes leader.
157
157
 
158
- Followers first try to re-register after leader reload or unknown-heartbeat responses, then promote only after the grace window expires. This preserves thread bindings through transient reload gaps without allowing competing pollers.
158
+ Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target so the new leader can reuse the same binding, then promote only after the grace window expires. The leader does not restore persisted followers into the live registry speculatively: after a bounded re-registration grace window it removes follower records that still lack a live bus owner from current `state.json`, without deleting Telegram tabs. This preserves real thread bindings through transient reload gaps without allowing historical records or competing pollers to masquerade as live state.
159
159
 
160
160
  ## Leader/Follower Communication
161
161
 
@@ -224,7 +224,7 @@ In Telegram private-chat Threaded Mode:
224
224
  - The private bot DM becomes the operator's multi-instance dashboard.
225
225
  - Each live bound instance gets one visible thread.
226
226
  - Each instance has a durable single-letter slot (`A`-`Z`) assigned by the extension and a bridge-authored `threadName`.
227
- - New slots advance monotonically through the alphabet and wrap after `Z` only to a free slot; closed earlier slots are not backfilled out of order. This preserves sequence feel and intentionally caps concurrent visible instances to the alphabet without duplicating occupied letters. The compact `bot.lastSlot` cursor persists across reloads and live-test history, so after `Z` the next truly new thread can be `A` again when `A` is currently free.
227
+ - New slots advance through the alphabet and wrap after `Z` only to a free slot, intentionally capping concurrent visible instances to the alphabet without duplicating occupied letters. The compact `bot.lastSlot` cursor persists while its binding remains live/recovering, including true `Z → A` wraparound. When post-grace follower compaction removes the cursor's binding, that same reconciliation pass realigns it to the newest-created remaining live binding; pending provisions and reservations still block collisions. Other explicit thread deletion paths may preserve the cursor to continue ring sequence.
228
228
  - A follower that later becomes leader keeps its existing slot and thread name; leadership changes are transport role changes, not identity resets.
229
229
  - Instance-thread names are short and recognizable. Default provisioning chooses one baked 4-6 letter single-word Latin thread name from the assigned slot's five-name palette using provisioning timestamp entropy and creates the Telegram thread with that title immediately. The slot remains internal ordering metadata and is not redundantly included in the thread name. Bare slot titles are fallback/legacy state only; do not prompt agents to self-name and do not expose a rename tool. Existing human-named threads are preserved across reloads and leadership changes when they remain the current live binding. If reload creates a new runtime instance while the previous leader thread is still alive, the new leader should take the next free slot instead of reusing the old slot immediately.
230
230
  - A thread-local `/start` opens that instance's menu.
@@ -370,9 +370,10 @@ All files containing routing, chat ids, thread ids, or process details use priva
370
370
 
371
371
  ### Follower heartbeat is missed
372
372
 
373
- - Leader prunes the follower from the live registry after missed heartbeats, but heartbeat pruning is only liveness bookkeeping.
373
+ - Leader prunes the follower from the live registry after missed heartbeats, but heartbeat pruning is only immediate liveness bookkeeping.
374
374
  - A missed heartbeat does not delete, close, mark offline, or send a disconnected notice for the follower's Telegram thread binding because the common cause may be leader reload, IPC handoff, or transient reconnect rather than a dead follower.
375
- - Followers treat rejected/missing heartbeat acknowledgements as registration loss: clear local registered truth, try to re-register with the current leader, wait a short leader-reload grace window, retry, and then promote themselves if the leader still cannot route them.
375
+ - Followers treat rejected/missing heartbeat acknowledgements as registration loss: retain the last known target locally, clear registered truth, try to re-register with the current leader, wait a short leader-reload grace window, retry, and then promote themselves if the leader still cannot route them.
376
+ - After leader reload grace expires, persisted manual-follower records without a matching live registry owner leave current `state.json`; this compaction is non-destructive and does not close/delete the Telegram thread.
376
377
  - Every successful follower registration/re-registration sends a compact connected notice in the assigned thread so recovery and reconnection are visible during live testing without confusing heartbeat suspicion with real disconnect.
377
378
  - Successful forwarded updates and follower-originated API calls refresh liveness, so active followers are not pruned only because the interval heartbeat tick lagged.
378
379
  - Destructive follower thread teardown belongs to explicit `/telegram-disconnect` or confirmed reconciliation actions, not generic heartbeat pruning.
package/index.ts CHANGED
@@ -57,41 +57,31 @@ export default function (pi: Pi.ExtensionAPI) {
57
57
  setThinkingLevel,
58
58
  } = piRuntime;
59
59
  const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
60
- const telegramInstanceId = `${process.pid}:${Date.now()}`;
61
- // Manual follower identity must survive a Pi process reload in the same
62
- // terminal. The process id changes on reload, but the parent shell/terminal id
63
- // is stable enough to keep the follower bound to its existing Telegram thread.
64
- const telegramManualFollowerOwnerId = String(process.ppid || process.pid);
65
60
  const getActiveTelegramThreadProfile = function (): string | undefined {
66
61
  return configStore.getActiveProfileName();
67
62
  };
68
- const getTelegramManualFollowerProfileKey = function (): string {
69
- return Threads.getTelegramThreadOwnerKey({
70
- kind: "manual-follower",
71
- instanceId: telegramManualFollowerOwnerId,
72
- telegramProfile: getActiveTelegramThreadProfile(),
63
+ const busProcessRuntime = Bus.createTelegramBusProcessRuntime({
64
+ getActiveProfileName: getActiveTelegramThreadProfile,
65
+ pid: process.pid,
66
+ parentPid: process.ppid,
67
+ createdAtMs: Date.now(),
68
+ });
69
+ const {
70
+ instanceId: telegramInstanceId,
71
+ manualFollowerOwnerId: telegramManualFollowerOwnerId,
72
+ getLeaderSocketPath: getTelegramBusSocketPath,
73
+ getFollowerSocketPath: getTelegramBusFollowerSocketPath,
74
+ } = busProcessRuntime;
75
+ const getTelegramManualFollowerProfileKey =
76
+ BusFollower.createTelegramManualFollowerProfileKeyResolver({
77
+ getActiveProfileName: getActiveTelegramThreadProfile,
78
+ manualFollowerOwnerId: telegramManualFollowerOwnerId,
73
79
  });
74
- };
75
80
  const telegramBusAuthSecret = Bus.createTelegramBusAuthSecret();
76
81
  let telegramActiveBusAuthSecret: string | undefined;
77
82
  let telegramBusLifecycleOverridePhase:
78
83
  | Status.TelegramBridgeBusLifecyclePhase
79
84
  | undefined;
80
- const getTelegramBusSocketPath = function (): string {
81
- return Bus.getTelegramBusSocketPath(
82
- undefined,
83
- undefined,
84
- getActiveTelegramThreadProfile(),
85
- );
86
- };
87
- const getTelegramBusFollowerSocketPath = function (): string {
88
- return Bus.getTelegramBusFollowerSocketPath(
89
- telegramInstanceId,
90
- undefined,
91
- undefined,
92
- getActiveTelegramThreadProfile(),
93
- );
94
- };
95
85
  let telegramBusRequestSequence = 0;
96
86
  const telegramBusFollowerRegistry = Bus.createTelegramBusFollowerRegistry();
97
87
  const telegramBusFollowerRegistrationState =
@@ -817,68 +807,72 @@ export default function (pi: Pi.ExtensionAPI) {
817
807
  compact,
818
808
  recordRuntimeEvent,
819
809
  });
820
- const telegramBusForwardedUpdateReceiver =
821
- BusFollower.createTelegramBusForwardedUpdateReceiverRuntime<
810
+ const promoteTelegramBusFollowerToLeader: BusFollower.TelegramBusFollowerPromotionHandler<Pi.ExtensionContext> =
811
+ BusFollower.createTelegramBusFollowerPromotionHandler<Pi.ExtensionContext>({
812
+ topicTargetStore: threadStore,
813
+ instanceId: telegramInstanceId,
814
+ getActiveProfileName: getActiveTelegramThreadProfile,
815
+ async startLeader(ctx): Promise<void> {
816
+ await lockedPollingRuntime.start(ctx, { force: true });
817
+ },
818
+ recordRuntimeEvent,
819
+ });
820
+ const telegramBusFollowerAssembly: BusFollower.TelegramBusFollowerRuntimeAssembly<Pi.ExtensionContext> =
821
+ BusFollower.createTelegramBusFollowerRuntimeAssembly<
822
822
  Pi.ExtensionContext,
823
823
  Updates.TelegramMessageReactionUpdated,
824
824
  Routing.TelegramRoutedCallbackQuery,
825
825
  Routing.TelegramRoutedMessage
826
826
  >({
827
- socketPath: getTelegramBusFollowerSocketPath,
828
- instanceId: telegramInstanceId,
829
- getContext() {
830
- return telegramSessionContextStore.get();
831
- },
832
- getAuthSecret() {
833
- return telegramActiveBusAuthSecret;
834
- },
835
- handleForwardedCallback(query, ctx) {
836
- return inboundRouteRuntime.handleUpdate({ callback_query: query }, ctx);
837
- },
838
- handleForwardedReaction(reactionUpdate, ctx) {
839
- return inboundRouteRuntime.handleAuthorizedReactionUpdate(
840
- reactionUpdate,
841
- ctx,
842
- );
843
- },
844
- handleForwardedMessage(message, ctx) {
845
- return inboundRouteRuntime.handleUpdate(
846
- { message: message as never },
847
- ctx,
848
- );
849
- },
850
- async handleForwardedEditedMessage(message, ctx) {
851
- return inboundRouteRuntime.handleUpdate(
852
- { edited_message: message as never },
853
- ctx,
854
- );
827
+ receiver: {
828
+ socketPath: getTelegramBusFollowerSocketPath,
829
+ instanceId: telegramInstanceId,
830
+ getContext() {
831
+ return telegramSessionContextStore.get();
832
+ },
833
+ getAuthSecret() {
834
+ return telegramActiveBusAuthSecret;
835
+ },
836
+ handleForwardedCallback(query, ctx) {
837
+ return inboundRouteRuntime.handleUpdate({ callback_query: query }, ctx);
838
+ },
839
+ handleForwardedReaction(reactionUpdate, ctx) {
840
+ return inboundRouteRuntime.handleAuthorizedReactionUpdate(
841
+ reactionUpdate,
842
+ ctx,
843
+ );
844
+ },
845
+ handleForwardedMessage(message, ctx) {
846
+ return inboundRouteRuntime.handleUpdate(
847
+ { message: message as never },
848
+ ctx,
849
+ );
850
+ },
851
+ async handleForwardedEditedMessage(message, ctx) {
852
+ return inboundRouteRuntime.handleUpdate(
853
+ { edited_message: message as never },
854
+ ctx,
855
+ );
856
+ },
857
+ recordRuntimeEvent,
855
858
  },
856
- handleReplaceTarget:
857
- BusFollower.createTelegramBusFollowerTargetReplacementHandler({
858
- topicTargetStore: threadStore,
859
- registrationState: telegramBusFollowerRegistrationState,
860
- instanceId: telegramInstanceId,
861
- manualFollowerProfileKey: getTelegramManualFollowerProfileKey(),
862
- manualFollowerOwnerId: telegramManualFollowerOwnerId,
863
- getSyncState() {
864
- return telegramSyncState;
865
- },
866
- setSyncState(state) {
867
- telegramSyncState = state;
868
- },
869
- updateStatus,
870
- recordRuntimeEvent,
871
- }),
872
- recordRuntimeEvent,
873
- });
874
- let telegramBusFollowerRegistration: BusFollower.TelegramBusFollowerRegistrationRuntime<Pi.ExtensionContext>;
875
- const telegramFollowerHeartbeatRecovery =
876
- BusFollower.createTelegramBusFollowerHeartbeatRecoveryHandler<Pi.ExtensionContext>(
877
- {
859
+ targetReplacement: {
860
+ topicTargetStore: threadStore,
878
861
  registrationState: telegramBusFollowerRegistrationState,
879
- getRegistrationRuntime() {
880
- return telegramBusFollowerRegistration;
862
+ instanceId: telegramInstanceId,
863
+ getManualFollowerProfileKey: getTelegramManualFollowerProfileKey,
864
+ manualFollowerOwnerId: telegramManualFollowerOwnerId,
865
+ getSyncState() {
866
+ return telegramSyncState;
881
867
  },
868
+ setSyncState(state) {
869
+ telegramSyncState = state;
870
+ },
871
+ updateStatus,
872
+ recordRuntimeEvent,
873
+ },
874
+ recovery: {
875
+ registrationState: telegramBusFollowerRegistrationState,
882
876
  getLeaderState() {
883
877
  return lockRuntime.getState();
884
878
  },
@@ -886,47 +880,17 @@ export default function (pi: Pi.ExtensionAPI) {
886
880
  telegramBusLifecycleOverridePhase = phase;
887
881
  },
888
882
  updateStatus,
889
- async promoteToLeader(ctx, binding) {
890
- const promotedRecord =
891
- await Threads.promoteTelegramFollowerBindingToLeader({
892
- store: threadStore,
893
- instanceId: telegramInstanceId,
894
- cwd: ctx.cwd,
895
- telegramProfile: getActiveTelegramThreadProfile(),
896
- target: binding.target,
897
- slot: binding.slot,
898
- threadName: binding.threadName,
899
- });
900
- if (promotedRecord) {
901
- recordRuntimeEvent(
902
- "bus",
903
- "Follower thread binding promoted to leader",
904
- {
905
- phase: "follower-promoted-binding",
906
- chatId: promotedRecord.target.chatId,
907
- threadId: promotedRecord.target.threadId,
908
- slot: promotedRecord.slot,
909
- threadName: promotedRecord.threadName,
910
- },
911
- );
912
- }
913
- await lockedPollingRuntime.start(ctx, { force: true });
914
- },
883
+ promoteToLeader: promoteTelegramBusFollowerToLeader,
915
884
  sleep(ms) {
916
885
  return Polling.sleepTelegramPollingRetry(ms);
917
886
  },
918
887
  promotionGraceMs: BusFollower.TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS,
919
888
  recordRuntimeEvent,
920
889
  },
921
- );
922
- telegramBusFollowerRegistration =
923
- BusFollower.createTelegramBusFollowerRegistrationRuntime<Pi.ExtensionContext>(
924
- {
890
+ registration: {
925
891
  instanceId: telegramInstanceId,
926
892
  getFollowerBusSocketPath: getTelegramBusFollowerSocketPath,
927
893
  getLeaderSocketPath: getTelegramBusSocketPath,
928
- startReceiving: telegramBusForwardedUpdateReceiver.start,
929
- stopReceiving: telegramBusForwardedUpdateReceiver.stop,
930
894
  registrationState: telegramBusFollowerRegistrationState,
931
895
  createRequestId() {
932
896
  telegramBusRequestSequence += 1;
@@ -947,10 +911,11 @@ export default function (pi: Pi.ExtensionAPI) {
947
911
  getProfileKey() {
948
912
  return getTelegramManualFollowerProfileKey();
949
913
  },
950
- onHeartbeatFailure: telegramFollowerHeartbeatRecovery,
951
914
  recordRuntimeEvent,
952
915
  },
953
- );
916
+ });
917
+ const telegramBusFollowerRegistration =
918
+ telegramBusFollowerAssembly.registration;
954
919
  const pollingRuntime = Polling.createTelegramPollingControllerRuntime({
955
920
  state: pollingControllerState,
956
921
  getConfig: configStore.get,
@@ -980,116 +945,67 @@ export default function (pi: Pi.ExtensionAPI) {
980
945
  recordEvent: recordRuntimeEvent,
981
946
  });
982
947
  };
983
- const telegramBusLeaderRuntime = BusLeader.createTelegramBusLeaderRuntime({
984
- socketPath: getTelegramBusSocketPath,
985
- followerRegistry: telegramBusFollowerRegistry,
986
- authSecret: telegramBusAuthSecret,
987
- startPolling: pollingRuntime.start,
988
- stopPolling: pollingRuntime.stop,
989
- authorizeFollowerApiCall: Bus.isTelegramFollowerApiCallAllowed,
990
- recordFollowerMessageOwnership(record) {
991
- messageOwnershipStore.record({
992
- chatId: record.chatId,
993
- messageId: record.messageId,
994
- target: record.target,
995
- instanceId: record.follower.instanceId,
996
- });
997
- },
998
- provisionLeaderTarget:
999
- BusLeader.createTelegramBusLeaderTargetProvisioner<Pi.ExtensionContext>({
1000
- getAllowedUserId: configStore.getAllowedUserId,
1001
- instanceId: telegramInstanceId,
1002
- getCwd(ctx) {
1003
- return typeof ctx.cwd === "string" ? ctx.cwd : undefined;
1004
- },
1005
- getTelegramProfile: getActiveTelegramThreadProfile,
1006
- shouldForceFreshUnnamed() {
1007
- return forceFreshLeaderThreadOnNextStart;
1008
- },
1009
- topicTargetStore: threadStore,
1010
- callApi(method, body) {
1011
- return directTelegramApiRuntime.call(method, body);
1012
- },
1013
- getCurrentLeaderEpoch,
1014
- getThreadReconciliationMachineState() {
1015
- return threadReconciliationRuntime.getState();
1016
- },
1017
- recordThreadReconciliationPlan,
1018
- getSyncState() {
1019
- return telegramSyncState;
1020
- },
1021
- setSyncState(state) {
1022
- telegramSyncState = state;
1023
- },
1024
- setLeaderTarget(input) {
1025
- telegramBusLeaderTarget = input.target;
1026
- telegramBusLeaderSlot = input.slot;
1027
- telegramBusLeaderThreadName = input.threadName;
1028
- },
1029
- onProvisioningStart() {
1030
- telegramTopicProvisioningCount += 1;
1031
- },
1032
- onProvisioningEnd() {
1033
- telegramTopicProvisioningCount = Math.max(
1034
- 0,
1035
- telegramTopicProvisioningCount - 1,
1036
- );
948
+ const telegramBusLeaderRuntime =
949
+ BusLeader.createTelegramBusLeaderRuntimeAssembly<Pi.ExtensionContext>({
950
+ runtime: {
951
+ socketPath: getTelegramBusSocketPath,
952
+ followerRegistry: telegramBusFollowerRegistry,
953
+ authSecret: telegramBusAuthSecret,
954
+ startPolling: pollingRuntime.start,
955
+ stopPolling: pollingRuntime.stop,
956
+ authorizeFollowerApiCall: Bus.isTelegramFollowerApiCallAllowed,
957
+ recordFollowerMessageOwnership(record) {
958
+ messageOwnershipStore.record({
959
+ chatId: record.chatId,
960
+ messageId: record.messageId,
961
+ target: record.target,
962
+ instanceId: record.follower.instanceId,
963
+ });
1037
964
  },
1038
- recordRuntimeEvent,
1039
- }),
1040
- onFollowerPruned: BusLeader.createTelegramBusFollowerPruneHandler({
965
+ },
966
+ getAllowedUserId: configStore.getAllowedUserId,
967
+ instanceId: telegramInstanceId,
968
+ getCwd(ctx) {
969
+ return typeof ctx.cwd === "string" ? ctx.cwd : undefined;
970
+ },
971
+ getTelegramProfile: getActiveTelegramThreadProfile,
972
+ shouldForceFreshUnnamed() {
973
+ return forceFreshLeaderThreadOnNextStart;
974
+ },
1041
975
  topicTargetStore: threadStore,
1042
976
  callApi(method, body) {
1043
977
  return directTelegramApiRuntime.call(method, body);
1044
978
  },
979
+ callMultipart: directTelegramApiRuntime.callMultipart,
980
+ downloadFile: directTelegramApiRuntime.downloadFile,
981
+ recoverStaleTargetError: recoverStaleTelegramTopicApiError,
1045
982
  getCurrentLeaderEpoch,
983
+ getThreadReconciliationMachineState() {
984
+ return threadReconciliationRuntime.getState();
985
+ },
986
+ recordThreadReconciliationPlan,
1046
987
  getSyncState() {
1047
988
  return telegramSyncState;
1048
989
  },
1049
990
  setSyncState(state) {
1050
991
  telegramSyncState = state;
1051
992
  },
993
+ setLeaderTarget(input) {
994
+ telegramBusLeaderTarget = input.target;
995
+ telegramBusLeaderSlot = input.slot;
996
+ telegramBusLeaderThreadName = input.threadName;
997
+ },
998
+ onProvisioningStart() {
999
+ telegramTopicProvisioningCount += 1;
1000
+ },
1001
+ onProvisioningEnd() {
1002
+ telegramTopicProvisioningCount = Math.max(
1003
+ 0,
1004
+ telegramTopicProvisioningCount - 1,
1005
+ );
1006
+ },
1052
1007
  recordRuntimeEvent,
1053
- }),
1054
- provisionFollowerTarget:
1055
- BusLeader.createTelegramBusFollowerTargetProvisioner({
1056
- getAllowedUserId: configStore.getAllowedUserId,
1057
- topicTargetStore: threadStore,
1058
- callApi(method, body) {
1059
- return directTelegramApiRuntime.call(method, body);
1060
- },
1061
- getCurrentLeaderEpoch,
1062
- getSyncState() {
1063
- return telegramSyncState;
1064
- },
1065
- setSyncState(state) {
1066
- telegramSyncState = state;
1067
- },
1068
- onProvisioningStart() {
1069
- telegramTopicProvisioningCount += 1;
1070
- },
1071
- onProvisioningEnd() {
1072
- telegramTopicProvisioningCount = Math.max(
1073
- 0,
1074
- telegramTopicProvisioningCount - 1,
1075
- );
1076
- },
1077
- recordRuntimeEvent,
1078
- }),
1079
- restoreFollowerRegistry:
1080
- BusLeader.createTelegramBusFollowerRegistryRestoreHandler({
1081
- topicTargetStore: threadStore,
1082
- followerRegistry: telegramBusFollowerRegistry,
1083
- recordRuntimeEvent,
1084
- }),
1085
- callApi: BusLeader.createTelegramBusLeaderApiProxy({
1086
- call: directTelegramApiRuntime.call,
1087
- callMultipart: directTelegramApiRuntime.callMultipart,
1088
- downloadFile: directTelegramApiRuntime.downloadFile,
1089
- recoverStaleTargetError: recoverStaleTelegramTopicApiError,
1090
- }),
1091
- recordRuntimeEvent,
1092
- });
1008
+ });
1093
1009
  let pollingStartedWithTelegramBus = false;
1094
1010
  let forceFreshLeaderThreadOnNextStart = false;
1095
1011
  const telegramLeaderHealthRuntime = Sync.createTelegramLeaderHealthRuntime({