@llblab/pi-telegram 0.20.2 → 0.20.3

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 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.
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 through a bounded leader-reload re-registration grace window. Successful target reuse must refresh the binding timestamp, and post-reload compaction must preserve a recently refreshed binding across the brief registry gap caused by follower session replacement; older 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.
@@ -99,7 +99,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
99
99
  - UI emoji semantics live in `docs/ui-style.md` as the project-level emoji registry. Before adding a new UI emoji, reuse an existing registered meaning or update the registry in the same change. Use `🧵` consistently as the canonical Telegram/Pi thread marker in thread chooser headings, thread lifecycle copy, and target-routing status text; do not decorate every concrete thread-target button with it when the button label is already the thread name or slot. Operator-facing Threaded Mode routing copy should use the `Pi` glyph for Pi instance/thread/session concepts; reserve `Pi` spelling for product prose, package/API names, and surrounding docs where the wordmark is clearer.
100
100
  - Telegram prompt context metadata uses `[tag|key:value|key:value]` without spaces around `:`. `[telegram...]` identifies the current inbound surface only: owner/current turns use `[telegram]` or `[telegram|thread:<name>]`, while guest-mode turns use `[telegram|guest:<group-title-or-peer-username-or-id>]`. In private guest replies authored by the owner, `guest:<...>` must name the replied guest peer, not the owner. Alternate/source authors belong on source-specific blocks such as `[reply|from:<username-or-id>]`, `[forward|from:<username-or-id>]`, and `[attachments|from:<username-or-id>]`; plain `[attachments]` remains current-turn/current-owner attachments and should render immediately after the `[telegram...]` current text before reply/forward/source attachment context.
101
101
  - Telegram `reply_to_message` context is prompt-only and must not affect slash-command parsing; when Telegram includes `rich_message` blocks on a quoted rich reply, extract plain text from those blocks before falling back to raw `text`/`caption` so prompt context does not leak raw Rich Markdown source
102
- - Long-lived timers, pollers, watchers, and deferred queue dispatch must be session-bound and avoid stale live Pi contexts after session replacement. Pi session replacement/reload changes agent context; Threaded Mode followers must disconnect their bus registration and receiver cleanly rather than preserving a stale follower socket across reload. After reload, a follower remains `Telegram disconnected` until the operator runs `Telegram Connect` again; its old Telegram thread binding is marked stale so the next explicit connect can provision a fresh routable thread instead of silently rebinding to an unroutable stale tab, and status must not report a live follower while registration is absent.
102
+ - Long-lived timers, pollers, watchers, and deferred queue dispatch must be session-bound and avoid stale live Pi contexts after session replacement. Pi session replacement/reload changes agent context, so Threaded Mode followers must stop the old receiver/heartbeat and never retain a stale context or socket. Before suspension, snapshot a short-lived same-process handoff containing the assigned target identity; on the next session start, automatically re-register through the live leader with that target and bind the new context. Preserve the existing Telegram thread during this handoff, show `Telegram disconnected` only while restoration is genuinely pending or failed, and require explicit `/telegram-connect` only when no fresh same-process handoff can be restored.
103
103
  - Do not add Telegram commands that imitate Pi interactive session replacement, navigation, or TUI rendering through private internals, ANSI terminal clearing, raw TTY injection, or a shadow `pi` subprocess. Features such as a real Telegram `/new` require a public Pi API that runs the same session-replacement path as the terminal command.
104
104
  - In-flight `/model` switching is limited to Telegram-owned active turns; if a tool call is active, abort is delayed until the tool finishes
105
105
 
@@ -114,7 +114,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
114
114
  - `telegram_voice` and `telegram_button` are not Pi tools; keep prompts/docs explicit that agents should author markup while voice synthesis provider extensions own TTS/OGG conversion, and pi-telegram owns button routing plus Telegram delivery
115
115
  - Voice reply policy and prompt context are owned by pi-telegram's `telegram.json` `voice.replyMode`: missing/invalid config behaves as `manual` but does not add a `[voice]` prompt-context block; only an explicit valid `voice.replyMode` renders context. Render a single voice field as `[voice] reply mode: manual|mirror|always`, and render multiple fields as a `[voice]` list; place voice context after `[outputs]` when handler output exists, otherwise after `[attachments]`; provider prompt contributions are optional provider-specific additions, not the default policy channel
116
116
  - Optional `telegram.json` `time` may add `[time] YYYY-MM-DD HH:mm:ss <timezone>` to Telegram-originated prompts for wall-clock context. It is hidden by default, uses `time.injectionMode` values `hidden|always|interval`, stores `time.interval` in milliseconds, uses the system timezone, and should render last after `[attachments]`, `[outputs]`, and `[voice]` sections. The Settings row `🕒 Time injection: hidden|always|interval` controls `time.injectionMode` only.
