@llblab/pi-telegram 0.20.3 → 0.20.5

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,12 +79,12 @@ 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 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
- - 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.
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. An authenticated live follower whose carried exact target is missing from current persisted bindings should recover that target instead of creating another Telegram thread; preserve a carried slot only when it does not collide with another current record. 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
+ - 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, live diagnostics, and `[telegram|thread:name]` through one target-aware current-instance identity resolver. Registered follower/leader metadata takes precedence over a stale shared record for the same target, with the record used only as fallback; 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.
86
86
  - Telegram extension work should not hold Pi's core agent lifecycle hostage once Pi has produced the semantic result. For Telegram-originated turns, final Telegram delivery, attachment upload, and transport cleanup are extension-owned side effects; schedule them off the critical `agent_end` path when ordering can still be preserved, record failures as runtime events, and keep dispatch of the next Telegram turn behind the delivery task when needed to avoid interleaving previews/finals.
87
- - Telegram runtime state should be treated as event-driven reconciliation of extension assumptions against observable Telegram signals, not as a full Telegram bot read-model and not as a reason to query Telegram on every action. Keep a local assumption model for bot identity/capabilities, pairing assumptions, thread support, known thread lifecycle, bound instance targets, reservations, and transport health. Invalidate and reconcile the relevant slice on meaningful events: startup/reload, lifecycle service messages, stale-send/API errors, setup/pairing changes, follower register/prune, explicit status/debug requests, and bounded low-frequency leader health ticks. `lib/sync.ts` owns sync slices, observation intake, invalidation triggers, status/debug freshness, and reconciliation scheduling; it must not promise complete bot-state mirroring because Bot API lacks a complete topic listing surface. `lib/thread-reconciler.ts` owns pure Threaded Mode lifecycle planning and should become the only policy authority for destructive thread cleanup decisions. `threads` owns current Telegram UI thread/tab binding primitives and thread-name helpers mapped to Bot API topic transport, `status` renders projections, and `index.ts` only wires ports. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging and resets on extension start / runtime scope changes; it is diagnostics only, not routing/provisioning authority. `state.json` should be an observable/debug snapshot aligned with `/telegram-status` (extension runtime, bot capabilities including `bot.lastSlot`, runtime role, live roster projection, reservations, diagnostics), not routing/provisioning authority. Live bus/runtime state is authoritative; file reservations and fresh capability observations may be startup hints/collision guards only; stale snapshots must re-probe before suppressing bus/topic behavior. Do not persist stale/offline/failed thread target history as source of truth.
87
+ - Telegram runtime state should be treated as event-driven reconciliation of extension assumptions against observable Telegram signals, not as a full Telegram bot read-model and not as a reason to query Telegram on every action. Keep a local assumption model for bot identity/capabilities, pairing assumptions, thread support, known thread lifecycle, bound instance targets, reservations, and transport health. Invalidate and reconcile the relevant slice on meaningful events: startup/reload, lifecycle service messages, stale-send/API errors, setup/pairing changes, follower register/prune, explicit status/debug requests, and bounded low-frequency leader health ticks. `lib/sync.ts` owns sync slices, observation intake, invalidation triggers, status/debug freshness, and reconciliation scheduling; it must not promise complete bot-state mirroring because Bot API lacks a complete topic listing surface. `lib/thread-reconciler.ts` owns pure Threaded Mode lifecycle planning and should become the only policy authority for destructive thread cleanup decisions. `threads` owns current Telegram UI thread/tab binding primitives and thread-name helpers mapped to Bot API topic transport, `status` renders projections, and `index.ts` only wires ports. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging and resets on extension start / runtime scope changes; it is diagnostics only, not routing/provisioning authority. `state.json` should be an observable/debug snapshot aligned with `/telegram-status` (extension runtime, bot capabilities including `bot.lastSlot`, runtime role, live roster projection, reservations, diagnostics), not routing/provisioning authority. Because every process on one Telegram profile shares this file, only the active transport lock owner may persist it; followers read current state and gain write authority only after promotion. Status-only writes must refresh disk-backed bindings before serialization so a stale loaded snapshot cannot erase newer leader state. Live bus/runtime state is authoritative; file reservations and fresh capability observations may be startup hints/collision guards only; stale snapshots must re-probe before suppressing bus/topic behavior. Do not persist stale/offline/failed thread target history as source of truth.
88
88
  - Thread bindings are bidirectional. From the Pi-instance side, an instance knows and preserves its target, slot, thread name, lifecycle state, and direct-delivery defaults. From the Telegram-client/bot side, the bridge observes thread creation/close/reopen/stale-send signals and reconciles them into instance binding state. Lifecycle transitions that affect operator understanding should be Telegram-visible when a live transport remains: every successful follower registration/re-registration gets a compact connected notice in the assigned thread, while heartbeat pruning stays silent because it is only liveness suspicion. These notices should use the instance thread name when known and fall back to the slot letter only while the thread is still unnamed. New unknown threads (owner writing in `All`) are preserved until the user explicitly chooses a reroute/restore/new-instance action that makes cleanup eligible; any destructive cleanup must go through `thread-reconciler`. Unknown `forum_topic_created` service events are observations, not destructive cleanup proof.
89
89
  - The multi-instance bus uses private bot DMs with Telegram private-chat Threaded Mode enabled for the bot. No supergroup, group, or forum configuration is needed; the thread chat is always the private chat with the paired owner. Threaded capability checks must use bot/private-chat evidence such as `getMe.has_topics_enabled`, incoming `message_thread_id`, and topic operation success/failure; do not use group chat metadata as the control-plane truth for Telegram private-chat Threaded Mode.
