@llblab/pi-telegram 0.56.0 → 0.56.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -74,7 +74,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
74
74
  - The bridge is session-local and paired to one allowed Telegram user. Preserve `{ chatId, threadId? }` through every inbound, queue, callback, reaction, media, preview, reply, menu, voice, attachment, and direct-delivery path.
75
75
  - First-contact pairing grants in-memory authority only after profile/token/execution-fenced durable publication confirms that exact user; it never overwrites another configured owner. `profiles.<name>.botToken` may store an exact `$NAME`/`${NAME}` environment reference instead of a copied secret: resolve it only at validation or activation boundaries, fail closed with a redacted named-variable diagnostic when unresolved, and keep literal tokens compatible. Sender admission precedes user message/edit/callback/reaction delegation, including foreign ownership and unbound-Thread fallback paths. Reactions require an existing exact human owner; private chat type is not authorization. Queued config persistence must not replay observed authority as local grant edits or erase later local unpair. Setup and retry details belong in [`docs/architecture.md`](./docs/architecture.md#setup-flow).
76
76
  - Telegram transport ownership is not semantic queue ownership. Losing the exact transport lock must not erase accepted local queue work or stop valid local Pi dispatch; direct Bot API mutations fail closed until exact direct or follower authority exists.
77
- - Runtime files live under `<agent-dir>/tmp/pi-telegram` (releases before 0.52.0 used `tmp/telegram`, which a new release never writes, migrates or deletes). The older `owners.json` is read-only evidence: a live fresh owner there blocks acquisition so two release generations never poll one bot, and only its identity is exposed, never its bus endpoint or secret. Only `state.json` and `logs.jsonl` are persistent root files: the per-profile `transport` section of `state.json` is the sole transport-owner authority, `workspace` owns canonical Workspace/Restore evidence and `admission` its leases; the `runtime` section and `logs.jsonl` never grant routing authority. Each section changes only through the shared transaction under its own domain authority (nonleader admission remains process/birth-owned, not transport-owned); Workspace/runtime publication captures exact session authority, and acquisition, refresh, release, takeover, and irreversible leader work fence the exact owner/epoch. Ordinary reads and section mutations still refuse a damaged shared envelope without repair. Only a non-election leader start (`/telegram-connect` or session auto-start) runs the operator-approved optimistic reset: under the shared transaction guard it replaces damaged envelope/transport/Workspace/admission evidence with an empty envelope, accepting loss of every profile's runtime continuity; filesystem access errors still refuse (see [Damaged-State Reset](./docs/architecture.md#damaged-state-reset-operator-approved)). Use the [session journal contract](./docs/multi-instance-bus.md#session-owned-journal-storage) for owners-named polling continuity and the explicitly approved optimistic session-family sweep; that sweep is not Thread deletion or receipt settlement.
77
+ - Runtime files live under `<agent-dir>/tmp/pi-telegram` (releases before 0.52.0 used `tmp/telegram`, which a new release never writes, migrates or deletes). The older `owners.json` is read-only evidence: a live fresh owner there blocks acquisition so two release generations never poll one bot, and only its identity is exposed, never its bus endpoint or secret. Only `state.json` and `logs.jsonl` are persistent root files: the per-profile `transport` section of `state.json` is the sole transport-owner authority, `workspace` owns canonical Workspace/Restore evidence and `admission` its leases; the `runtime` section and `logs.jsonl` never grant routing authority. Each section changes only through the shared transaction under its own domain authority (nonleader admission remains process/birth-owned, not transport-owned); Workspace/runtime publication captures exact session authority, and acquisition, refresh, release, takeover, and irreversible leader work fence the exact owner/epoch. Ordinary reads and section mutations still refuse a damaged shared envelope without repair. Explicit `/telegram-connect` additionally grants one profile-admitted reset of matching untargeted unfinished Thread-creation metadata before Workspace identity allocation; it may leave an unknown Telegram tab behind, but never deletes a known target, Workspace binding or journal receipt. Automatic startup/re-registration and Restore-only admission cannot grant that reset. Known pending targets retain their exact binding key and slot; fresh provisioning uses unique operation IDs so late recovery evidence cannot attach to a replacement attempt. Follower reset intent uses authenticated `explicit-connect-recovery-v1` registration. Only a non-election leader start (`/telegram-connect` or session auto-start) runs the operator-approved optimistic reset: under the shared transaction guard it replaces damaged envelope/transport/Workspace/admission evidence with an empty envelope, accepting loss of every profile's runtime continuity; filesystem access errors still refuse (see [Damaged-State Reset](./docs/architecture.md#damaged-state-reset-operator-approved)). Use the [session journal contract](./docs/multi-instance-bus.md#session-owned-journal-storage) for owners-named polling continuity and the explicitly approved optimistic session-family sweep; that sweep is not Thread deletion or receipt settlement.
78
78
  - Threaded Mode has exactly one live leader per bot profile. Followers are real operator-started Pi processes and must authenticate/register over local IPC; Telegram never spawns hidden Pi processes. A live but unreachable owner does not authorize split-brain polling.
79
79
  - Local IPC is a trust boundary, not merely a private socket. Unknown, stale, mismatched-generation, or unauthorized requests must not inject prompts, callbacks, API sends, artifacts, liveness, or bindings. Follower registration identity may prepare a binding, but inbound generation authority is published only after successful preparation for the same generation and current context; pending or failed startup cannot append into a retained old journal. Readiness also binds the active Pi context and supplied session generation. Same-session refresh awaits binding preparation without re-registering; a changed session ID cannot publish readiness until its exact leader registration is acknowledged. Reusing a context object cannot carry readiness across a session-generation or ID change. Registration requests capture session authority before asynchronous startup and fence every publication/finalization against the current attempt. Stop or supersession invalidates that attempt; obsolete cleanup cannot erase a newer registration or a refreshed context.
80
80
  - Protocol compatibility is independent from package version. Registration negotiates protocol version, runtime build, and canonical capabilities before target provisioning or live publication. `durable-follower-admission-v1` gates source forwarding; `queue-handoff-v1` independently gates semantic queue transfer for every participant and is advertised only with exact source/recipient journal-binding composition. `follower.register`, capability-gated restore-only `follower.restoreWorkspace` and side-effect-free liveness `bus.probe` are bootstrap requests; other requests require exact live-registry generation authority, and `bus.ack` is response-only. `thread-display-mode-v1` gates follower display-setting requests; the leader owns their serialized profile preference and title application.
@@ -99,15 +99,15 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
99
99
 
100
100
  ### 4.4 Queue, Delivery, And User Surfaces
101
101
 
102
- - Queue lane/kind admission is explicit. Ordinary new-task dispatch waits for active-turn, pending-dispatch, control, compaction, `ctx.isIdle()`, and Pi pending-message guards; a dispatched prompt stays queued until `agent_start` consumes it. The terminal shows one warning-yellow `+N` suffix for executable waiting work across all lanes (Normal/Priority/Steer/Control), omitted when zero. It excludes the dispatched head, frozen attempts and Skip, and never counts current agent work from any source. Each prompt is one object with one active lane and no reserved return slot. Control/Steer/Priority/Normal have explicit ranks. For ordinary prompts, crossing lanes removes it from the source and appends it at the destination tail; Keep/Skip and unchanged effective intent preserve lane position. Complete reaction sets derive Steer from fire, otherwise Priority from thumbs-up/heart/dove, and independent Skip from negative emoji. Fire dominates weaker positives; lightning is a Control marker, never a command classifier or Priority alias. Decorative changes cannot reset a menu lane choice; positive intent and Skip may coexist, suppressed turns retain durable receipts while waiting, and Skip settles them only when the prompt reaches dispatch before dropping it without inference. Suppressed turns remain visible at a struck-through physical ordinal without contributing to executable queue counters, while graceful session shutdown discards all remaining queue authority before clearing memory.
102
+ - Queue lane/kind admission is explicit. Ordinary new-task dispatch waits for active-turn, pending-dispatch, control, compaction, `ctx.isIdle()`, and Pi pending-message guards; a dispatched prompt stays queued until `agent_start` consumes it. The terminal shows one warning-yellow `+N` suffix for executable waiting work across all lanes (Normal/Priority/Steer/Control), omitted when zero. It excludes the dispatched head, frozen attempts and Skip, and never counts current agent work from any source. Each prompt is one object with one active lane and no reserved return slot. Control/Steer/Priority/Normal have explicit ranks. For ordinary prompts, crossing lanes removes it from the source and appends it at the destination tail; Keep/Skip and unchanged effective intent preserve lane position. Only fire, thumbs-up and thumbs-down have queue reaction semantics. Complete reaction sets derive Steer from fire, otherwise Priority from thumbs-up, and independent Skip from thumbs-down. All other reactions are decorative and cannot flush or mutate queued work. Fire dominates thumbs-up; lightning is a Control marker, never a command classifier or Priority alias. Decorative changes cannot reset a menu lane choice; positive intent and Skip may coexist, suppressed turns retain durable receipts while waiting, and Skip settles them only when the prompt reaches dispatch before dropping it without inference. Suppressed turns remain visible at a struck-through physical ordinal without contributing to executable queue counters, while graceful session shutdown discards all remaining queue authority before clearing memory.
103
103
  - Experimental Steer stays in the existing Queue/admission owner until one protected whole-packet attempt. Active insertion uses public `turn_end` after one response/full tool batch without resetting current Telegram reply/preview ownership; idle uses ordinary `sendUserMessage`. Recheck exact context/generation/source/native/compaction guards, freeze original objects before whole-receipt journal disposition, and never treat it as SDK/model-use proof. Partial/refused/unknown attempts remain non-executable and immutable without replay. Idle originals stay behind one pinned pending head until `agent_start` projects one active reply/voice/attachment owner. Keep identity fencing separate from same-session readiness refusal so old failures cannot clear replacement flags and unissued packets cannot contaminate native agent starts. Queue transfer allows unspent Steer only with `queue-steer-v1` plus baseline handoff support at donor/leader/recipient; missing capability refuses before offer, and frozen/pending work never transfers as unspent input.
104
- - `/stop`, `/abort`, `/next`, and `/continue` respectively reset+abort, abort while preserving queue, force the next turn, and enqueue a control-lane continuation. A negative reaction to an original standalone `/continue` settles and removes only its exact waiting source-addressed continuation; positive reactions do not change its control lane. Protect the Pi-owned pending head and already-started work, preserve other queue positions, and never address synthetic model continuations by their reply anchor. Settlement failure retains the continuation. Abort-history folding applies only to Telegram-owned active turns. A busy `/next` marks only its exact active Telegram turn: terminal abort settlement attempts one explicit abort notice before the exact selected queued prompt receives its dispatch notice. Notice failure is diagnostic and cannot block dispatch; a later `/abort` or `/stop` cancels both pending transition notices before taking ownership. Target command adapters must forward these transition ports. A successful dispatch notice keeps the queued prompt's one reply-header claim across agent start, so later messages in that turn never repeat it.
104
+ - `/stop`, `/abort`, `/next`, and `/continue` respectively reset+abort, abort while preserving queue, force the next turn, and enqueue a control-lane continuation. A thumbs-down reaction to an original standalone `/continue` settles and removes only its exact waiting source-addressed continuation; positive reactions do not change its control lane. Protect the Pi-owned pending head and already-started work, preserve other queue positions, and never address synthetic model continuations by their reply anchor. Settlement failure retains the continuation. Abort-history folding applies only to Telegram-owned active turns. A busy `/next` marks only its exact active Telegram turn: terminal abort settlement attempts one explicit abort notice before the exact selected queued prompt receives its dispatch notice. Notice failure is diagnostic and cannot block dispatch; a later `/abort` or `/stop` cancels both pending transition notices before taking ownership. Target command adapters must forward these transition ports. A successful dispatch notice keeps the queued prompt's one reply-header claim across agent start, so later messages in that turn never repeat it.
105
105
  - A model selection from an authorized Telegram target may stop and continue any interruptible active agent run in that same Pi session, including local/TUI work. Queue the synthetic control-lane continuation before aborting; when no Telegram prompt owns the run, bind continuation and reply ownership to the exact model-menu chat/Thread/message. Delay abort until every active tool execution settles, and clear both selection and fallback target on cancellation, agent start, settlement, or session replacement.
106
106
  - Telegram extension side effects must not hold Pi's core lifecycle hostage after semantic completion. Preserve ordering in extension-owned background work, record failures, and fence target/profile/transport/session authority.
107
107
  - In Threaded Mode, All is service/input-only. New output, previews, typing and routing UI require a concrete Thread; neither implicit fallback nor explicit aggregate delivery may write to All. Unavailable routing authority retains input without an All notice or silent settlement. Classic DM and explicit channel/business destinations remain separate; historical All output is not auto-cleaned.
108
108
  - Complete assistant/guest model answers use Telegram-native Rich Markdown. Harness-owned menus, status, diagnostics, thinking, and tool evidence remain explicit HTML/plain or their documented native surface. Before Telegram preview or final delivery, strip every assistant-authored HTML comment regardless of Markdown position while keeping action activation top-level-only; a comment-only result sends no text message. Preserve literal code outside comments and structurally safe chunking; never split invalid markup.
109
109
  - `preview` owns streaming lifecycle only, not assistant rendering. Finalization waits for the captured generation's preceding preview work, never later drafts blocked by that publication, and must not issue pre/post-final draft-clear calls that create transient Telegram draft UI. Reset/invalidation detaches predecessor dependencies; late settlements cannot mutate replacement state. Turns that already answer as one atomic reply (voice replies, Guest Mode queries) never stream previews.
110
- - Native `sendChatAction(typing)` is the automatic activity signal for unsettled agent and compaction work while Telegram transport is authorized. Extension-owned blocking UI prompts pause it and completion resumes it while either work owner remains active. Compaction may stop only a typing loop it actually started; it must preserve a pre-existing agent-owned loop. Refresh only the assigned Thread on a conservative three-second cadence; do not mirror typing to aggregate `All` and multiply shared-chat flood pressure. Typing is best-effort presence only: a failed exact-target action remains a structured diagnostic and must not replace a healthy connected/leader/follower status with `error`. Do not invent extra in-chat work indicators or emit activity for startup/connect/reload/recovery alone.
110
+ - Native `sendChatAction(typing)` is the automatic activity signal for unsettled agent and compaction work while Telegram transport is authorized. Extension-owned blocking UI prompts pause it and completion resumes it while either work owner remains active. Compaction may stop only a typing loop it actually started; it must preserve a pre-existing agent-owned loop. Refresh only the assigned Thread on a conservative three-second cadence; do not mirror typing to aggregate `All` and multiply shared-chat flood pressure. Typing is best-effort presence only, never a Thread-existence or message-delivery probe: a failed exact-target action remains a structured diagnostic and must not replace a healthy connected/leader/follower status with `error`. Do not invent extra in-chat work indicators or emit activity for startup/connect/reload/recovery alone.
111
111
  - Public activity handlers and connected companion delivery are asynchronous, target-bound, generation-fenced surfaces. Connected companion projection has no independent opt-out: disconnect or authority loss is its boundary. Token deltas, hidden reasoning, unknown sources, and stale authority never enter public projection.
112
112
  - Thread display defaults to the profile-scoped Letters strategy, with Names, Directory Snake, and Directory Title as the other automatic choices; Names projects the generated dictionary name for the slot. Unsupported retained display keys resolve to Letters without rewriting persisted configuration. A durable manual Thread display name retained on its Workspace binding overrides any automatic projection until exact reset; keep generated/recovery identity separate from manual and acknowledged display fields. After leader startup, automatic display contraction waits one follower-staleness window so election and follower re-registration cannot briefly remove and restore an acknowledged same-cwd suffix; stop/start generation cancels stale reconciliation. UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable action labels use `emoji + space + text`; emoji-free action text is only a reasoned no-semantic-marker fallback. State/value controls follow the [button control hierarchy](./docs/ui-style.md#button-control-hierarchy), not the action-label grammar. Non-spatial generated controls default to top-level vertical cells, with nested rows reserved for unmistakably compact peers. Do not restate other evolving UI details here.
113
113
 
package/BACKLOG.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  _This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
4
4
 
5
+ - [ ] **Connection recovery live acceptance.** Verify operator-run leader/follower reconnect after interrupted creation on updated peers. Known target reuse and unknown-target abandonment have separate local regressions; do not infer live recovery from them. Also verify a long-deleted follower tab, follower `/start` Restore with negotiated menu-alias support, and leader Restore confirmation on the first click plus separately proven old-tab cleanup. Record leftover-tab behavior, exact peer capabilities and diagnostics; local regressions and release authorization do not replace this live evidence.
5
6
  - [ ] **Steer follow-up live acceptance.** Release authorization explicitly defers these checks beyond 0.56.0; do not treat automated coverage or the accepted busy-run smoke as live proof for unrun scenarios. Preserve the [queue and dispatch contract](./docs/architecture.md#queue-and-dispatch-safety).
6
7
  - [ ] **Operator scenarios.** Verify final selector/counter rendering; idle packets, native pending input, compaction, model switching, abort/Continue, complete media groups, Reroute/Restore, negotiated leader/follower transfer, reply/preview ownership and session-generation replacement. Record actual Pi/OS/Telegram versions, peer capabilities and redacted outcomes. Reload and disposable live tests require operator intent; do not manipulate runtime files to obtain acceptance.
8
+ - [ ] **Restore recipient refusal after leadership change.** Diagnose a retained routing chooser refusing the selected current leader with unavailable recipient authority. Identify the exact context/session/Workspace mismatch before proposing recovery; do not treat an absent follower registry as the cause of a leader-target refusal, bypass fences or mutate live state.
7
9
  - [ ] **Post-reload diagnostics.** Investigate recurring typing-authority refusals and Workspace publication retries observed alongside successful leader/follower reconnection. Establish whether they are expected fenced stale work or affect current-session delivery; successful registration alone does not establish error-free operation.
8
10
  - [ ] **Native Windows scenarios.** Verify named-pipe custody/ACK, journal/atomic rename, reused-context lifecycle and the phone scenarios on an actual Windows host. Record skipped tests and missing platform evidence explicitly; a hosted CI result does not establish phone or full live-runtime acceptance.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.56.2: Canonical Queue Reactions
8
+
9
+ - `Queue reactions`: Only thumbs-up selects Priority, thumbs-down selects Skip, and fire selects Steer. Heart, dove, ghost, broken-heart, poop and wastebasket aliases are no longer controls. Unsupported reactions neither flush waiting input nor change queue intent; removing a supported reaction does not fall back to a retired alias. Only thumbs-down cancels a waiting standalone Continue.
10
+ - `Queue display`: Priority and Skip badges use thumbs-up and thumbs-down even for retained older metadata. Existing lane, disposition, source and receipt authority remain unchanged; Control keeps its lightning label, not a reaction shortcut.
11
+
12
+ ## 0.56.1: Recover interrupted connections
13
+
14
+ - `Connection recovery`: Explicit `/telegram-connect` can abandon matching unknown Thread-creation attempts and connect afresh without deleting tabs or clearing accepted work. Known pending targets keep their Workspace key and slot; unique creation IDs prevent late replies from attaching to a new attempt. Follower recovery requires `explicit-connect-recovery-v1` on both peers. A lost API reply may leave an extra tab; automatic startup and Restore-only registration never reset it.
15
+ - `Restore`: Reconnecting followers verify retained tabs with a connected message rather than typing, replacing a proven deleted tab. Negotiated `held-menu-aliases-v1` lets `/start` and `/help` restore a follower and open its restricted menu without pairing or bootstrap. A leader Restore waits briefly for the exact command-removal ACK on the original click, independently of menu delivery; lost ACKs never replay input, and old-tab cleanup remains separately fenced.
16
+ - `Windows validation`: Follower control fixtures use fresh registration timestamps and the production Live Restore deadline; a delayed apply case retains exact command and receipt assertions. Commentary/Next integration tests await observed progress under a shared test deadline and always stop their runtime before restoring fixtures, including on failure. Runtime authority and retry policy are unchanged.
17
+
7
18
  ## 0.56.0: Steer from Telegram
8
19
 
9
20
  - `Thread recovery`: A startup Telegram API timeout can leave the owner without a local bus despite retained Threaded Mode. A fresh positive capability probe now starts that owner's Threaded transport instead of treating the unchanged saved mode as readiness. Healthy buses stay running and followers cannot acquire leadership through this recovery.
package/README.md CHANGED
@@ -61,6 +61,8 @@ The connected Pi instance owns Telegram polling. Use `/telegram-connect <profile
61
61
 
62
62
  `/telegram-connect` keeps working when the shared runtime `state.json` is damaged. If it is malformed, foreign or contains invalid evidence, the connecting leader replaces it with a fresh empty state and continues. Every profile then forgets old Thread bindings, slots, unfinished Restore/temporary work and polling continuity; `telegram.json`, Pi history and the released `tmp/telegram` are untouched. Permission errors still stop connection. See [Damaged-State Reset](./docs/architecture.md#damaged-state-reset-operator-approved).
63
63
 
64
+ If Thread creation was interrupted, an explicit `/telegram-connect` can reset that unfinished attempt and try a fresh connection without clearing queued prompts or taking over a live leader. A lost Telegram response may leave an extra tab behind; known tabs are reused rather than blindly recreated. Both peers need connection-recovery support. See [Connection Recovery](./docs/architecture.md#explicit-connection-recovery).
65
+
64
66
  The prompt queue is session-local. Before a reload or restart, let it drain or knowingly accept losing waiting work and re-send what still matters afterwards; nothing is replayed automatically.
65
67
 
66
68
  Ordinary network or admission failures use exponential retry pauses capped at 30 seconds, without permanently stopping after an arbitrary attempt count. The poller recovers when the outage clears. Explicit `/telegram-disconnect` still attempts to stop its captured local transport if Thread cleanup is unavailable; it reports unconfirmed cleanup rather than successful deletion, and an old disconnect cannot stop a new connection. Incomplete stop/release remains visible; check `/telegram-status --debug` and keep Pi open.
@@ -206,7 +208,7 @@ In Threaded Mode, `All` is input-only: messages enter temporary routing tabs, an
206
208
 
207
209
  Messages sent while Pi is busy become queued turns. Queue controls let you inspect, prioritize, keep or skip, and dispatch work without touching the terminal.
208
210
 
209
- Use reactions or the queue menu to control waiting prompts.
211
+ Use reactions or the queue menu to control waiting prompts. Only `👍`, `👎`, and `🔥` have queue semantics; all other reactions are ignored.
210
212
 
211
213
  Priority — when the prompt runs:
212
214
  - **Normal — no dedicated reaction emoji.** The default lane for ordinary queued work.
@@ -225,7 +227,7 @@ Queue policy:
225
227
  - One yellow terminal counter shows waiting work across all lanes, excluding current or already-dispatched work, Skip and frozen attempts.
226
228
  - Dispatched or frozen prompts are inspection-only. An uncertain Steer attempt is not automatically retried.
227
229
  - The bot cannot remove your Telegram reactions; changing a menu choice may leave the original reaction visible.
228
- - A negative reaction to your waiting standalone `/continue` cancels it immediately rather than marking it for reversible Skip. Already-running work is unaffected.
230
+ - A `👎` reaction to your waiting standalone `/continue` cancels it immediately rather than marking it for reversible Skip. Already-running work is unaffected.
229
231
  - The queue is session-local. Let it drain before reload or shutdown, or accept losing waiting work.
230
232
 
231
233
  See [Queue Behavior and Safety](./docs/architecture.md#priority-reactions-keep-and-skip) for reaction precedence, dispatch rules and recovery details.
@@ -37,6 +37,7 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
37
37
  target?: TelegramTarget;
38
38
  previousInstanceId?: string;
39
39
  restoreWorkspace?: boolean;
40
+ recoverConnection?: boolean;
40
41
  }) => Promise<boolean>;
41
42
  setContext: (ctx: TContext) => void | Promise<void>;
42
43
  disconnectFromLeader?: () => Promise<boolean>;
@@ -9,7 +9,7 @@ import { createHash } from "node:crypto";
9
9
  import { basename } from "node:path";
10
10
  import { isDeepStrictEqual } from "node:util";
11
11
  import { getTelegramBusTransportRetryPolicy, TELEGRAM_BUS_REGISTRATION_RETRY, } from "./bus-transport.js";
12
- import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, rejectTelegramBusRequest, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, hasTelegramBusSteerQueueHandoffCapability, isTelegramBusEnvelopeAuthorized, parseTelegramBusEnvelope, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE, TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION, TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES, TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES, TelegramBusLocalAuthorityError, } from "./bus.js";
12
+ import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, rejectTelegramBusRequest, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, hasTelegramBusSteerQueueHandoffCapability, isTelegramBusEnvelopeAuthorized, parseTelegramBusEnvelope, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE, TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION, TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES, getTelegramBusHeldCommandCapabilities, TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES, TelegramBusLocalAuthorityError, } from "./bus.js";
13
13
  import { parseTelegramUpdateJournalQueueOwner, } from "./journal.js";
14
14
  import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS, } from "./locks.js";
15
15
  import * as Machine from "./machine.js";
@@ -1792,7 +1792,7 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1792
1792
  deps.setActiveAuthSecret?.(undefined);
1793
1793
  await deps.stopReceiving?.();
1794
1794
  };
1795
- const pendingHandoff = options
1795
+ const pendingHandoff = options && !options.recoverConnection
1796
1796
  ? undefined
1797
1797
  : getTelegramFollowerSessionHandoff();
1798
1798
  const pendingHandoffOptions = isTelegramFollowerSessionHandoffFresh(pendingHandoff)
@@ -1801,7 +1801,7 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1801
1801
  previousInstanceId: pendingHandoff.instanceId,
1802
1802
  }
1803
1803
  : undefined;
1804
- const registrationOptions = options ?? pendingHandoffOptions;
1804
+ const registrationOptions = options ? { ...pendingHandoffOptions, ...options } : pendingHandoffOptions;
1805
1805
  const leaderSocketPath = leader.busSocketPath ??
1806
1806
  deps.getLeaderSocketPath?.() ??
1807
1807
  getTelegramBusSocketPath();
@@ -1821,6 +1821,8 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1821
1821
  : "follower.register",
1822
1822
  requestId: registrationGeneration,
1823
1823
  auth: activeAuthSecret,
1824
+ ...(registrationOptions?.recoverConnection && !registrationOptions.restoreWorkspace
1825
+ ? { recoverConnection: true } : {}),
1824
1826
  registration: {
1825
1827
  instanceId: deps.instanceId,
1826
1828
  ...(registrationOptions?.previousInstanceId
@@ -2875,8 +2877,7 @@ export function createTelegramBusForwardedUpdateReceiverRuntime(deps) {
2875
2877
  ? [
2876
2878
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY,
2877
2879
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE,
2878
- TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET,
2879
- TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY,
2880
+ ...getTelegramBusHeldCommandCapabilities(commandInput.name),
2880
2881
  ]
2881
2882
  : []),
2882
2883
  ];
@@ -187,6 +187,7 @@ export declare function createTelegramBusInstanceLifecycleAnnouncement(input: {
187
187
  }): TelegramBusFollowerLifecycleAnnouncement;
188
188
  export declare function createTelegramBusFollowerTargetProvisioner(deps: TelegramBusFollowerTargetProvisionerDeps): (registration: TelegramBusInstanceRegistration, options?: {
189
189
  existingWorkspaceBindingOnly?: boolean;
190
+ recoverConnection?: boolean;
190
191
  }) => Promise<(TelegramTarget & {
191
192
  slot?: string;
192
193
  threadName?: string;
@@ -248,6 +249,7 @@ export interface TelegramBusLeaderEnvelopeHandlerDeps {
248
249
  }>) => Promise<unknown> | unknown;
249
250
  provisionFollowerTarget?: (registration: TelegramBusInstanceRegistration, options?: {
250
251
  existingWorkspaceBindingOnly?: boolean;
252
+ recoverConnection?: boolean;
251
253
  }) => Promise<(TelegramTarget & {
252
254
  slot?: string;
253
255
  threadName?: string;
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { isDeepStrictEqual } from "node:util";
8
8
  import { getTelegramBusTransportRetryPolicy } from "./bus-transport.js";
9
- import { createTelegramBusLocalServer, createUnauthorizedBusAck, rejectTelegramBusRequest, getTelegramBusEnvelopeTrafficClass, getTelegramBusFollowerSocketPath, getTelegramBusProtocolCompatibility, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, parseTelegramBusEnvelope, sendTelegramBusLocalEnvelope, stripTelegramBusApiMetadata, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF, hasTelegramBusSteerQueueHandoffCapability, TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND, TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT, TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, } from "./bus.js";
9
+ import { createTelegramBusLocalServer, createUnauthorizedBusAck, rejectTelegramBusRequest, getTelegramBusEnvelopeTrafficClass, getTelegramBusFollowerSocketPath, getTelegramBusProtocolCompatibility, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, parseTelegramBusEnvelope, sendTelegramBusLocalEnvelope, stripTelegramBusApiMetadata, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF, TELEGRAM_BUS_CAPABILITY_CONNECT_RECOVERY, hasTelegramBusSteerQueueHandoffCapability, TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND, TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT, TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, } from "./bus.js";
10
10
  import { escapeHtml } from "./rendering.js";
11
11
  import * as Sync from "./sync.js";
12
12
  import { parseTelegramIntegerId as asInteger, } from "./target.js";
@@ -881,6 +881,16 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
881
881
  if (typeof chatId !== "number")
882
882
  return registration.target;
883
883
  await deps.topicTargetStore.load();
884
+ if (options?.recoverConnection && !options.existingWorkspaceBindingOnly) {
885
+ const epoch = deps.getCurrentLeaderEpoch?.();
886
+ await Threads.resetTelegramConnectionProvisioning({
887
+ store: deps.topicTargetStore, owner: "manual-follower", instanceId: registration.instanceId,
888
+ profileKey: registration.profileKey ?? `manual:${registration.instanceId}`,
889
+ isCurrent: () => !deps.getCurrentLeaderEpoch ||
890
+ (epoch !== undefined && deps.getCurrentLeaderEpoch() === epoch),
891
+ recordEvent: deps.recordRuntimeEvent,
892
+ });
893
+ }
884
894
  let restoredPredecessor;
885
895
  if (options?.existingWorkspaceBindingOnly && registration.cwd && registration.sessionId &&
886
896
  !registration.previousInstanceId && deps.getLiveFollowers && deps.isFollowerProcessAlive) {
@@ -917,6 +927,7 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
917
927
  ? deps.topicTargetStore.claimWorkspaceIdentity(registration.cwd, registration.instanceId, registration.previousInstanceId ?? restoredPredecessor?.instanceId, {
918
928
  existingBindingOnly: options?.existingWorkspaceBindingOnly === true,
919
929
  sessionId: registration.sessionId,
930
+ provisionProfileKey: registration.profileKey ?? `manual:${registration.instanceId}`,
920
931
  onCapacityUnavailable() {
921
932
  capacityUnavailable = true;
922
933
  },
@@ -1129,11 +1140,6 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
1129
1140
  };
1130
1141
  }
1131
1142
  const probeRequiredRecord = reconnectRecord?.status === "probe-required";
1132
- const exactSessionHandoff = crossSessionReuse &&
1133
- !!requestedTarget &&
1134
- registration.previousInstanceId === reconnectRecord?.instanceId &&
1135
- requestedTarget.chatId === reconnectRecord.target.chatId &&
1136
- requestedTarget.threadId === reconnectRecord.target.threadId;
1137
1143
  const requiresVisibilityProbe = crossSessionReuse ||
1138
1144
  probeRequiredRecord ||
1139
1145
  recoverableTarget !== undefined;
@@ -1151,18 +1157,14 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
1151
1157
  if (requiresVisibilityProbe && connectedAnnouncement) {
1152
1158
  try {
1153
1159
  assertRestoreRegistration();
1154
- await deps.callApi(exactSessionHandoff ? "sendChatAction" : "sendMessage", exactSessionHandoff
1155
- ? {
1156
- chat_id: connectedAnnouncement.target.chatId,
1157
- message_thread_id: connectedAnnouncement.target.threadId,
1158
- action: "typing",
1159
- }
1160
- : {
1161
- chat_id: connectedAnnouncement.target.chatId,
1162
- message_thread_id: connectedAnnouncement.target.threadId,
1163
- text: connectedAnnouncement.text,
1164
- parse_mode: connectedAnnouncement.parseMode,
1165
- });
1160
+ await deps.callApi(
1161
+ // Typing is presence, not evidence that a retained Thread still accepts messages.
1162
+ "sendMessage", {
1163
+ chat_id: connectedAnnouncement.target.chatId,
1164
+ message_thread_id: connectedAnnouncement.target.threadId,
1165
+ text: connectedAnnouncement.text,
1166
+ parse_mode: connectedAnnouncement.parseMode,
1167
+ });
1166
1168
  assertRestoreRegistration();
1167
1169
  if (recoverableTarget || probeRequiredRecord) {
1168
1170
  const activatedRecord = deps.topicTargetStore.upsert({
@@ -2261,6 +2263,9 @@ export function createTelegramBusLeaderEnvelopeHandler(deps) {
2261
2263
  (hasTelegramBusCapability(deps.protocolIdentity, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE) &&
2262
2264
  hasTelegramBusCapability(envelope.registration.protocol, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE));
2263
2265
  const restoringWorkspace = envelope.kind === "follower.restoreWorkspace";
2266
+ const recoverConnection = envelope.kind === "follower.register" && envelope.recoverConnection === true;
2267
+ const supportsConnectionRecovery = [deps.protocolIdentity, envelope.registration.protocol]
2268
+ .every(identity => hasTelegramBusCapability(identity, TELEGRAM_BUS_CAPABILITY_CONNECT_RECOVERY));
2264
2269
  const supportsWorkspaceAutoConnect = hasTelegramBusCapability(deps.protocolIdentity, TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT) &&
2265
2270
  hasTelegramBusCapability(envelope.registration.protocol, TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT);
2266
2271
  if (!compatibility.compatible ||
@@ -2268,7 +2273,8 @@ export function createTelegramBusLeaderEnvelopeHandler(deps) {
2268
2273
  !readerCompatible() ||
2269
2274
  (envelope.registration.cwd !== undefined &&
2270
2275
  envelope.registration.sessionId === undefined) ||
2271
- (restoringWorkspace && !supportsWorkspaceAutoConnect)) {
2276
+ (restoringWorkspace && !supportsWorkspaceAutoConnect) ||
2277
+ (recoverConnection && !supportsConnectionRecovery)) {
2272
2278
  return {
2273
2279
  kind: "bus.ack",
2274
2280
  requestId: envelope.requestId,
@@ -2309,7 +2315,7 @@ export function createTelegramBusLeaderEnvelopeHandler(deps) {
2309
2315
  if (hasTelegramBusCapability(deps.protocolIdentity, TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
2310
2316
  !deps.commitFollowerRegistration)
2311
2317
  throw new Error("Workspace Restore registration publication protection is unavailable.");
2312
- const target = await deps.provisionFollowerTarget?.(envelope.registration, { existingWorkspaceBindingOnly: restoringWorkspace });
2318
+ const target = await deps.provisionFollowerTarget?.(envelope.registration, { existingWorkspaceBindingOnly: restoringWorkspace, ...(recoverConnection ? { recoverConnection: true } : {}) });
2313
2319
  if (restoringWorkspace && !target) {
2314
2320
  return {
2315
2321
  kind: "bus.ack",
package/dist/lib/bus.d.ts CHANGED
@@ -38,6 +38,7 @@ export declare const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION: "durabl
38
38
  export declare const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF: "queue-handoff-v1";
39
39
  /** Unspent Steer lane codec; independent from baseline receipt handoff support. */
40
40
  export declare const TELEGRAM_BUS_CAPABILITY_QUEUE_STEER: "queue-steer-v1";
41
+ export declare const TELEGRAM_BUS_CAPABILITY_CONNECT_RECOVERY: "explicit-connect-recovery-v1";
41
42
  export declare const TELEGRAM_BUS_CAPABILITY_INPUT_CUSTODY_REFERENCE: "input-custody-reference-v1";
42
43
  export declare const TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME: "workspace-thread-rename-v1";
43
44
  export declare const TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE: "thread-display-mode-v1";
@@ -58,6 +59,9 @@ export declare const TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET: "live-thre
58
59
  /** Every live rebinding needs the save/apply/settle trio; held command selections additionally need these two. */
59
60
  export declare const TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES: readonly ["live-thread-rebind-save-v1", "live-thread-rebind-apply-v1", "live-thread-rebind-settle-v1"];
60
61
  export declare const TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES: readonly ["live-thread-rebind-command-set-v1", "selected-menu-delivery-v1"];
62
+ /** Start/help are restricted menu aliases, negotiated separately from the original held-command set. */
63
+ export declare const TELEGRAM_BUS_CAPABILITY_HELD_MENU_ALIASES: "held-menu-aliases-v1";
64
+ export declare function getTelegramBusHeldCommandCapabilities(name: string): readonly string[];
61
65
  export interface TelegramBusSelectedCommandInput {
62
66
  name: string;
63
67
  target: TelegramTarget & {
@@ -327,6 +331,7 @@ export declare function createTelegramBusFollowerSourceReferenceDeliveryIdentity
327
331
  export type TelegramBusEnvelope = ({
328
332
  kind: "follower.register";
329
333
  requestId: string;
334
+ recoverConnection?: true;
330
335
  registration: TelegramBusInstanceRegistration;
331
336
  } | {
332
337
  kind: "follower.restoreWorkspace";
package/dist/lib/bus.js CHANGED
@@ -52,6 +52,7 @@ export const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION = "durable-follo
52
52
  export const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF = "queue-handoff-v1";
53
53
  /** Unspent Steer lane codec; independent from baseline receipt handoff support. */
54
54
  export const TELEGRAM_BUS_CAPABILITY_QUEUE_STEER = "queue-steer-v1";
55
+ export const TELEGRAM_BUS_CAPABILITY_CONNECT_RECOVERY = "explicit-connect-recovery-v1";
55
56
  export const TELEGRAM_BUS_CAPABILITY_INPUT_CUSTODY_REFERENCE = "input-custody-reference-v1";
56
57
  export const TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME = "workspace-thread-rename-v1";
57
58
  export const TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE = "thread-display-mode-v1";
@@ -79,6 +80,13 @@ export const TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES = [
79
80
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET,
80
81
  TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY,
81
82
  ];
83
+ /** Start/help are restricted menu aliases, negotiated separately from the original held-command set. */
84
+ export const TELEGRAM_BUS_CAPABILITY_HELD_MENU_ALIASES = "held-menu-aliases-v1";
85
+ export function getTelegramBusHeldCommandCapabilities(name) {
86
+ return name === "start" || name === "help"
87
+ ? [...TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES, TELEGRAM_BUS_CAPABILITY_HELD_MENU_ALIASES]
88
+ : TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES;
89
+ }
82
90
  function parseSelectedCommandInput(value) {
83
91
  if (!isRecord(value) ||
84
92
  !hasOnlyWireKeys(value, ["name", "target"]) ||
@@ -1316,12 +1324,7 @@ export function createTelegramBusLiveRebindController(deps) {
1316
1324
  : []),
1317
1325
  ...(settling || held ? [TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE] : []),
1318
1326
  ...(observingHeld ? [TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION] : []),
1319
- ...(held
1320
- ? [
1321
- TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET,
1322
- TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY,
1323
- ]
1324
- : []),
1327
+ ...(held ? getTelegramBusHeldCommandCapabilities(held.name) : []),
1325
1328
  ];
1326
1329
  const follower = deps.getFollower(expected.instanceId), secret = deps.getAuthSecret();
1327
1330
  if (!isCurrent() ||
@@ -2144,8 +2147,13 @@ async function handleTelegramBusSocketLine(line, socket, handleEnvelope, recordT
2144
2147
  }
2145
2148
  }
2146
2149
  function parseRegisterEnvelope(value, requestId, kind) {
2150
+ if (value.recoverConnection !== undefined &&
2151
+ (kind !== "follower.register" || value.recoverConnection !== true))
2152
+ return undefined;
2147
2153
  const registration = parseRegistration(value.registration);
2148
- return registration ? { kind, requestId, registration } : undefined;
2154
+ return registration ? { kind, requestId, registration,
2155
+ ...(kind === "follower.register" && value.recoverConnection === true ? { recoverConnection: true } : {}),
2156
+ } : undefined;
2149
2157
  }
2150
2158
  function parseFollowerPresenceEnvelope(value, requestId, kind) {
2151
2159
  return typeof value.instanceId === "string" &&
@@ -13,7 +13,7 @@ import { type TelegramBridgeStatusLineOptions } from "./status.ts";
13
13
  import { type TelegramApiCallOptions } from "./telegram-api.ts";
14
14
  import type { TelegramSessionReplacementIntent } from "./threads.ts";
15
15
  import { type TelegramDeferredSourceEvidence, type TelegramLiveSourceCompletionReadiness, type TelegramQueueAdmissionReceiptLike } from "./updates.ts";
16
- export type TelegramHeldCommandName = "status" | "abort" | "stop" | "next" | "continue";
16
+ export type TelegramHeldCommandName = "status" | "start" | "help" | "abort" | "stop" | "next" | "continue";
17
17
  /** Commands-owned no-fold admission over the ordinary recipient queue; source reporting stays separate from removal. */
18
18
  export interface TelegramHeldTurnAdmission {
19
19
  assertCurrent(): void;
@@ -1446,35 +1446,40 @@ export function createTelegramCommandHandler(deps) {
1446
1446
  },
1447
1447
  };
1448
1448
  };
1449
- const heldPlans = {
1450
- status: {
1451
- completion: "before-effect",
1452
- effect: "showStatus",
1453
- prepare(ctx, effects) {
1454
- const scopedStatus = effects?.showStatus;
1455
- const captured = {
1456
- ...deps,
1457
- showStatus: scopedStatus
1458
- ? (message, context, options) => {
1459
- const address = getTelegramCommandMessageTarget(message);
1460
- return scopedStatus.call(effects, address.chatId, address.replyToMessageId, context, address.threadId, options);
1461
- }
1462
- : deps.showStatus,
1463
- };
1464
- return {
1465
- captured,
1466
- portsCurrent: () => !effects || effects.showStatus === scopedStatus,
1467
- async run({ message, command, assertRecipient }) {
1468
- const recipientOptions = { assertAuthority: assertRecipient };
1469
- return handleTelegramCommandRuntime(command.name, message, ctx, {
1470
- ...captured,
1471
- assertExecutionCurrent: assertRecipient,
1472
- showStatus: (value, context) => captured.showStatus(value, context, recipientOptions),
1473
- }, command.args);
1474
- },
1475
- };
1476
- },
1449
+ // Held followers are already paired and bound. Start/help open the same restricted menu as status;
1450
+ // they cannot pair an operator, bootstrap another Thread or invoke unrestricted Bot API effects.
1451
+ const heldMenuPlan = {
1452
+ completion: "before-effect",
1453
+ effect: "showStatus",
1454
+ prepare(ctx, effects) {
1455
+ const scopedStatus = effects?.showStatus;
1456
+ const captured = {
1457
+ ...deps,
1458
+ showStatus: scopedStatus
1459
+ ? (message, context, options) => {
1460
+ const address = getTelegramCommandMessageTarget(message);
1461
+ return scopedStatus.call(effects, address.chatId, address.replyToMessageId, context, address.threadId, options);
1462
+ }
1463
+ : deps.showStatus,
1464
+ };
1465
+ return {
1466
+ captured,
1467
+ portsCurrent: () => !effects || effects.showStatus === scopedStatus,
1468
+ async run({ message, command, assertRecipient }) {
1469
+ const recipientOptions = { assertAuthority: assertRecipient };
1470
+ return handleTelegramCommandRuntime("status", message, ctx, {
1471
+ ...captured,
1472
+ assertExecutionCurrent: assertRecipient,
1473
+ showStatus: (value, context) => captured.showStatus(value, context, recipientOptions),
1474
+ }, command.args);
1475
+ },
1476
+ };
1477
1477
  },
1478
+ };
1479
+ const heldPlans = {
1480
+ status: heldMenuPlan,
1481
+ start: heldMenuPlan,
1482
+ help: heldMenuPlan,
1478
1483
  abort: {
1479
1484
  completion: "after-semantics",
1480
1485
  effect: "sendTextReply",
@@ -60,6 +60,8 @@ const telegramBusProtocolIdentity = Bus.createTelegramCurrentBusProtocolIdentity
60
60
  Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION,
61
61
  Bus.TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF,
62
62
  Bus.TELEGRAM_BUS_CAPABILITY_QUEUE_STEER,
63
+ Bus.TELEGRAM_BUS_CAPABILITY_CONNECT_RECOVERY,
64
+ Bus.TELEGRAM_BUS_CAPABILITY_HELD_MENU_ALIASES,
63
65
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT,
64
66
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
65
67
  Bus.TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
@@ -976,7 +978,7 @@ export default function (pi) {
976
978
  isSelectedCommandAvailable(follower, name) {
977
979
  return (Bus.hasTelegramBusSharedCapabilities(telegramBusProtocolIdentity, follower.protocol, [
978
980
  ...Bus.TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES,
979
- ...Bus.TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES,
981
+ ...Bus.getTelegramBusHeldCommandCapabilities(name),
980
982
  ]) &&
981
983
  typeof selectedMenuDelivery === "function" &&
982
984
  typeof inboundRouteRuntime.prepareHeldCommand === "function" &&
@@ -1225,6 +1227,7 @@ export default function (pi) {
1225
1227
  threadStore,
1226
1228
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1227
1229
  liveRebindCleanupSchedule: Routing.TELEGRAM_LIVE_REBIND_CLEANUP_SCHEDULE,
1230
+ liveRebindCommandCompletionTimeoutMs: 8_000,
1228
1231
  updateStatus,
1229
1232
  isContextActive: telegramSessionContextStore.isCurrent,
1230
1233
  dispatchNextQueuedTelegramTurn,
@@ -259,7 +259,9 @@ export interface TelegramLockedPollingRuntime<TContext extends TelegramLockConte
259
259
  captureTransportAuthority: (ctx: TContext) => (() => boolean) | undefined;
260
260
  onPersistentConflict: (ctx: TContext, count: number) => Promise<void>;
261
261
  onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
262
- registerFollowerWithOwner?: (ctx: TContext, owner: TelegramLockEntry) => boolean | undefined | Promise<boolean | undefined>;
262
+ registerFollowerWithOwner?: (ctx: TContext, owner: TelegramLockEntry, options?: {
263
+ recoverConnection?: boolean;
264
+ }) => boolean | undefined | Promise<boolean | undefined>;
263
265
  restoreFollowerWithOwner?: (ctx: TContext, owner: TelegramLockEntry) => boolean | undefined | Promise<boolean | undefined>;
264
266
  stopFollowerRegistration?: () => void;
265
267
  }
@@ -274,7 +276,9 @@ interface TelegramLockedPollingRuntimeDeps<TContext extends TelegramLockContext>
274
276
  formatStartBlockedMessage?: (ctx: TContext) => string;
275
277
  startPolling: (ctx: TContext, options?: TelegramLockedPollingStartOptions) => void | Promise<void>;
276
278
  stopPolling: () => Promise<void>;
277
- registerFollowerWithOwner?: (ctx: TContext, owner: TelegramLockEntry) => boolean | undefined | Promise<boolean | undefined>;
279
+ registerFollowerWithOwner?: (ctx: TContext, owner: TelegramLockEntry, options?: {
280
+ recoverConnection?: boolean;
281
+ }) => boolean | undefined | Promise<boolean | undefined>;
278
282
  restoreFollowerWithOwner?: (ctx: TContext, owner: TelegramLockEntry) => boolean | undefined | Promise<boolean | undefined>;
279
283
  stopFollowerRegistration?: () => void;
280
284
  /** Threaded Mode takeover evidence after failed follower registration; replaces the retired file heartbeat. */
package/dist/lib/locks.js CHANGED
@@ -1625,7 +1625,7 @@ export function createTelegramLockedPollingRuntime(deps) {
1625
1625
  if (deps.registerFollowerWithOwner) {
1626
1626
  let failureMessage;
1627
1627
  try {
1628
- const registered = await deps.registerFollowerWithOwner(ctx, acquired.lock);
1628
+ const registered = await deps.registerFollowerWithOwner(ctx, acquired.lock, options.forceFreshLeaderThread ? { recoverConnection: true } : undefined);
1629
1629
  if (!isCurrent())
1630
1630
  return cancelled;
1631
1631
  if (registered) {
@@ -1838,8 +1838,8 @@ export function createTelegramLockedPollingRuntime(deps) {
1838
1838
  sessionAutoStartRun = run;
1839
1839
  },
1840
1840
  registerFollowerWithOwner: deps.registerFollowerWithOwner
1841
- ? async (ctx, owner) => {
1842
- const registered = await deps.registerFollowerWithOwner?.(ctx, owner);
1841
+ ? async (ctx, owner, options) => {
1842
+ const registered = await deps.registerFollowerWithOwner?.(ctx, owner, options);
1843
1843
  if (registered)
1844
1844
  deps.updateStatus(ctx);
1845
1845
  return registered === true;
@@ -71,7 +71,7 @@ function buildTelegramQueueMenuReplyMarkup(items, emptyRefreshIndex = 0) {
71
71
  return { inline_keyboard: [backRow, refreshRow] };
72
72
  const rows = items.map((item) => {
73
73
  const badge = getTelegramQueueMenuLaneBadge(item);
74
- const prefix = item.reactionSuppressionEmoji ? `${item.reactionSuppressionEmoji} ` :
74
+ const prefix = item.reactionSuppressionEmoji ? "👎 " :
75
75
  badge ? `${badge} ` : item.hasAttachments ? "📎 " : "";
76
76
  const position = item.reactionSuppressionEmoji
77
77
  ? formatSkippedTelegramQueuePosition(item.queuePosition)
@@ -130,11 +130,10 @@ function getTelegramQueueMenuLaneBadge(item) {
130
130
  return "🔥";
131
131
  if (item.queueLane !== "priority")
132
132
  return "";
133
- const emoji = item.priorityEmoji?.replace(/\uFE0F/g, "");
134
- return emoji === "❤" || emoji === "🕊" ? emoji : "👍";
133
+ return "👍";
135
134
  }
136
135
  function getTelegramQueueMenuItemText(item) {
137
- const marker = item.reactionSuppressionEmoji ?? getTelegramQueueMenuLaneBadge(item);
136
+ const marker = item.reactionSuppressionEmoji ? "👎" : getTelegramQueueMenuLaneBadge(item);
138
137
  const badge = marker ? ` ${marker}` : "";
139
138
  const position = item.reactionSuppressionEmoji
140
139
  ? `<s>${item.queuePosition}</s>.`
@@ -441,11 +440,10 @@ function applyQueuedTelegramPromptMenuFlags(chatId, replyToMessageId, ctx, deps,
441
440
  const disposition = { kind: "reaction-transition" };
442
441
  if (flags.lane !== undefined) {
443
442
  disposition.promptLane = flags.lane;
444
- disposition.priorityEmoji = flags.lane === "steer" ? "🔥" : flags.lane === "priority" ?
445
- getTelegramQueueMenuLaneBadge({ queueLane: "priority", priorityEmoji: item.queueLane === "priority" ? item.priorityEmoji : undefined }) : null;
443
+ disposition.priorityEmoji = flags.lane === "steer" ? "🔥" : flags.lane === "priority" ? "👍" : null;
446
444
  }
447
445
  if (flags.skipped !== undefined)
448
- disposition.suppressionEmoji = flags.skipped ? (item.reactionSuppressionEmoji ?? "👎") : null;
446
+ disposition.suppressionEmoji = flags.skipped ? "👎" : null;
449
447
  return deps.queueMutationRuntime.applyReactionByMessageId(replyToMessageId, disposition, ctx) ||
450
448
  ((flags.lane === undefined || flags.lane === item.queueLane) &&
451
449
  (flags.skipped === undefined || flags.skipped === (item.reactionSuppressionEmoji !== undefined)));
@@ -221,7 +221,9 @@ export interface TelegramThreadAwarePollingPorts<TContext, TOwner> {
221
221
  forceFreshLeaderThread?: boolean;
222
222
  }) => Promise<void>;
223
223
  stopPolling: () => Promise<void>;
224
- registerFollowerWithOwner: (ctx: TContext, owner: TOwner) => Promise<boolean | undefined>;
224
+ registerFollowerWithOwner: (ctx: TContext, owner: TOwner, options?: {
225
+ recoverConnection?: boolean;
226
+ }) => Promise<boolean | undefined>;
225
227
  restoreFollowerWithOwner: (ctx: TContext, owner: TOwner) => Promise<boolean | undefined>;
226
228
  stopFollowerRegistration: () => void;
227
229
  }
@@ -233,7 +235,9 @@ export interface TelegramThreadAwarePollingControls<TContext, TOwner> {
233
235
  stopBusLeaderPolling: () => Promise<void>;
234
236
  startLeaderHealth: () => void;
235
237
  stopLeaderHealth: () => void;
236
- registerFollowerWithLeader: (ctx: TContext, owner: TOwner) => Promise<boolean | undefined>;
238
+ registerFollowerWithLeader: (ctx: TContext, owner: TOwner, options?: {
239
+ recoverConnection?: boolean;
240
+ }) => Promise<boolean | undefined>;
237
241
  restoreFollowerWithLeader?: (ctx: TContext, owner: TOwner) => Promise<boolean | undefined>;
238
242
  hasRememberedWorkspaceBinding?: (ctx: TContext) => boolean;
239
243
  stopFollowerRegistration: () => void;
@@ -752,10 +752,10 @@ export function createTelegramThreadAwarePollingPorts(deps) {
752
752
  }
753
753
  return deps.topicTargetStore.getBotState().threadMode === "enabled";
754
754
  };
755
- const registerFollowerWithOwner = async (ctx, owner) => {
755
+ const registerFollowerWithOwner = async (ctx, owner, options) => {
756
756
  if (!(await refreshFollowerState()))
757
757
  return undefined;
758
- return deps.registerFollowerWithLeader(ctx, owner);
758
+ return deps.registerFollowerWithLeader(ctx, owner, options);
759
759
  };
760
760
  const restoreFollowerWithOwner = async (ctx, owner) => {
761
761
  if (!(await refreshFollowerState())) {