117
- - Agent activity status is native-only for the release path: Telegram uses `sendChatAction(typing)` / product `...active` status as the automatic work signal. It belongs to real agent work only: active Telegram turns, tool/reasoning/message activity during those turns, confirmed manual `/compact`, and automatic compaction tied to an active Telegram turn. Do not revive startup/connect/reload typing or add extra in-chat work surfaces without a new explicit design decision.
117
+ - Agent activity status is native-only for the release path: Telegram uses `sendChatAction(typing)` and renders its native `…typing` indicator as the automatic work signal. When a connected instance starts any real agent run, send that indicator to the instance's own thread (and aggregate `All` surface), including Telegram turns, local/TUI prompts, and autonomous continuations such as Grow Loop; stop it at agent/session completion. This signal is independent from terminal `Active`, which remains scoped to Telegram-owned turns. Confirmed manual `/compact` and automatic compaction tied to an active Telegram turn also use native activity. Do not send activity merely for startup/connect/reload/recovery or add extra in-chat work surfaces without a new explicit design decision.
118
118
  - Voice reply mode Settings UI standard: the top-level Settings row is `👄 Voice reply: hidden|manual|mirror|always`; `hidden` is the true default and means no valid `voice.replyMode` is persisted, behavior is manual, and no voice policy is added to prompt context; explicit `manual` behaves the same operationally but renders reply-mode context. The submenu title is `👄 Voice reply mode:`; choice buttons use lowercase labels with a model-style active dot (`🟢 hidden`, `🟢 mirror`) rather than per-mode emoji; the explanatory submenu body uses compact HTML-code bullets such as `<code>-</code> <code>hidden</code> (default): ...`. Preserve this wording/icons unless the operator explicitly asks to redesign it
119
119
  - Outbound voice delivery is one fallback pipeline: configured `outboundHandlers` with `type: "voice"` run first in `telegram.json` order, then programmatic voice handlers, then registered voice synthesis providers as zero-config progressive fallbacks; provider extensions must not override operator-configured handlers
120
120
  - `telegram_voice` text is arbitrary TTS-target text and the prompt should show only forms that include text to speak: `<!-- telegram_voice text="Short summary" -->` or `<!-- telegram_voice: Short summary -->`. Avoid raw Markdown/code/tables in voice text.
package/BACKLOG.md CHANGED
@@ -30,7 +30,7 @@ Context: The release should avoid speculative live-test matrices. Future Telegra
30
30
 
31
31
  Open work:
32
32
 
33
- - [ ] Capture any new Telegram client or Bot API behavior that contradicts the documented Threaded Mode contract.
33
+ - [ ] Capture any new Telegram client or Bot API behavior that contradicts the documented Threaded Mode contract, including a live local/autonomous `…typing` observation when convenient.
34
34
  - [ ] Add a focused regression or documented client caveat only for confirmed behavior.
35
35
  - [ ] Keep one-off live environment names, thread names, and operator-specific observations out of repository context unless they demonstrate a general product issue.
36
36
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.3: Persistent Threads And Activity
4
+
5
+ - `[Follower Sessions]` Registered followers now snapshot their target, slot, and thread name before same-process Pi session replacement, stop the old receiver/heartbeat context, and automatically re-register the new session through the live leader. Impact: `/new` and `/reload` no longer intentionally leave a healthy follower disconnected or require another manual `/telegram-connect`.
6
+ - `[Thread Identity]` Follower re-registration transfers an exact requested target from the previous runtime instance through the stable manual-follower identity, and every reuse refreshes the binding's recovery timestamp. Post-leader-reload compaction preserves recently refreshed bindings across a brief follower registry gap, while still removing genuinely historical records. Impact: follower or leader reload does not rotate baked names or create duplicate same-slot Telegram tabs merely because runtime instance ids changed or the follower reload overlaps the leader's compaction deadline.
7
+ - `[Native Activity]` Telegram's native `…typing` indicator now starts for every connected instance agent run, including local/TUI prompts and autonomous continuations such as Grow Loop, using the active Telegram turn target when present and otherwise the instance's assigned target. Impact: Telegram shows that an instance is working even when a new Telegram prompt is queued behind local work, while terminal `Active` remains scoped to Telegram-owned turns.
8
+ - `[Live Linux]` Leader reload triggered bounded follower election/re-registration without replacing follower threads; subsequent Boreal follower `/reload` and `/new` both automatically restored follower role on the same slot `B` and thread `524509`. Impact: leader reload and both Pi session-replacement paths have direct evidence for exact follower thread preservation without manual reconnect or duplicate tabs.
9
+ - `[Validation]` Relaxed the process-shutdown child harness timeout from 2 to 5 seconds after full-suite concurrency exceeded the old wall-clock bound while the isolated shutdown suite remained green. Impact: process ownership regressions still fail on leaked handles but no longer flake solely from parallel test-runner load.
10
+
3
11
  ## 0.20.2: Live Thread Reality