90
90
  - Target-scoped behavior must preserve `{ chatId, threadId? }` through inbound messages/edits/callbacks/reactions, thread lifecycle service messages, media and split-text grouping, queue mutations, active-turn cleanup, previews, reply deduplication, menus, sections, generated buttons, voice, attachments, and direct delivery. Threaded Mode replies must keep Telegram's reply affordance to the triggering message just like classic DM mode. In Telegram private-chat Threaded Mode, mobile Telegram has been live-verified to render `sendMessage` replies that include top-level `message_thread_id` plus same-chat `reply_parameters.message_id`; Telegram Desktop may fail to show the visual reply header for the same correct payload, so do not treat Desktop-only absence as bridge failure without mobile/payload evidence.
package/BACKLOG.md CHANGED
@@ -2,6 +2,70 @@
2
2
 
3
3
  _This backlog tracks only open release-relevant work: live promoted-follower verification, evidence-gated Telegram client/runtime follow-ups, and upstream Pi API blockers. Completed validation evidence belongs in `CHANGELOG.md`, not in this queue._
4
4
 
5
+ ## P0 — Private Guest DM Peer Attribution
6
+
7
+ Evidence: live private Guest Mode produced `[telegram|guest:<owner>]` for an owner-authored DM turn even though `guest` must identify the remote conversation peer. Code inspection confirms that private guest routing falls back to `fromPeer` whenever an owner-authored message has no usable `reply_to_message`; because `from.id` then equals the configured `allowedUserId`, the owner is mislabeled as the guest. Existing coverage protects incoming guest messages and owner replies with explicit replied-guest metadata, but does not cover owner-authored private guest messages without reply context.
8
+
9
+ Planned work:
10
+
11
+ - [ ] Capture or minimize the raw private `guest_message` shape for owner-authored turns without reply context and identify the stable remote-peer fields supplied by Telegram (`chat` identity, username/name/id, or another explicit peer field) before choosing a resolver.
12
+ - [ ] Centralize Guest Mode peer attribution: group turns use the group title; private non-owner turns use the sender; private owner turns use the replied guest when present and otherwise the remote private-chat peer.
13
+ - [ ] Compare ownership by Telegram user id (`allowedUserId`), not display name or username. Never emit the configured owner as `guest`; if Telegram omits a username, fall back to the remote peer's stable name/id rather than the owner.
14
+ - [ ] Keep `[reply|from:...]` and `[attachments|from:...]` source attribution aligned with the same resolved peer without changing the current-turn/source-context distinction.
15
+ - [ ] Add regressions for incoming private guests, owner replies, owner-authored no-reply turns, missing usernames, username changes, and named-profile pairing identities.
16
+ - [ ] Update the prompt-context contract/docs only after the minimized Telegram fixture establishes the actual private Guest Mode field semantics.
17
+
18
+ Done when: `[telegram|guest:...]` always identifies the remote peer or group for private/group Guest Mode, never the paired owner, and reply/attachment provenance remains source-correct.
19
+
20
+ ## P0 — Guest Reply File And Audio Delivery
21
+
22
+ Evidence: live Guest Mode accepted `telegram_attach` during an active guest turn and reported the file as queued, but delivered nothing. Code inspection confirms that guest turns use sentinel `chatId: 0`; the tool appends files to `queuedAttachments`, then the agent-end guest branch sends only `answerGuestQuery` text and returns before queued attachments or voice artifacts run. Telegram's `answerGuestQuery` accepts one `InlineQueryResult`, not ordinary `sendDocument`/`sendVoice` multipart delivery, so local artifacts require a guest-specific result plan rather than reuse of chat/thread attachment transport.
23
+
24
+ Planned work:
25
+
26
+ - [x] Fail closed immediately for unsupported guest attachments until guest delivery is available; never return `Queued` when the guest agent-end path cannot consume the artifact.
27
+ - [x] Map the current Bot API `InlineQueryResult` capabilities for document, photo, audio, and voice replies, including URL versus cached `file_id`, caption limits, supported formats, and the one-result-per-guest-query constraint. `answerGuestQuery` accepts exactly one result; local multipart uploads are not accepted there. URL results require public HTTP content (documents only PDF/ZIP, audio MP3, voice OGG/OPUS, photos JPEG up to 5 MB), while cached result variants accept Telegram `document_file_id`, `photo_file_id`, `audio_file_id`, or `voice_file_id`; media captions remain limited to 0–1024 characters after entity parsing.
28
+ - [x] Design one guest reply planner that chooses exactly one result: text article, one cached local file/media with answer text reduced to a caption, or one cached synthesized voice/audio result. Guest tool admission rejects a second attachment before mutation. A failure before the one-shot answer may degrade to one text article; an ambiguous/failing `answerGuestQuery` call must not issue a second answer that could duplicate delivery.
29
+ - [x] Determine an evidence-backed local-file staging path. Local media must upload through the existing leader-owned multipart transport to the paired owner's bot chat, extract the returned Telegram `file_id`, answer the guest query with the matching cached result, and delete the staging message in `finally`. The staging message can briefly appear or notify the owner; this unavoidable Bot API limitation must be documented, no external hosting is introduced, and cleanup failure must be diagnosed rather than hidden.
30
+ - [x] Extend `answerGuestQuery` and bus forwarding from hard-coded article input to the minimal typed result union required by confirmed file/audio/voice cases.
31
+ - [x] Route `telegram_attach`, queued outbound artifacts, and `telegram_voice` through the guest planner before the guest branch returns; never call ordinary multipart methods with sentinel `chatId: 0`.
32
+ - [x] Preserve follower operation by routing staging and `answerGuestQuery` through the transport leader without duplicate answers or leaked staging messages.
33
+ - [x] Add regressions for unsupported fail-closed behavior, document/image/audio/voice result construction, caption fallback, staging cleanup/failure, multiple-file rejection, guest query one-shot semantics, and text fallback after media failure.
34
+ - [ ] Capture live private and group Guest Mode evidence for one local document and one synthesized voice/audio reply before claiming support.
35
+
36
+ Done when: guest turns never silently lose queued artifacts, one supported local file or audio/voice result can be delivered through `answerGuestQuery` with clear constraints, and unsupported/multi-file cases fail visibly without sending to an unrelated thread.
37
+
38
+ ## P1 — Compaction Status Ownership And Native Activity
39
+
40
+ Context: Pi already renders its own compaction lifecycle, while pi-telegram currently overrides its terminal status row with `compacting` whenever the shared compaction flag is set. This duplicates Pi-owned state and hides the distinction between Telegram-owned activity and unrelated automatic/session compaction. Manual `/compact` already calls the typing-loop port and automatic compaction starts typing only when an active Telegram turn exists, so the reported absence of Telegram `…typing` needs transport-level and live verification rather than an assumed rewrite.
41
+
42
+ Planned work:
43
+
44
+ - [x] Remove `compacting` as a pi-telegram terminal status label while retaining the internal compaction flag for queue/dispatch safety and explicit diagnostics.
45
+ - [x] Track compaction origin for status projection: confirmed Telegram `/compact` and auto-compaction inside a Telegram-owned turn render normal `Active`; local/autonomous/background compaction keeps the stable `connected`, `leader`, or `follower` role.
46
+ - [x] Define and verify the native activity matrix: Telegram-owned compaction targets the invoking/active thread plus `All`; non-Telegram compaction uses the connected instance target plus `All` without changing terminal role semantics.
47
+ - [x] Trace manual confirmation, `session_before_compact`, `session_compact`, completion, error, timeout, abort, and shutdown ordering to ensure one keyed typing loop remains active for the whole compaction window and always stops.
48
+ - [x] Add transport-level regressions that assert actual `sendChatAction(typing)` targets and keepalive lifecycle, not only invocation of a mocked `startTypingLoop` callback.
49
+ - [x] Replace status tests that currently require `compacting` with Telegram-owned `Active` and non-Telegram stable-role cases; preserve `/telegram-status` compaction diagnostics where operationally useful.
50
+ - [ ] Capture live evidence for manual Telegram compaction, auto-compaction during a Telegram turn, and non-Telegram auto-compaction before finalizing the activity contract.
51
+
52
+ Done when: Pi remains the only terminal owner of the `compacting` label, pi-telegram status reflects Telegram ownership rather than generic compaction, and Telegram native `…typing` remains visible and correctly targeted throughout every confirmed compaction class without leaking afterward.
53
+
54
+ ## P1 — Leader Endpoint Loss Recovery
55
+
56
+ Context: live evidence showed a process retaining a fresh transport lock and active polling while its Threaded Mode Unix socket path was absent. The likely trigger was external removal of the shared Telegram temp directory while the owner process remained alive. The local server keeps listening on the unlinked Unix socket but `start()` treats its in-memory server handle as sufficient, leader health checks only Bot API transport, and a new instance therefore exhausts follower-registration retries with `ENOENT`. This is a real diagnosable recovery gap, but not yet evidence for a broad readiness protocol or automatic takeover; force-acquiring while the old owner may still run `getUpdates` would risk split-brain.
57
+
58
+ Planned work:
59
+
60
+ - [x] Reproduce deterministically by unlinking only the active Unix leader socket while its process, polling runtime, and in-memory server remain live. Native Windows named pipes have no equivalent filesystem path to unlink, so recovery remains Unix-specific unless separate named-pipe evidence appears.
61
+ - [x] Let the owning Threaded Mode runtime detect an externally missing Unix endpoint during its existing health/prune cadence and restart only the local bus server without changing lock ownership, leader epoch, polling, or thread bindings.
62
+ - [x] Make initial follower registration report `live owner / unreachable bus endpoint` after bounded retries, with direct operator guidance; do not add automatic or force takeover without separate evidence that the old owner cannot still poll.
63
+ - [x] Keep intentional classic ownership unchanged because classic mode does not require a bus endpoint.
64
+ - [x] Add focused regressions for Unix endpoint unlink/rebind, bounded follower diagnosis, leader reload overlap, and no duplicate `getUpdates` ownership; add Windows coverage only for behavior the named-pipe transport can reproduce.
65
+ - [ ] Capture live recovery evidence without deleting lock/state or creating a replacement Telegram thread.
66
+
67
+ Done when: the confirmed endpoint-loss scenario either self-recovers under the existing owner or produces precise safe remediation, while classic mode and single-owner polling remain unchanged.
68
+
5
69
  ## P1 — Promoted Follower Reload Evidence