4
12
 
5
13
  - `[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.
package/README.md CHANGED
@@ -94,7 +94,7 @@ The first Telegram user to message the bot becomes the allowed owner. Other user
94
94
  | Prompt templates | Run Pi prompt templates as Telegram-safe commands such as `/fix_tests`. | Reusable local workflows become phone-accessible without exposing arbitrary terminal commands. |
95
95
  | Model and thinking | Switch model or reasoning level from Telegram through safe continuation flows. | Mobile control can adjust execution strategy without tearing down the current session. |
96
96
  | Compaction | Confirm `/compact`, show native active status during compaction, and preserve Telegram-owned turn semantics. | Context maintenance is visible and safe from the phone. |
97
- | Draft previews | Show native active status by default, or enable Telegram Rich Draft previews while the agent is still working. | The operator can hide/show in-progress text independently from final rendering. |
97
+ | Draft previews | Show Telegram's native `…typing` indicator whenever the connected instance is doing agent work, or enable Rich Draft previews for streamed answer text. | Local prompts, Telegram turns, and autonomous continuations remain visibly active while draft visibility stays independent from final rendering. |
98
98
  | Assistant rendering | Choose Native Rich Markdown or legacy Markdown-to-HTML for final assistant replies. | Renderer compatibility is explicit instead of being conflated with draft previews. |
99
99
  | Bridge UI rendering | Render tool rows, reasoning/technical steps, menus, queue controls, status, settings, diagnostics, and sections through explicit Telegram HTML/plain UI. | Harness-owned surfaces remain operationally predictable and visually distinct from model-authored answers. |
100
100
  | Inbound files | Download inbound files to the Pi agent temp directory with size limits. | Screenshots, PDFs, datasets, and artifacts enter Pi as inspectable local files. |
@@ -184,6 +184,7 @@ Classic private DM mode is the base product mode. When Telegram private-chat Thr
184
184
  - One live leader owns `getUpdates`.
185
185
  - Followers are visible Pi processes started by the operator.
186
186
  - Each connected instance gets a Telegram thread target.
187
+ - Follower session replacement automatically reconnects the new session context to the same thread instead of requiring another manual connect.
187
188
  - Unknown threads are preserved and offered explicit reroute/restore choices.
188
189
  - Telegram never launches hidden Pi processes.
189
190
 
@@ -108,7 +108,7 @@ Telegram configuration lives in `~/.pi/agent/telegram.json`. Polling ownership l
108
108
  - 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.
109
109
  - 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.
110
110
  - 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.
111
- - Session replacement suspends polling/watchers without releasing ownership so the next session-start hook in the same process can resume.
111
+ - 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.
112
112
  - Live polling owners require explicit takeover confirmation.
113
113
  - Long-lived polling timers use snapshotted ownership context and stop local polling when the lock no longer points at their own process.
114
114
  - `locks.json` owns only external Telegram control/polling. Local extension and queue state are per Pi instance: losing the lock stops live Telegram control here, but does not drain or silence this instance's accepted queue, previews, final delivery, or dispatch.
@@ -124,7 +124,7 @@ Named Telegram profiles are orthogonal to Threaded Mode. The selected profile ch
124
124
 
125
125
  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. 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.
126
126
 
127
- 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. `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. 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.
127
+ 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; post-leader-reload compaction preserves that recently refreshed binding across a brief follower session-replacement registry gap, while genuinely historical records still leave current state after grace. `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. 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.
128
128
 
129
129
  Follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then does that process register as a follower with an instance-scoped internal binding identity and cause the leader to create/reuse a thread for it. Telegram does not expose `/thread`, auto-spawn arbitrary unbound threads, or launch hidden follower subprocesses. In Threaded Mode, `/telegram-connect` does not offer manual takeover while a live leader exists; takeover is reserved for stale-leader election/recovery. Leadership remains an ephemeral transport role that another live follower can take over after stale heartbeat detection.
130
130
 
@@ -223,7 +223,7 @@ Native typing during compaction is deliberately narrower than the compaction fla
223
223
  - Startup, reload, connect/reconnect, restore, leader/follower recovery, and idle/background compaction without an active Telegram turn must not send visible typing.
224
224
  - Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface.
225
225
 
226
- During active Telegram-owned turns, assistant message start/update hooks re-arm typing so transient provider/model errors do not leave a continuing run without Telegram activity feedback.
226
+ At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Assistant message start/update hooks still re-arm it during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
227
227
 
228
228
  ### Rendering And Delivery
229
229
 
@@ -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, 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.
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. Every successful reuse refreshes the binding timestamp. The leader does not restore persisted followers into the live registry speculatively: after a bounded re-registration grace window it removes older follower records that still lack a live bus owner from current `state.json`, while preserving a recently refreshed binding across the brief registry gap caused by follower session replacement and never 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
 
@@ -231,7 +231,7 @@ In Telegram private-chat Threaded Mode:
231
231
  - Prompts typed in a thread route to the owning instance.
232
232
  - Replies, previews, files, voice, and buttons stay in that thread.
233
233
  - Queue controls and reactions affect only that instance target.
234
- - Native typing/activity for real work is sent to that instance thread and mirrored to `All`; `All` is the aggregate surface and should show active when any bound thread is active. Startup/connect/reload/recovery must not send typing by themselves.
234
+ - Telegram's native `…typing` indicator for real agent work is sent to that instance thread and mirrored to `All`; `All` is the aggregate surface and should show activity when any bound instance is running a Telegram turn, local prompt, or autonomous continuation. Terminal `Active` remains Telegram-turn-specific. Startup/connect/reload/recovery must not send activity by themselves.
235
235
  - If the instance disconnects, the leader can post/update a compact status: `Instance offline`.
236
236
  - If the same live binding identity returns, it can reclaim the thread and post a compact reconnect status.
237
237
 
@@ -289,22 +289,22 @@ Every outbound request carries its target. The leader injects `message_thread_id
289
289
 
290
290
  Threaded Mode should make follower threads behave like normal Telegram instance surfaces, with the leader acting only as transport owner. Any feature in the matrix below that works for the leader must either work for followers or have an explicit documented exception.
291
291
 
292
- | Surface | Leader behavior | Follower requirement | Routing/ownership invariant | Regression evidence |
293
- | --- | --- | --- | --- | --- |
294
- | Prompt intake | Thread prompt queues locally | Thread prompt is forwarded and queued by the owning follower | Target ownership routes by `{ chatId, threadId }` before local handling | Routing tests for foreign target message forwarding |
295
- | Queued-message removal reactions | 👎/👻/💔/💩/🗑 removes pending prompt/media turn | Same reaction on a queued follower prompt removes that follower's pending turn before dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
296
- | Queue priority reactions | 👍/⚡/❤/🕊/🔥 prioritizes queued prompts | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
297
- | Message edits | Edits update matching queued prompt text | Edits in a follower thread update that follower's queued prompt | Message target ownership forwards edits to the owning instance; stored message ownership is the fallback when Telegram edit payloads omit thread id | Update routing tests for foreign target and message-owned edited-message forwarding |
298
- | Callbacks/buttons/menus | Callback handled by the owning instance/menu state | Follower callbacks are forwarded to the owning follower; follower menu sends/edits/deletes route through leader transport | Leader records ownership for follower-sent Bot API messages so callbacks can route by message id even when Telegram omits thread id; Bot API edit/delete lacks thread id, so follower bus allows validated same-chat message operations | Callback forwarding, generated-button target, bus follower-sent ownership, and bus edit/delete allowlist tests |
299
- | Replies/finals | Final replies land in the same thread | Follower finals go through leader transport into follower thread | Outbound calls carry target and inject `message_thread_id` | Reply delivery and bus API tests |
300
- | Previews/Rich Drafts | Draft previews use the active thread target | Follower previews use the same native draft lifecycle through the leader | Preview transport preserves target and draft id | Preview thread-target tests |
301
- | Attachments/voice | Files and voice upload in the instance thread | Follower uploads route through leader multipart transport | Multipart calls are target-scoped and follower-authorized | Bus allowlist and outbound delivery tests |
302
- | Native activity status | `sendChatAction(typing)` shows thread Active, mirrors aggregate `All`, and terminal status flips from role to `active` during work | Follower work sends one thread action and one aggregate action through leader transport, and terminal status follows the same idle-role active transition as leaders | Typing loop targets the active turn and avoids duplicate aggregate sends/rate-limit pressure; status rendering gives processing labels precedence over stable bus role labels | Runtime typing loop starter and status bar parity regressions |
303
- | Leader election / promotion | Current leader keeps its thread across reload | A promoted follower keeps its existing thread, slot, and name when elected and after later reload | Promotion converts the current follower binding into the leader profile before forced lock acquisition, so leader startup reuses it instead of provisioning a new thread | Follower heartbeat recovery passes binding snapshot into promotion; own-topic provisioner reuses promoted bindings |
304
- | `/start` command/menu bootstrap | Registers visible bot commands and opens the menu | Follower `/start` can refresh the bot command menu through the leader and open its local menu without warnings | Bot command registration is a validated global Bot API call allowed through trusted follower bus transport | Bus allowlist regression for `setMyCommands` |
305
- | Follower reconnect | Existing leader binding is reused only when still usable | Follower reconnect that points at a closed/stale Telegram tab recreates a visible thread before reporting success, and session replacement replaces any old same-profile/same-target registry entry | Same-profile reuse is probed with a connected notice; stale Bot API errors mark the old target stale and provision a fresh target; live follower target ownership never falls back to leader records | Bus leader stale reused follower-thread provisioner and bus registry/ownership regressions |
306
- | Unbound thread reroute/restore | New unbound thread can route to a live instance or replace the selected instance thread | Same chooser exposes all currently live bus leader/follower targets; restore is offered from concrete unbound threads, not historical snapshots | Live bus roster plus active target bindings define the selectable set; history/state snapshots are not authority | Routing chooser regressions for live target filtering and restore rows |
307
- | Status/menu diagnostics | Status reflects leader role, queue, and target | Follower status reflects follower role, thread name, queue, and bus health | Status is local runtime truth plus bus registration state, not leader queue state | Status and bus diagnostics tests |
292
+ | Surface | Leader behavior | Follower requirement | Routing/ownership invariant | Regression evidence |
293
+ | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
294
+ | Prompt intake | Thread prompt queues locally | Thread prompt is forwarded and queued by the owning follower | Target ownership routes by `{ chatId, threadId }` before local handling | Routing tests for foreign target message forwarding |
295
+ | Queued-message removal reactions | 👎/👻/💔/💩/🗑 removes pending prompt/media turn | Same reaction on a queued follower prompt removes that follower's pending turn before dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
296
+ | Queue priority reactions | 👍/⚡/❤/🕊/🔥 prioritizes queued prompts | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
297
+ | Message edits | Edits update matching queued prompt text | Edits in a follower thread update that follower's queued prompt | Message target ownership forwards edits to the owning instance; stored message ownership is the fallback when Telegram edit payloads omit thread id | Update routing tests for foreign target and message-owned edited-message forwarding |
298
+ | Callbacks/buttons/menus | Callback handled by the owning instance/menu state | Follower callbacks are forwarded to the owning follower; follower menu sends/edits/deletes route through leader transport | Leader records ownership for follower-sent Bot API messages so callbacks can route by message id even when Telegram omits thread id; Bot API edit/delete lacks thread id, so follower bus allows validated same-chat message operations | Callback forwarding, generated-button target, bus follower-sent ownership, and bus edit/delete allowlist tests |
299
+ | Replies/finals | Final replies land in the same thread | Follower finals go through leader transport into follower thread | Outbound calls carry target and inject `message_thread_id` | Reply delivery and bus API tests |
300
+ | Previews/Rich Drafts | Draft previews use the active thread target | Follower previews use the same native draft lifecycle through the leader | Preview transport preserves target and draft id | Preview thread-target tests |
301
+ | Attachments/voice | Files and voice upload in the instance thread | Follower uploads route through leader multipart transport | Multipart calls are target-scoped and follower-authorized | Bus allowlist and outbound delivery tests |
302
+ | Native activity status | `sendChatAction(typing)` renders Telegram's native `…typing` indicator in the assigned thread and mirrors aggregate `All` for every agent run, including local and autonomous work; terminal status changes to `active` only for Telegram-owned turns | Followers route one thread action and one aggregate action through leader transport for any agent run while retaining their stable terminal role during non-Telegram work | Agent lifecycle targets the active Telegram turn when present and otherwise the instance binding; one keyed loop avoids duplicate aggregate sends/rate-limit pressure | Agent-start binding, typing-loop, target-routing, and terminal-status regressions |
303
+ | Leader election / promotion | Current leader keeps its thread across reload | A promoted follower keeps its existing thread, slot, and name when elected and after later reload | Promotion converts the current follower binding into the leader profile before forced lock acquisition, so leader startup reuses it instead of provisioning a new thread | Follower heartbeat recovery passes binding snapshot into promotion; own-topic provisioner reuses promoted bindings |
304
+ | `/start` command/menu bootstrap | Registers visible bot commands and opens the menu | Follower `/start` can refresh the bot command menu through the leader and open its local menu without warnings | Bot command registration is a validated global Bot API call allowed through trusted follower bus transport | Bus allowlist regression for `setMyCommands` |
305
+ | Follower reconnect | Existing leader binding is reused only when still usable | Same-process `/new` or `/reload` suspends the old follower socket/context and automatically re-registers the new session to the exact prior target; explicit reconnect to a genuinely closed/stale Telegram tab still recreates a visible thread before success | A short-lived handoff carries the assigned target across session replacement, and the leader transfers that binding to the new runtime instance id by stable manual-follower identity; stale Bot API errors remain the proof for fresh provisioning | Session handoff/refresh, leader binding-transfer, persisted leader-reload reuse, and stale-target replacement regressions |
306
+ | Unbound thread reroute/restore | New unbound thread can route to a live instance or replace the selected instance thread | Same chooser exposes all currently live bus leader/follower targets; restore is offered from concrete unbound threads, not historical snapshots | Live bus roster plus active target bindings define the selectable set; history/state snapshots are not authority | Routing chooser regressions for live target filtering and restore rows |
307
+ | Status/menu diagnostics | Status reflects leader role, queue, and target | Follower status reflects follower role, thread name, queue, and bus health | Status is local runtime truth plus bus registration state, not leader queue state | Status and bus diagnostics tests |
308
308
 