6
70
 
7
71
  Context: deterministic coverage protects promoted follower thread preservation, and the latest live Linux smoke closed reload routing, follower Active, and reroute/restore regressions. The exact promoted-leader reload path is deliberately outside the 0.20.1 profile IPC hotfix because it is unrelated to profile transport isolation; keep it as an evidence-gated follow-up rather than blocking that release.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.5: Guest Media And Runtime Recovery Hotfix
4
+
5
+ - `[Setup Persistence]` The setup prompt now applies the validated bot identity to its config store before invoking persistence, so adapters that serialize current store state cannot write an empty or stale `telegram.json` on first run. Persistence failure rolls the in-memory candidate back and remains ahead of success notifications and polling startup. Impact: setup from a missing file or `{}` durably writes `botToken`, `botId`, and `botUsername` on the first successful command for default and named profiles without reporting an unsaved connection.
6
+ - `[Validation]` Added file-backed setup regressions for missing and empty config files, environment-provided tokens, named-profile isolation, and persistence-failure rollback while preserving existing cancellation, validation, polling-failure, and atomic config-write coverage. Full validation passes with 1,098 tests and one platform-only skip; typecheck and npm audit remain clean.
7
+ - `[Guest Attachments]` `telegram_attach` now admits exactly one local file during an active Guest Mode turn and rejects a second before file inspection or queue mutation. Agent-end stages that file through the paired-owner chat, sends one cached-media guest result with final text as its caption, cleans up the staging message, and never follows an ambiguous media-answer failure with a second guest answer. Impact: a requested local file is no longer silently discarded, while Telegram's one-result constraint remains explicit.
8
+ - `[Guest Media Transport]` `answerGuestQuery` now accepts a typed cached document/photo/audio/voice result in addition to text and Rich Markdown articles, and the bus-aware API preserves that exact result when a follower routes it through the leader. Impact: guest delivery no longer hard-codes article construction at the direct or IPC boundary, providing the minimal one-shot transport needed for staged local media without widening the follower API allowlist.
9
+ - `[Guest Media Staging]` Added a bounded local-media staging primitive that selects document/photo/audio/voice multipart transport, extracts the returned Telegram `file_id`, emits one matching cached guest result, truncates captions safely to 1,024 code points, and deletes the staging message in `finally`. Extraction and answer failures still clean up; cleanup failures record diagnostics without retrying the one-shot guest answer. Impact: local Guest Mode artifacts have deterministic cleanup and no duplicate-answer path.
10
+ - `[Guest Voice]` Guest agent-end now routes one explicit or policy-intercepted voice reply through the existing synthesis/handler chain, captures the generated OGG/OPUS artifact instead of sending it to sentinel chat `0`, stages it through the leader-owned multipart transport, and answers with one cached voice result. Visible answer text becomes the media caption, and multiple voice blocks reduce to the first result under Telegram's one-query/one-result contract. Impact: ordinary Guest Mode responses can include synthesized audio without bypassing ownership or emitting a separate text answer.
11
+ - `[Leader Endpoint Recovery]` The active Threaded Mode leader now checks its Unix socket during the existing follower-health cadence. If an external cleanup unlinks the endpoint while the server and polling owner remain alive, it closes only the orphaned local server, recreates the socket and parent directory, and resumes follower reachability without restarting polling, changing leader epoch, or touching thread bindings. Named pipes and classic mode remain unchanged because they do not expose the same filesystem-loss condition. Impact: a live owner can self-heal the observed `ENOENT bus.sock` split between polling health and follower connectivity without unsafe lock takeover.
12
+ - `[Follower Diagnostics]` When bounded registration retries fail with `ENOENT`, `ECONNREFUSED`, or `ETIMEDOUT` behind a still-live lock owner, the connection result now identifies `live owner / unreachable bus endpoint`, asks the operator to wait briefly and retry `/telegram-connect`, and explicitly rejects force takeover while the owner remains live. Impact: transient endpoint recovery no longer looks like a generic registration failure or invite split-brain remediation.
13
+ - `[Profile Diagnostics]` Current logs use `logs.jsonl` for default and `logs.<profile>.jsonl` for named profiles; preserved logs use `logs._prev.jsonl` and `logs.<profile>._prev.jsonl`. Status and `telegram_help` resolve paths through the shared helper, old logs remain untouched as ephemeral evidence, and profile names allow only lowercase ASCII letters and digits. Impact: `_prev` remains an unmistakable lifecycle suffix because underscores and dots cannot occur in profile identifiers, while compact dotted profile filenames remain readable.
14
+ - `[Compaction Status]` Removed pi-telegram's terminal and status-summary `compacting` projection while retaining the compaction flag in explicit bridge diagnostics and dispatch safety. A Telegram-owned active turn continues to render `active`; unrelated compaction leaves the stable connected/leader/follower role visible. Impact: Pi remains the sole owner of compaction lifecycle UI, and pi-telegram reports Telegram ownership rather than duplicating generic session state.
15
+ - `[Compaction Activity]` Compaction now starts the same connected-instance native typing path used by agent activity instead of suppressing typing when no Telegram turn is active. Active Telegram compaction targets its thread plus `All`; local/autonomous compaction targets the instance's assigned thread plus `All`, with completion, timeout, and shutdown stopping the keyed loop. Impact: compaction remains visible in Telegram without taking ownership of Pi's terminal lifecycle label.
16
+
17
+ ## 0.20.4: Thread State Ownership Hotfix
18
+
19
+ - `[State Ownership]` Made the active transport lock owner the only process allowed to persist the profile-shared `state.json`; followers remain readers and acquire write authority only after promotion. Status-only persistence now refreshes disk-backed bindings before serialization. Impact: a stale follower diagnostics snapshot cannot erase newer leader-owned bindings, produce duplicate slot occupancy, or make a live follower disappear from current thread state.
20
+ - `[Follower Recovery]` Followers now carry target, slot, and thread name during re-registration. When an authenticated live follower carries an exact target missing from persisted bindings, the leader recovers that target without creating another Telegram thread and restores its slot only when unoccupied; a matching previous live-roster observation can recover the name for an older follower runtime. Impact: damaged local state converges around the surviving Telegram tab instead of replacing it or preserving a duplicate letter.
21
+ - `[Thread Identity]` Unified terminal status and `[telegram|thread:name]` behind one target-aware current-instance identity resolver. Registered follower/leader metadata takes precedence over a stale shared record for the same target, with persisted state used only as fallback. Impact: when the prompt tag correctly identifies `Beacon`, terminal status can no longer regress to a previous thread name from stale state.
22
+ - `[Live Linux]` A clean same-directory bootstrap produced one leader and two followers with exact unique bindings `A / Aster / 524667`, `B / Beacon / 524669`, and `C / Cinder / 524671`; the live roster and persisted targets agreed without duplicate slots. Impact: multiple processes launched from the same cwd no longer expose the state-writer collision that originally duplicated slot `E`.
23
+ - `[Validation]` Added deterministic coverage for stale status writers, denied follower writes, promotion-time write authority, carried identity metadata, exact live-target recovery, collision-safe slot recovery, and status/prompt identity convergence. The full release validation passes with one platform-only named-pipe skip. Impact: the original corruption and cross-surface mismatch scenarios are release-gated.
24
+
3
25
  ## 0.20.3: Persistent Threads And Activity