309
309
  ## Queue And State Scoping
310
310
 
package/index.ts CHANGED
@@ -1176,14 +1176,6 @@ export default function (pi: Pi.ExtensionAPI) {
1176
1176
  registrationState: telegramBusFollowerRegistrationState,
1177
1177
  instanceId: telegramInstanceId,
1178
1178
  suspendPolling: lockedPollingRuntime.suspend,
1179
- onFollowerSessionDisconnect() {
1180
- const target = telegramBusFollowerRegistrationState.getTarget();
1181
- if (!target) return undefined;
1182
- return BusFollower.markTelegramFollowerThreadStaleForSessionDisconnect({
1183
- topicTargetStore: threadStore,
1184
- target,
1185
- });
1186
- },
1187
1179
  recordRuntimeEvent,
1188
1180
  });
1189
1181
  const queueSessionLifecycle = Queue.createTelegramSessionLifecycleRuntime({
@@ -1308,6 +1300,12 @@ export default function (pi: Pi.ExtensionAPI) {
1308
1300
  telegramBusFollowerRegistrationState.isRegistered()
1309
1301
  );
1310
1302
  },
1303
+ canSendAgentActivity(ctx) {
1304
+ return (
1305
+ lockOwnershipGuard.ownsContext(ctx) ||
1306
+ telegramBusFollowerRegistrationState.isRegistered()
1307
+ );
1308
+ },
1311
1309
  updateStatus,
1312
1310
  recordRuntimeEvent,
1313
1311
  });