4
26
 
5
27
  - `[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`.
package/README.md CHANGED
@@ -151,6 +151,8 @@ Run these inside Pi.
151
151
  | `/telegram-disconnect` | Stop polling and release ownership |
152
152
  | `/telegram-status` | Inspect connection, mode, queue, transport, and recent diagnostics |
153
153
 
154
+ Named profile identifiers contain only lowercase ASCII letters and digits (maximum 32 characters); `default`, `main`, and `active` remain reserved.
155
+
154
156
  ## Main Surfaces
155
157
 
156
158
  ### Operator Menu
@@ -120,11 +120,11 @@ Deleting `locks.json` resets runtime ownership without deleting Telegram configu
120
120
 
121
121
  Telegram private-chat Threaded Mode is the public switch for multi-instance Telegram operation. Classic single-DM polling is the base mode. When Telegram private-chat threads are available for the bot, the bridge enables the local leader/follower bus automatically; when threads are unavailable or later disabled, the bridge returns to classic single-DM polling as a first-class mode.
122
122
 
123
- Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes singleton locks, diagnostics logs, state files, thread/bus owner keys, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the default profile. A different selected profile is a parallel bot runtime: its locks, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, previous log, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and from other named profiles while shared bridge settings remain top-level/global. The default profile preserves legacy state, log, socket, and named-pipe paths for compatibility.
123
+ Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes singleton locks, diagnostics logs, state files, thread/bus owner keys, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the default profile. A different selected profile is a parallel bot runtime: its locks, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and from other named profiles while shared bridge settings remain top-level/global. The default profile preserves legacy state, log, socket, and named-pipe paths for compatibility.
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. 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.
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. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers that target without creating a replacement Telegram thread; a carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
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
 
@@ -214,14 +214,14 @@ Queue and menu mutations are reachable through Telegram updates handled by the c
214
214
 
215
215
  ### Compaction And Typing Status
216
216
 
217
- Manual `/compact` requires inline confirmation because accidental taps are disruptive. Confirmed manual compaction and auto-compaction both set the bridge compaction flag, block queued prompt dispatch, update status to `compacting`, and clear that state on compact completion, timeout fallback, or session shutdown.
217
+ Manual `/compact` requires inline confirmation because accidental taps are disruptive. Confirmed manual compaction and auto-compaction both set the bridge compaction flag, block queued prompt dispatch, retain that flag in explicit diagnostics, and clear it on compact completion, timeout fallback, or session shutdown. Pi owns its terminal compaction lifecycle; pi-telegram keeps `Active` scoped to Telegram-owned work and otherwise preserves the stable connected/leader/follower role.
218
218
 
219
- Native typing during compaction is deliberately narrower than the compaction flag:
219
+ Native typing during compaction follows connected-instance activity rather than terminal status:
220
220
 
221
- - Confirmed manual `/compact` always starts a native `typing` keepalive in the command target and stops it on completion/failure.
222
- - Automatic/session compaction starts native `typing` only when there is an active Telegram-owned turn; it must reuse that active turn's target.
223
- - Startup, reload, connect/reconnect, restore, leader/follower recovery, and idle/background compaction without an active Telegram turn must not send visible typing.
224
- - Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface.
221
+ - Confirmed manual `/compact` starts a native `typing` keepalive in the command target and stops it on completion/failure.
222
+ - Automatic/session compaction with an active Telegram turn reuses that turn's target.
223
+ - Automatic/session compaction without an active Telegram turn uses the connected instance's assigned target; an unconnected instance sends nothing.
224
+ - Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface; completion, timeout, and shutdown stop the keyed loop.
225
225
 
226
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
 