package/lib/bindings.ts CHANGED
@@ -268,6 +268,7 @@ interface TelegramLifecycleBindingDeps {
268
268
  proactivePushTargetGetter: () => Queue.TelegramQueueTarget | undefined;
269
269
  isProactivePushEnabled: () => boolean;
270
270
  canSendProactivePush: (ctx: Pi.ExtensionContext) => boolean;
271
+ canSendAgentActivity: (ctx: Pi.ExtensionContext) => boolean;
271
272
  updateStatus: TelegramBridgeStatusUpdater;
272
273
  recordRuntimeEvent: TelegramRuntimeEventRecorder;
273
274
  }
@@ -300,6 +301,7 @@ export function registerTelegramLifecycleRuntimeHooks({
300
301
  proactivePushTargetGetter,
301
302
  isProactivePushEnabled,
302
303
  canSendProactivePush,
304
+ canSendAgentActivity,
303
305
  updateStatus,
304
306
  recordRuntimeEvent,
305
307
  }: TelegramLifecycleBindingDeps): void {
@@ -396,6 +398,16 @@ export function registerTelegramLifecycleRuntimeHooks({
396
398
  const agentStartWithDedupReset = Lifecycle.createAgentStartDedupHook(
397
399
  agentLifecycleHooks.onAgentStart,
398
400
  );
401
+ const startAgentActivityTypingLoop = (ctx: Pi.ExtensionContext): void => {
402
+ if (!canSendAgentActivity(ctx)) return;
403
+ const turn = activeTurnRuntime.get();
404
+ const target = turn?.target ?? proactivePushTargetGetter();
405
+ promptDispatchRuntime.startTypingLoop(
406
+ ctx,
407
+ turn?.chatId ?? target?.chatId ?? proactivePushChatIdGetter(),
408
+ { target },
409
+ );
410
+ };
399
411
  const startActiveTurnTypingLoop = (ctx: Pi.ExtensionContext): void => {
400
412
  const turn = activeTurnRuntime.get();
401
413
  promptDispatchRuntime.startTypingLoop(ctx, turn?.chatId, {
@@ -431,7 +443,10 @@ export function registerTelegramLifecycleRuntimeHooks({
431
443
  },
432
444
  onSessionBeforeCompact: compactionObserver.onSessionBeforeCompact,
433
445
  onSessionCompact: compactionObserver.onSessionCompact,
434
- onAgentStart: agentStartWithDedupReset,
446
+ async onAgentStart(event, ctx) {
447
+ await agentStartWithDedupReset(event, ctx);
448
+ startAgentActivityTypingLoop(ctx);
449
+ },
435
450
  async onToolExecutionStart(_event, _ctx) {
436
451
  agentLifecycleHooks.onToolExecutionStart();
437
452
  },
@@ -40,6 +40,9 @@ export interface TelegramFollowerSessionHandoff {
40
40
  pid: number;
41
41
  instanceId: string;
42
42
  createdAtMs: number;
43
+ target: TelegramTarget;
44
+ slot?: string;
45
+ threadName?: string;
43
46
  }
44
47
 
45
48
  export function getTelegramFollowerSessionHandoff():
@@ -53,7 +56,10 @@ export function getTelegramFollowerSessionHandoff():
53
56
  if (
54
57
  typeof handoff.pid !== "number" ||
55
58
  typeof handoff.instanceId !== "string" ||
56
- typeof handoff.createdAtMs !== "number"
59
+ typeof handoff.createdAtMs !== "number" ||
60
+ !handoff.target ||
61
+ typeof handoff.target !== "object" ||
62
+ typeof handoff.target.chatId !== "number"
57
63
  ) {
58
64
  return undefined;
59
65
  }
@@ -83,16 +89,19 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
83
89
  registerWithLeader: (
84
90
  ctx: TContext,
85
91
  leader: { busSocketPath?: string; busSecret?: string },
92
+ options?: { target?: TelegramTarget },
86
93
  ) => Promise<boolean>;
87
94
  setContext: (ctx: TContext) => void;
88
95
  stop: () => void;
89
96
  }
90
97
 
91
98
  export interface TelegramBusFollowerSessionReplacementSuspenderDeps {
92
- registrationState: Pick<TelegramBusFollowerRegistrationState, "isRegistered">;
99
+ registrationState: Pick<
100
+ TelegramBusFollowerRegistrationState,
101
+ "isRegistered" | "getTarget" | "getSlot" | "getThreadName"
102
+ >;
93
103
  instanceId: string;
94
104
  suspendPolling: () => Promise<void>;
95
- onFollowerSessionDisconnect?: () => Promise<void> | void;
96
105
  recordRuntimeEvent: (
97
106
  category: string,
98
107
  error: unknown,
@@ -514,40 +523,30 @@ function isTelegramStaleContextError(error: unknown): boolean {
514
523
  );
515
524
  }
516
525
 
517
- export async function markTelegramFollowerThreadStaleForSessionDisconnect(input: {
518
- topicTargetStore: Threads.TelegramTopicTargetStore;
519
- target: TelegramTarget;
520
- }): Promise<void> {
521
- await input.topicTargetStore.load();
522
- const record = input.topicTargetStore.list().find(
523
- (candidate) =>
524
- candidate.target.chatId === input.target.chatId &&
525
- candidate.target.threadId === input.target.threadId,
526
- );
527
- const marked = input.topicTargetStore.markStaleByTarget(
528
- input.target,
529
- "unknown",
530
- "Follower session reloaded before reconnect.",
531
- );
532
- const forgotIdentity = record?.profileKey
533
- ? input.topicTargetStore.forgetIdentityByProfileKey(record.profileKey)
534
- : false;
535
- if (marked || forgotIdentity) await input.topicTargetStore.persist();
536
- }
537
-
538
526
  export function createTelegramBusFollowerSessionReplacementSuspender(
539
527
  deps: TelegramBusFollowerSessionReplacementSuspenderDeps,
540
528
  ): () => Promise<void> {
529
+ const getNowMs = deps.getNowMs ?? Date.now;
530
+ const getPid = deps.getPid ?? (() => process.pid);
541
531
  return async () => {
542
- if (deps.registrationState.isRegistered()) {
543
- setTelegramFollowerSessionHandoff(undefined);
544
- await deps.onFollowerSessionDisconnect?.();
532
+ const target = deps.registrationState.getTarget();
533
+ if (deps.registrationState.isRegistered() && target) {
534
+ setTelegramFollowerSessionHandoff({
535
+ pid: getPid(),
536
+ instanceId: deps.instanceId,
537
+ createdAtMs: getNowMs(),
538
+ target,
539
+ slot: deps.registrationState.getSlot(),
540
+ threadName: deps.registrationState.getThreadName(),
541
+ });
545
542
  deps.recordRuntimeEvent(
546
543
  "bus",
547
- "Telegram follower registration stopped for session replacement",
544
+ "Telegram follower registration suspended for session replacement",
548
545
  {
549
- phase: "follower-session-disconnect",
546
+ phase: "follower-session-handoff",
550
547
  instanceId: deps.instanceId,
548
+ chatId: target.chatId,
549
+ threadId: target.threadId,
551
550
  },
552
551
  );
553
552
  }
@@ -568,6 +567,7 @@ export function createTelegramBusFollowerSessionRefreshHook<TContext>(
568
567
  const restored = await deps.registrationRuntime.registerWithLeader(
569
568
  ctx,
570
569
  lockState.lock,
570
+ { target: handoff.target },
571
571
  );
572
572
  if (restored) {
573
573
  setTelegramFollowerSessionHandoff(undefined);
@@ -828,7 +828,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
828
828
  heartbeatInterval.unref?.();
829
829
  };
830
830
  return {
831
- registerWithLeader: async (ctx, leader) => {
831
+ registerWithLeader: async (ctx, leader, options) => {
832
832
  const leaderSocketPath =
833
833
  leader.busSocketPath ??
834
834
  deps.getLeaderSocketPath?.() ??
@@ -853,7 +853,10 @@ export function createTelegramBusFollowerRegistrationRuntime<
853
853
  (ctx.cwd ? basename(ctx.cwd) : undefined),
854
854
  cwd: ctx.cwd,
855
855
  pid: getPid(),
856
- target: deps.registrationState?.getTarget() ?? lastKnownTarget,
856
+ target:
857
+ options?.target ??
858
+ deps.registrationState?.getTarget() ??
859
+ lastKnownTarget,
857
860
  busSocketPath:
858
861
  deps.getFollowerBusSocketPath?.() ?? deps.followerBusSocketPath,
859
862
  connectedAtMs: getNowMs(),
package/lib/bus-leader.ts CHANGED
@@ -236,6 +236,8 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
236
236
  });
237
237
  }
238
238
 
239
+ export const TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS = 10_000;
240
+
239
241
  export interface TelegramBusFollowerBindingRealityDeps {
240
242
  topicTargetStore: Pick<
241
243
  Threads.TelegramTopicTargetStore,
@@ -248,6 +250,8 @@ export interface TelegramBusFollowerBindingRealityDeps {
248
250
  | "setBotState"
249
251
  >;
250
252
  followerRegistry: Pick<TelegramBusFollowerRegistry, "list">;
253
+ getNowMs?: () => number;
254
+ recentBindingGraceMs?: number;
251
255
  recordRuntimeEvent: (
252
256
  category: string,
253
257
  error: unknown,
@@ -301,8 +305,13 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
301
305
  export function createTelegramBusFollowerBindingRealityReconciler(
302
306
  deps: TelegramBusFollowerBindingRealityDeps,
303
307
  ): () => Promise<number> {
308
+ const getNowMs = deps.getNowMs ?? Date.now;
309
+ const recentBindingGraceMs =
310
+ deps.recentBindingGraceMs ??
311
+ TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS;
304
312
  return async () => {
305
313
  await deps.topicTargetStore.load();
314
+ const nowMs = getNowMs();
306
315
  const liveInstanceIds = new Set(
307
316
  deps.followerRegistry.list().map((follower) => follower.instanceId),
308
317
  );
@@ -310,7 +319,8 @@ export function createTelegramBusFollowerBindingRealityReconciler(
310
319
  return (
311
320
  record.owner?.kind === "manual-follower" &&
312
321
  !!record.instanceId &&
313
- !liveInstanceIds.has(record.instanceId)
322
+ !liveInstanceIds.has(record.instanceId) &&
323
+ nowMs - record.updatedAtMs > recentBindingGraceMs
314
324
  );
315
325
  });
316
326
  const staleInstanceIds = new Set(
@@ -452,18 +462,19 @@ export function createTelegramBusFollowerTargetProvisioner(
452
462
  claimPendingTargets: false,
453
463
  });
454
464
  const recordsBeforeProvision = deps.topicTargetStore.list();
465
+ const followerProfileKey =
466
+ registration.profileKey ?? `manual:${registration.instanceId}`;
455
467
  const reconnectRecord = registration.target
456
468
  ? recordsBeforeProvision.find((record) => {
457
469
  return (
458
470
  record.owner?.kind === "manual-follower" &&
459
- record.instanceId === registration.instanceId &&
471
+ (record.instanceId === registration.instanceId ||
472
+ record.profileKey === followerProfileKey) &&
460
473
  record.target.chatId === registration.target?.chatId &&
461
474
  record.target.threadId === registration.target.threadId
462
475
  );
463
476
  })
464
477
  : undefined;
465
- const followerProfileKey =
466
- registration.profileKey ?? `manual:${registration.instanceId}`;
467
478
  const followerOwner =
468
479
  Threads.getTelegramThreadOwnerFromProfileKey(followerProfileKey);
469
480
  const registrationKey = followerProfileKey || registration.instanceId;
@@ -494,6 +505,25 @@ export function createTelegramBusFollowerTargetProvisioner(
494
505
  let result = reconnectRecord
495
506
  ? { target: reconnectRecord.target, reused: true, record: reconnectRecord }
496
507
  : await provisionTarget();
508
+ if (reconnectRecord) {
509
+ const nowMs = getNowMs();
510
+ const transferredRecord = deps.topicTargetStore.upsert({
511
+ ...reconnectRecord,
512
+ instanceId: registration.instanceId,
513
+ updatedAtMs: nowMs,
514
+ lastSyncObservedAtMs: nowMs,
515
+ lastReconcileAction:
516
+ reconnectRecord.instanceId === registration.instanceId
517
+ ? "follower-register-reuse"
518
+ : "follower-session-handoff",
519
+ });
520
+ await deps.topicTargetStore.persist();
521
+ result = {
522
+ target: transferredRecord.target,
523
+ reused: true,
524
+ record: transferredRecord,
525
+ };
526
+ }
497
527
  deps.setSyncState(
498
528
  Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
499
529
  nowMs: getNowMs(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"