@@ -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. 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.
158
+ Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding, then promote only after the grace window expires. If the exact live target is absent from persisted bindings, the leader recovers it instead of creating another Telegram thread and keeps its carried slot only when that slot is free. 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
 
@@ -192,7 +192,7 @@ Manual smoke checklist:
192
192
  6. Close the follower terminal; verify heartbeat pruning, disconnected notice, and cleanup behavior match Unix-like behavior.
193
193
  7. Reload the leader and verify status/debug output does not expose raw pipe internals except in explicit diagnostics.
194
194
 
195
- If any step fails, capture `telegram-status --debug`, `tmp/telegram/state.json`, `tmp/telegram/logs.jsonl`, and, after a reload, `tmp/telegram/logs.previous.jsonl`. Debug status prints local leader/follower endpoints with their active transport kind (`pipe` or `socket`), while the runtime log records request-scoped transport failures with envelope kind, request id, retry attempt, endpoint, and classified IPC error. Reloads preserve the prior JSONL log as `logs.previous.jsonl` so the evidence that caused the reload is not immediately overwritten.
195
+ If any step fails, capture `telegram-status --debug`, `tmp/telegram/state.json`, `tmp/telegram/logs.jsonl`, and, after a reload, `tmp/telegram/logs._prev.jsonl`. Debug status prints local leader/follower endpoints with their active transport kind (`pipe` or `socket`), while the runtime log records request-scoped transport failures with envelope kind, request id, retry attempt, endpoint, and classified IPC error. Reloads preserve the prior JSONL log as `logs._prev.jsonl` so the evidence that caused the reload is not immediately overwritten.
196
196
 
197
197
  ### Native Windows Assumption Audit
198
198
 
@@ -346,10 +346,10 @@ Rules:
346
346
  Current state under the agent dir:
347
347
 
348
348
  - `locks.json`: current bus leader identity, capability secret, heartbeat, and cleanup fencing epoch. The local bus endpoint is derived from the agent directory by default; legacy `busSocketPath` entries are tolerated but are not required.
349
- - `tmp/telegram/state.json`: volatile extension+bot observable/debug snapshot, not routing authority. It writes `source: "snapshot"` and `writtenAtMs` so consumers do not confuse it with an authoritative database. It mirrors `/telegram-status`-style projections: top-level `bot` stores bot-wide capability state such as `threadMode: "unknown" | "enabled" | "disabled"`, `runtime` identifies leader/follower role and process status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors status/debug signals, `threads` stores current routeable bindings, `bot.lastSlot` stores the compact slot cursor used when all current threads are gone, and `reservations` records short-lived slot collision guards.
349
+ - `tmp/telegram/state.json`: volatile extension+bot observable/debug snapshot, not routing authority. It writes `source: "snapshot"` and `writtenAtMs` so consumers do not confuse it with an authoritative database. Every process on one Telegram profile reads this shared path, but only the active transport lock owner may persist it; followers become writers only after promotion. Status-only persistence refreshes disk-backed bindings before serialization so an already-loaded stale view cannot erase newer leader records. It mirrors `/telegram-status`-style projections: top-level `bot` stores bot-wide capability state such as `threadMode: "unknown" | "enabled" | "disabled"`, `runtime` identifies leader/follower role and process status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors status/debug signals, `threads` stores current routeable bindings, `bot.lastSlot` stores the compact slot cursor used when all current threads are gone, and `reservations` records short-lived slot collision guards.
350
350
  - Local bus endpoints: Unix-like platforms use `tmp/telegram/bus.sock` and `tmp/telegram/followers/*`; native Windows uses deterministic named pipes under `\\.\pipe\pi-telegram-...`. These are transient IPC endpoints, not durable routing state.
351
351
 
352
- The bridge must not keep a durable `telegram-targets.json` history. Stale/offline/failed thread entries are reconciliation observations, not reusable source-of-truth state; persisting them increases collision risk. `sync` is event-driven assumption reconciliation, not full Telegram bot-state mirroring, because Bot API does not expose a complete topic/thread listing surface. `state.json` therefore exists for extension+bot observability, diagnostics, startup hints, and explaining reconciliation decisions; live bus/runtime state remains authoritative for routing and provisioning. Non-current routeable thread bindings are pruned during load/persist; old session records must not be retained just to compute the next slot because `bot.lastSlot` is the only durable cursor. Previous-process leader bindings are treated as occupied TTL-bounded reservations until Telegram confirms deletion: reload/startup may close/delete/probe the old thread, known reservations are retried proactively on leader startup, and if Telegram still accepts the old thread id, the new leader should provision the next free slot (`B`, `C`, …) rather than creating a duplicate same-letter tab or blocking startup on Telegram UI convergence. Routing must use live current threads/follower registry, never reservations. The bus leader provisions its own thread during bus startup/connect and provisions follower threads on `follower.register`; registered followers also live in the leader's in-memory registry and communicate over the local bus socket. The live follower registry can resolve a follower by exact `{ chatId, threadId? }`; the leader uses that target ownership to forward message and edited-message updates to followers, and the follower receiver accepts those updates in addition to callbacks and reactions. Media album grouping and split-text coalescing keys include the thread target, queue reaction mutations can scope by chat/thread to avoid cross-target message-id collisions, active-turn target is exposed for lifecycle cleanup and local direct-tool defaults, transport reply dedup is chat/thread-scoped, stored menu state is keyed by chat/message so callback state lookup cannot collide across chats, and generated button turns plus section prompt/open actions preserve the callback thread target. `telegram_message` and immediate `telegram_attach` delivery can also carry an explicit `thread_id` with `chat_id`; when a follower is registered, their default direct-tool target is the assigned thread target and the bus-aware API runtime routes the send through the leader instead of calling Bot API transport locally.
352
+ The bridge must not keep a durable `telegram-targets.json` history. Stale/offline/failed thread entries are reconciliation observations, not reusable source-of-truth state; persisting them increases collision risk. `sync` is event-driven assumption reconciliation, not full Telegram bot-state mirroring, because Bot API does not expose a complete topic/thread listing surface. `state.json` therefore exists for extension+bot observability, diagnostics, startup hints, and explaining reconciliation decisions; live bus/runtime state remains authoritative for routing and provisioning. Non-current routeable thread bindings are pruned during load/persist; old session records must not be retained just to compute the next slot because `bot.lastSlot` is the only durable cursor. Previous-process leader bindings are treated as occupied TTL-bounded reservations until Telegram confirms deletion: reload/startup may close/delete/probe the old thread, known reservations are retried proactively on leader startup, and if Telegram still accepts the old thread id, the new leader should provision the next free slot (`B`, `C`, …) rather than creating a duplicate same-letter tab or blocking startup on Telegram UI convergence. Routing must use live current threads/follower registry, never reservations. The bus leader provisions its own thread during bus startup/connect and provisions follower threads on `follower.register`; registered followers also live in the leader's in-memory registry and communicate over the local bus socket. The live follower registry can resolve a follower by exact `{ chatId, threadId? }`; the leader uses that target ownership to forward message and edited-message updates to followers, and the follower receiver accepts those updates in addition to callbacks and reactions. Terminal status and `[telegram|thread:name]` resolve the matching current-instance identity through the same target-aware path, preferring registered local metadata over stale shared bindings. Media album grouping and split-text coalescing keys include the thread target, queue reaction mutations can scope by chat/thread to avoid cross-target message-id collisions, active-turn target is exposed for lifecycle cleanup and local direct-tool defaults, transport reply dedup is chat/thread-scoped, stored menu state is keyed by chat/message so callback state lookup cannot collide across chats, and generated button turns plus section prompt/open actions preserve the callback thread target. `telegram_message` and immediate `telegram_attach` delivery can also carry an explicit `thread_id` with `chat_id`; when a follower is registered, their default direct-tool target is the assigned thread target and the bus-aware API runtime routes the send through the leader instead of calling Bot API transport locally.
353
353
 
354
354
  All files containing routing, chat ids, thread ids, or process details use private permissions and represent current state rather than historical target caches.
355
355
 
package/docs/outbound.md CHANGED
@@ -18,6 +18,12 @@ An outbound handler is selected by `type`. Text replies and assistant markup map
18
18
 
19
19
  The voice pipeline is detailed below: configured `type: "voice"` handlers first, then programmatic handlers, then registered synthesis providers.
20
20
 
21
+ ### Guest Mode media boundary
22
+
23
+ A Guest Mode reply is one `answerGuestQuery` call carrying exactly one `InlineQueryResult`; it is not a normal chat target and cannot receive `sendDocument`/`sendVoice` multipart uploads through sentinel `chatId: 0`. `telegram_attach` therefore admits at most one file during a guest turn and rejects additional files before queue mutation.
24
+
25
+ Telegram accepts public URLs or existing Telegram `file_id` values for inline media results, but pi-telegram does not publish local artifacts to external hosting. A local guest document, photo, MP3 audio, or OGG/OPUS voice therefore uses a temporary upload to the paired owner's bot chat, extraction of the returned `file_id`, one cached-media guest answer, and best-effort deletion of the staging message. That message can briefly appear or notify the owner. One guest query can carry only one media item, and its answer text must fit the media caption limit rather than a separate full Rich Markdown message.
26
+
21
27
  Configured text handlers provide `template`. A string is one command; an array is ordered composition. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values. The command-template default timeout applies automatically. Use `template: [...]` for composition; the old local `pipe` alias is removed in 0.13.0.
22
28
 
23
29
  ## Text Handler Config
package/index.ts CHANGED
@@ -24,6 +24,7 @@ import * as Menu from "./lib/menu.ts";
24
24
  import * as Model from "./lib/model.ts";
25
25
  import * as Outbound from "./lib/outbound.ts";
26
26
  import * as Ownership from "./lib/ownership.ts";
27
+ import * as Paths from "./lib/paths.ts";
27
28
  import * as Pi from "./lib/pi.ts";
28
29
  import * as Polling from "./lib/polling.ts";
29
30
  import * as Preview from "./lib/preview.ts";
@@ -142,6 +143,9 @@ export default function (pi: Pi.ExtensionAPI) {
142
143
  };
143
144
  Config.bindGlobalTelegramConfigRuntime(configStore);
144
145
  const configControls = Config.createTelegramConfigControls(configStore);
146
+ let canPersistThreadState = function (): boolean {
147
+ return false;
148
+ };
145
149
  const threadStore = Threads.createTelegramTopicTargetStore({
146
150
  path: function () {
147
151
  return Threads.getTelegramTopicTargetsPath(
@@ -149,6 +153,9 @@ export default function (pi: Pi.ExtensionAPI) {
149
153
  configStore.getActiveProfileName(),
150
154
  );
151
155
  },
156
+ canPersist: function () {
157
+ return canPersistThreadState();
158
+ },
152
159
  });
153
160
  const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>({
154
161
  key: Locks.createTelegramLockKeyResolver(configStore),
@@ -156,6 +163,9 @@ export default function (pi: Pi.ExtensionAPI) {
156
163
  busSecret: telegramBusAuthSecret,
157
164
  staleHeartbeatMs: Locks.TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS,
158
165
  });
166
+ canPersistThreadState = function (): boolean {
167
+ return lockRuntime.getState().kind === "active-here";
168
+ };
159
169
  const lockOwnershipGuard =
160
170
  Locks.createTelegramLockOwnershipGuard(lockRuntime);
161
171
  const getCurrentLeaderEpoch = function (): number | undefined {
@@ -262,12 +272,45 @@ export default function (pi: Pi.ExtensionAPI) {
262
272
  }
263
273
  return record;
264
274
  };
275
+ const getCurrentInstanceThreadIdentity = function (
276
+ target?: Queue.TelegramQueueTarget,
277
+ ): Threads.TelegramInstanceThreadIdentityCandidate {
278
+ const followerTarget = telegramBusFollowerRegistrationState.getTarget();
279
+ const record = target
280
+ ? Threads.findCurrentTelegramInstanceThreadRecord({
281
+ records: threadStore.list(),
282
+ instanceId: telegramInstanceId,
283
+ preferredTarget: target,
284
+ })
285
+ : getCurrentThreadRecord();
286
+ return Threads.resolveTelegramInstanceThreadIdentity({
287
+ target,
288
+ follower:
289
+ telegramBusFollowerRegistrationState.isRegistered() && followerTarget
290
+ ? {
291
+ target: followerTarget,
292
+ slot: telegramBusFollowerRegistrationState.getSlot(),
293
+ threadName:
294
+ telegramBusFollowerRegistrationState.getThreadName(),
295
+ }
296
+ : undefined,
297
+ leader: telegramBusLeaderTarget
298
+ ? {
299
+ target: telegramBusLeaderTarget,
300
+ slot: telegramBusLeaderSlot,
301
+ threadName: telegramBusLeaderThreadName,
302
+ }
303
+ : undefined,
304
+ record,
305
+ });
306
+ };
265
307
  const statusRuntime = Status.createTelegramBridgeStatusRuntime<
266
308
  Pi.ExtensionContext,
267
309
  Queue.TelegramQueueItem<Pi.ExtensionContext>
268
310
  >({
269
311
  getConfig: configStore.get,
270
312
  getActiveProfileName: configStore.getActiveProfileName,
313
+ getDiagnosticPaths: Paths.getTelegramDiagnosticsDisplayPaths,
271
314
  isPollingActive: Polling.createTelegramPollingActivityReader(
272
315
  pollingControllerState,
273
316
  ),
@@ -337,19 +380,11 @@ export default function (pi: Pi.ExtensionAPI) {
337
380
  },
338
381
  getInstanceSlot() {
339
382
  if (threadStore.getBotState().threadMode === "disabled") return undefined;
340
- return (
341
- getCurrentThreadRecord()?.slot ??
342
- telegramBusFollowerRegistrationState.getSlot() ??
343
- telegramBusLeaderSlot
344
- );
383
+ return getCurrentInstanceThreadIdentity().slot;
345
384
  },
346
385
  getInstanceThreadName() {
347
386
  if (threadStore.getBotState().threadMode === "disabled") return undefined;
348
- return (
349
- getCurrentThreadRecord()?.threadName ??
350
- telegramBusFollowerRegistrationState.getThreadName() ??
351
- telegramBusLeaderThreadName
352
- );
387
+ return getCurrentInstanceThreadIdentity().threadName;
353
388
  },
354
389
  });
355
390
  const { updateStatus: updateStatusLine } = statusRuntime;
@@ -733,19 +768,15 @@ export default function (pi: Pi.ExtensionAPI) {
733
768
  },
734
769
  getLocalThreadLabelForTarget(target) {
735
770
  const followerTarget = telegramBusFollowerRegistrationState.getTarget();
736
- if (
771
+ const isLocalFollowerTarget =
772
+ telegramBusFollowerRegistrationState.isRegistered() &&
737
773
  followerTarget?.chatId === target.chatId &&
738
- followerTarget.threadId === target.threadId
739
- ) {
740
- return telegramBusFollowerRegistrationState.getThreadName();
741
- }
742
- if (
774
+ followerTarget.threadId === target.threadId;
775
+ const isLocalLeaderTarget =
743
776
  telegramBusLeaderTarget?.chatId === target.chatId &&
744
- telegramBusLeaderTarget.threadId === target.threadId
745
- ) {
746
- return telegramBusLeaderThreadName;
747
- }
748
- return undefined;
777
+ telegramBusLeaderTarget.threadId === target.threadId;
778
+ if (!isLocalFollowerTarget && !isLocalLeaderTarget) return undefined;
779
+ return getCurrentInstanceThreadIdentity(target).threadName;
749
780
  },
750
781
  getCurrentLeaderEpoch,
751
782
  getThreadReconciliationMachineState() {
@@ -1289,6 +1320,7 @@ export default function (pi: Pi.ExtensionAPI) {
1289
1320
  sendTextReply,
1290
1321
  dispatchNextQueuedTelegramTurn,
1291
1322
  answerGuestQuery,
1323
+ deleteMessage: deleteTelegramMessage,
1292
1324
  sendGuestReply,
1293
1325
  finalizeMarkdownPreview,
1294
1326
  proactivePushChatIdGetter,