@llblab/pi-telegram 0.55.1 → 0.56.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENTS.md +5 -4
  2. package/BACKLOG.md +6 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +28 -17
  5. package/dist/lib/bindings.d.ts +8 -4
  6. package/dist/lib/bindings.js +41 -1
  7. package/dist/lib/bus-follower.d.ts +1 -0
  8. package/dist/lib/bus-follower.js +10 -5
  9. package/dist/lib/bus-leader.d.ts +2 -0
  10. package/dist/lib/bus-leader.js +34 -20
  11. package/dist/lib/bus.d.ts +8 -0
  12. package/dist/lib/bus.js +21 -7
  13. package/dist/lib/commands.d.ts +1 -1
  14. package/dist/lib/commands.js +33 -28
  15. package/dist/lib/extension.js +18 -3
  16. package/dist/lib/lifecycle.d.ts +2 -1
  17. package/dist/lib/lifecycle.js +28 -20
  18. package/dist/lib/locks.d.ts +6 -2
  19. package/dist/lib/locks.js +3 -3
  20. package/dist/lib/menu-queue.d.ts +1 -0
  21. package/dist/lib/menu-queue.js +102 -99
  22. package/dist/lib/pi.d.ts +3 -2
  23. package/dist/lib/pi.js +3 -0
  24. package/dist/lib/polling.d.ts +6 -2
  25. package/dist/lib/polling.js +5 -3
  26. package/dist/lib/queue.d.ts +57 -5
  27. package/dist/lib/queue.js +313 -25
  28. package/dist/lib/routing.d.ts +4 -0
  29. package/dist/lib/routing.js +62 -1
  30. package/dist/lib/status.d.ts +1 -2
  31. package/dist/lib/status.js +6 -7
  32. package/dist/lib/sync.js +11 -1
  33. package/dist/lib/threads.d.ts +11 -0
  34. package/dist/lib/threads.js +57 -6
  35. package/dist/lib/updates.d.ts +12 -7
  36. package/dist/lib/updates.js +44 -9
  37. package/dist/package.json +1 -1
  38. package/docs/README.md +1 -1
  39. package/docs/architecture.md +24 -13
  40. package/docs/callback-namespaces.md +4 -0
  41. package/docs/multi-instance-bus.md +6 -4
  42. package/docs/ui-style.md +14 -10
  43. package/lib/bindings.ts +52 -3
  44. package/lib/bus-follower.ts +13 -5
  45. package/lib/bus-leader.ts +37 -23
  46. package/lib/bus.ts +23 -7
  47. package/lib/commands.ts +52 -47
  48. package/lib/extension.ts +18 -2
  49. package/lib/lifecycle.ts +34 -28
  50. package/lib/locks.ts +5 -2
  51. package/lib/menu-queue.ts +116 -162
  52. package/lib/pi.ts +8 -0
  53. package/lib/polling.ts +7 -2
  54. package/lib/queue.ts +328 -33
  55. package/lib/routing.ts +51 -1
  56. package/lib/status.ts +7 -7
  57. package/lib/sync.ts +11 -0
  58. package/lib/threads.ts +57 -5
  59. package/lib/updates.ts +44 -15
  60. package/package.json +1 -1
@@ -61,6 +61,8 @@ The repository uses a **Flat Domain DAG**:
61
61
 
62
62
  ### Live-Rebind Module Map
63
63
 
64
+ For an already-issued leader menu/control command whose semantics finish before delivery, the original chooser may observe its exact warm source-removal ACK for one window of up to eight seconds before answering. Later taps only reobserve; they do not restart that wait. Naming/confirmation/extension producers whose source completion can depend on later publication keep their independent completion path. Updates notifies subscribers only after its carrier retains that ACK, not at the earlier journal-completion hook. Routing rechecks source, recipient, canonical binding and execution generation; observation cannot issue a second command or turn source absence into confirmation. Menu/reply delivery is not awaited. Timeout, abort or authority loss removes subscriptions and retains uncertainty without replay; old-Thread cleanup remains an independent fenced operation.
65
+
64
66
  Release `0.53.0` adds the [live-rebind channel](./multi-instance-bus.md#live-rebind-channel) and bounded old-Thread cleanup. It advertises live save/apply/settle, command-set and selected-menu-delivery through bus protocol v3, retaining Workspace Restore for legacy continuity. A loaded 0.53.0 build prefers the live path; an instance still running an older build keeps Restore until it is reloaded. Windows runs the same channel and strict reads, exercised by the CI matrix; there is no platform-specific alternate protocol.
65
67
 
66
68
  The channel reuses existing owners rather than adding a parallel command bus, queue or recovery store:
@@ -110,7 +112,7 @@ Only the live cleanup origin uses the approved historical-protection reduction.
110
112
  - `outbound-attachments`: `telegram_attach`, queued outbound files, stat/limit checks, ordinary photo/document delivery, and narrow single-artifact Rich Message planning/sending for probe-confirmed photo/video/audio formats. It owns known-failure fallback eligibility and ambiguous-send no-replay classification through structural error contracts without importing Bot API helpers.
111
113
  - `channel-posts`: profile/token-bound authority for agent-authored channel publication intents, one-shot send/edit/delete fencing, outcome-unknown retention, exact successful post identity, capacity refusal, and bounded local listing. It never reads Telegram history; the composition root supplies direct-leader Bot API effects only after its durable grants.
112
114
  - `status` / `logs`: status bar/status-message rendering, queue-lane summaries, the structural redacted event ring, profile-aware JSONL scope/reset/append behavior, exact-owner destructive commits, fail-soft synchronous and queued diagnostics persistence, status snapshot scheduling, grouped diagnostics, and allowlisted compact connection-failure copy shared by commands and lifecycle. `status` remains a structural leaf; `logs` composes filesystem evidence with status projections and contains every persistence failure so diagnostics cannot terminate or poison the runtime queue.
113
- - `bindings` / `lifecycle` / `prompts` / `prompt-templates` / `pi`: Pi-facing command/tool/hook registration and cohesive cross-domain binding assembly, including queue mutation/dispatch/watchdog composition over admission and transport ports; session-generation fencing and start/shutdown sequencing across Queue, grouped input, Delivery, polling, capability monitor, follower refresh, and assistant-output projection; Telegram prompt guidance; prompt-template discovery/expansion; and centralized direct Pi SDK imports. `lifecycle` also owns bounded same-process connect intent across local `/resume`, cancellation/supersession, and fresh-context startup scheduling; it does not own transport authority, durable bindings, queue custody, or source-target continuity.
115
+ - `bindings` / `lifecycle` / `prompts` / `prompt-templates` / `pi`: Pi-facing command/tool/hook registration and cohesive cross-domain binding assembly, including queue mutation/dispatch/watchdog composition over admission and transport ports; session-generation fencing and start/shutdown sequencing across Queue, grouped input, Delivery, polling, capability monitor, follower refresh, and assistant-output projection; Telegram prompt guidance; prompt-template discovery/expansion; and centralized direct Pi SDK imports. `lifecycle` also owns bounded same-process connect intent across local `/resume`, cancellation/supersession, and fresh-context startup scheduling; it does not own transport authority, durable bindings, queue custody, or source-target continuity. Each Queue start/shutdown invocation captures its session generation for inner post-await checks, including config/temp preparation and polling suspension. Reused context identity cannot authorize an obsolete reset/discard or successor service startup. Follower refresh uses that same invocation-local predicate through its awaited registration/context publication; no mutable shared generation or target-preservation flag crosses overlapping invocations.
114
116
  - `command-templates`: shell-free command-template helpers, composition expansion, placeholder substitution, executable resolution, warnings, and retry/timeout semantics.
115
117
 
116
118
  ### Host Compatibility Boundary
@@ -441,7 +443,7 @@ Named Telegram profiles are orthogonal to Threaded Mode. The selected profile ch
441
443
 
442
444
  Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so unrelated global/profile updates do not stale-replace one another. Each profile's journal transaction independently publishes its monotonic admission cursor together with admitted work; polling never writes runtime state through config persistence. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments are flat `kind-scope-messageId[-index][-name|.ext]` files under `tmp/pi-telegram/attachments` and are session artifacts rather than identity or routing authority. The scope is the bot username (else bot ID) for the bot's private chat, a group username or numeric ID, and for Guest Mode the remote peer's username or numeric ID, because guest message IDs belong to the peer chat. Scratch cleanup removes regular files older than 24 hours. It never age-deletes journals, ownership, state, logs, or other top-level runtime files and therefore cannot redirect live traffic or orphan immutable journal segments.
443
445
 
444
- 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. Followers heartbeat every `1s`; the leader uses a `15s` stale grace and a `1s` prune loop so transient IPC stalls do not create false routing gaps while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping 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. Heartbeat pruning alone preserves stored owner records and Workspace bindings. The same leader can retain bounded, non-routing observations of actually registered PID/generation/target tuples for later absence proof and retry of uncommitted preservation; the [pruning contract](./multi-instance-bus.md#follower-heartbeat-is-missed) owns cancellation, admission identity and lifetime limits. When Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, serialized ahead of replacement registration. With cleanup disabled, that proof instead permits a profile-admitted, fenced non-destructive detachment: the store removes the uniquely matching owner record and records first inactivity while retaining the exact Workspace binding and its letter. Publication rechecks PID absence, runtime generation, profile, leader epoch, replacement registrations and the record snapshot; missing or ambiguous restoration identity blocks it. The Thread and its messages remain untouched, accepted-work protection remains independent, and no deleted-Thread observation is invented. Successful follower target reuse refreshes the binding and clears inactivity. Durable Workspace bindings remain restoration hints after detachment; heartbeat silence or unverifiable process evidence cannot stamp inactivity. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/pi-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/pi-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, `workspaceBindings` stores profile-scoped normalized exact-`cwd` target/name/slot reuse hints, 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. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. 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.
446
+ 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. Followers heartbeat every `1s`; the leader uses a `15s` stale grace and a `1s` prune loop so transient IPC stalls do not create false routing gaps while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping 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. Heartbeat pruning alone preserves stored owner records and Workspace bindings. The same leader can retain bounded, non-routing observations of actually registered PID/generation/target tuples for later absence proof and retry of uncommitted preservation; the [pruning contract](./multi-instance-bus.md#follower-heartbeat-is-missed) owns cancellation, admission identity and lifetime limits. When Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, serialized ahead of replacement registration. With cleanup disabled, that proof instead permits a profile-admitted, fenced non-destructive detachment: the store removes the uniquely matching owner record and records first inactivity while retaining the exact Workspace binding and its letter. Publication rechecks PID absence, runtime generation, profile, leader epoch, replacement registrations and the record snapshot; missing or ambiguous restoration identity blocks it. The Thread and its messages remain untouched, accepted-work protection remains independent, and no deleted-Thread observation is invented. Successful follower target reuse refreshes the binding and clears inactivity. Durable Workspace bindings remain restoration hints after detachment; heartbeat silence or unverifiable process evidence cannot stamp inactivity. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/pi-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/pi-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, `workspaceBindings` stores profile-scoped normalized exact-`cwd` target/name/slot reuse hints, 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. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. A retained `enabled` capability is not proof of a running local bus: after a startup probe timeout leaves classic polling active, a fresh positive monitor result restarts Threaded transport under the current lock owner even when the persisted mode is unchanged. Unknown results do not certify this recovery, healthy buses are not restarted, and followers do not acquire leader authority through the monitor. 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.
445
447
 
446
448
  Fresh follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then may that process allocate a profile-scoped normalized exact-`cwd` Workspace identity and cause the leader to create a Thread. A later process reopening that remembered Workspace automatically sends capability-gated restore-only admission under a live leader. The leader may reclaim, visibility-probe, or stale-replace the remembered target, but an absent binding returns quietly without creating a Thread. `/telegram-connect [profile] as=Name` supplies a unique capitalized Latin-word identity only to fresh Workspace provisioning; an existing Workspace keeps its persisted name. Telegram `/name Name` stores the owning Thread's durable `manualThreadName` and immediately applies it over the active automatic display projection; bare `/name` opens five-minute exact-target input whose next valid text is consumed before agent dispatch. Name input, cancel, and reset are consume-once; stale scope, target, message ID, expiry, and duplicate callbacks cannot mutate. Reset clears only the override and restores the current automatic projection. Leader command routing reuses its already-held profile admission for the rename body rather than recursively entering the non-reentrant Workspace gate; standalone leader renames acquire their own admission. Followers send an authenticated exact-generation `workspace-thread-rename-v1` request, and the leader owns any Bot API mutation plus durable binding persistence. Concurrent processes from one directory receive deterministic Workspace suffixes, while leader/follower roles remain transient projections over that durable identity. 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. A confirmed runtime transition from Threaded to Singleton stops threaded transport and suspends the process-local leader target before classic polling can accept new work; durable Workspace slot, generated name, manual display name, and binding evidence remain retained. Re-enabling Threaded Mode restores or replaces that logical binding before publishing one new live target. Already-admitted turns keep their captured destination and are never silently retargeted or duplicated.
447
449
 
@@ -575,37 +577,46 @@ Polling and inbound-worker diagnostics remain separate so an executing, deferred
575
577
 
576
578
  Long-text split recovery remains conservative: only human text at or above the near-limit threshold opens its debounce window. Forward annotation has two semantic layers: the forward owns its source text/caption/media, while an optional separate owner-authored annotation normally precedes it. A bounded one-second pairing window joins that annotation and adjacent forward in either transport order, including a media-only forward without source caption text; the matching opposite-kind message flushes immediately. Same-kind rapid messages, commands, bots, ordinary non-forward captions, media groups, different senders/targets, reversed ids, and distant message ids do not enter this pairing path. Prompt construction always places the owner annotation first, followed by `[forward|from:...]` with the forward's own source text/caption, then source-attributed forwarded attachments, regardless of arrival order.
577
579
 
580
+ ### Explicit Connection Recovery
581
+
582
+ An explicit `/telegram-connect` is permission for one fresh connection attempt, not permission to take over a live leader or clear accepted input. Under the existing profile-wide Workspace admission and mutation gate, the leader resets only matching unfinished creation records without a known Thread target before claiming Workspace identity. The reset verifies the exact retained record and refuses sidecar target-recovery evidence; durable publication and current ownership are required before another create. Known targets and other owners' pending records remain intact. A late response is isolated by a fresh provisioning ID, independent of wall-clock resolution.
583
+
584
+ If Telegram committed the abandoned create without returning its Thread ID, a spare tab may remain. This is the operator-approved availability trade-off: no unknown tab is deleted, and no journal, receipt, live binding or queued prompt is erased. A new API outage can still fail the new attempt; the command does not promise connectivity without a working Telegram transport.
585
+
586
+ Followers carry this explicit intent only on authenticated `follower.register`, gated by `explicit-connect-recovery-v1` at caller and leader. Automatic heartbeat recovery and `follower.restoreWorkspace` do not reset creation evidence. A matching targeted pending provision contributes its original Workspace key/slot when the exact session is claimed again, so retry can complete known publication rather than allocate a conflicting identity. Cross-session targets never become recovery grants.
587
+
578
588
  ### Queue And Dispatch Safety
579
589
 
580
- The bridge keeps its own Telegram queue. The Pi status bar's yellow `+N` suffix projects only executable prompts still waiting: current agent work from Telegram, terminal, or autonomous sources never contributes, and the dispatched Telegram head is subtracted while it remains retained pending `agent_start` consumption.
590
+ The bridge keeps its own Telegram queue. The Pi status bar projects executable waiting work across Normal, Priority, Steer and Control into one warning-yellow `+N` suffix, omitted when zero. It uses Queue's executability filter, so Skip and frozen attempts never contribute; current agent work from Telegram, terminal, or autonomous sources never contributes either. The dispatched Telegram head is subtracted while retained pending `agent_start` consumption.
581
591
 
582
592
  Queue items have two explicit dimensions:
583
593
 
584
594
  - `kind`: `prompt` or `control`.
585
- - `queueLane`: `control`, `priority`, or `default`.
595
+ - `queueLane`: `control`, `steer`, `priority`, or `default`.
586
596
 
587
- Dispatch rank:
597
+ Dispatch rank (distinct from the ascending menu selector order):
588
598
 
589
599
  1. `control` lane.
590
- 2. `priority` prompt lane.
591
- 3. `default` prompt lane.
600
+ 2. `steer` prompt lane, using its guarded active-boundary or idle-packet attempt.
601
+ 3. `priority` prompt lane.
602
+ 4. `default` prompt lane.
592
603
 
593
604
  #### Priority, Reactions, Keep, and Skip
594
605
 
595
- Waiting Normal/Priority prompts expose two independent dimensions:
606
+ Bridge-owned Steer uses the existing public Pi SDK. Busy-run operator smoke coverage is established; broader live and native-Windows acceptance remains follow-up work in the [backlog](../BACKLOG.md). Waiting ordinary prompts expose two independent dimensions:
596
607
 
597
- - `Priority` / `Normal` selects the FIFO lane and therefore scheduling order.
608
+ - `Normal` / `Priority` / `Steer` selects the FIFO lane and scheduling opportunity in increasing intervention order. Control remains producer-owned.
598
609
  - `Keep` / `Skip` selects whether the prompt executes when dispatch reaches it.
599
610
 
600
611
  A prompt is one queue object with exactly one active lane membership and one current position. It has no duplicate, shadow entry, or reserved return slot in the other lane. Each prompt admitted directly to a lane joins that lane's tail. A `Normal → Priority` transition removes it from Normal and appends that same object to the Priority tail; a later `Priority → Normal` transition removes it from Priority and appends it to the current Normal tail rather than restoring any historical position. The immutable `queueOrder` records original admission identity only and is never a return address; `laneOrder` records the current destination-lane position. Keep/Skip changes and emoji changes within the same reaction category preserve both lane and lane position exactly.
601
612
 
602
- Telegram reactions are shortcut controls over those dimensions. Recognized positive reactions control Priority and recognized negative reactions control Skip; the emoji sets live in the [queue reaction registry](./ui-style.md#queue-reaction-shortcuts). The runtime compares the complete old and new reaction sets and mutates only categories that changed, so adding or removing a negative reaction cannot silently change Priority, and changing a positive reaction cannot silently change Skip. The registry order selects the retained display emoji when several recognized emoji from one category coexist; it does not let one category override the other.
613
+ Telegram reactions are shortcut controls over those dimensions. Fire selects Steer and dominates other positives; thumbs-up/heart/dove select Priority and recognized negatives control Skip. Lightning is now a Control marker only, not a reaction shortcut; the emoji sets live in the [queue reaction registry](./ui-style.md#queue-reaction-shortcuts). The runtime compares effective intent from complete old and new reaction sets and mutates only dimensions that changed. Weaker positives under fire and decorative changes cannot reset a menu lane choice; so adding or removing a negative reaction cannot silently change Priority, and changing a positive reaction cannot silently change Skip. The registry order selects the retained display emoji when several recognized emoji from one category coexist; it does not let one category override the other.
603
614
 
604
- Priority and Skip may coexist when a prompt carries both a positive and a negative reaction. The prompt remains at its Priority-lane position while waiting, and its durable journal receipts remain intact so Keep stays reversible and exact live handoff remains possible. Skip wins when dispatch reaches the prompt: the dispatcher first settles those receipts durably, then drops it without a model turn and continues; settlement failure retains the skipped head instead of allowing replay ambiguity. The prompt stays visible with only its negative emoji, is excluded immediately from the executable queue count shared by the Pi status bar and Telegram main menu, and keeps a struck-through physical ordinal. Returning it to Keep restores its contribution to the count without moving it. Graceful session shutdown discards all remaining queue receipts before clearing session-local memory, and startup discards receipts only after proving their former process dead, so a new or unrelated session never inherits queued work. Queue item detail exposes symmetric Priority/Normal and Keep/Skip selectors instead of an irreversible Delete action.
615
+ Steer or Priority and Skip may coexist when a prompt carries both positive and negative reactions. The prompt remains at its current lane position while waiting, and its durable journal receipts remain intact so Keep stays reversible and exact live handoff remains possible. Skip wins when dispatch reaches the prompt: the dispatcher first settles those receipts durably, then drops it without a model turn and continues; settlement failure retains the skipped head instead of allowing replay ambiguity. The prompt stays visible with only its negative emoji, is excluded immediately from the executable queue count shared by the Pi status bar and Telegram main menu, and keeps a struck-through physical ordinal. Returning it to Keep restores its contribution to the count without moving it. Graceful session shutdown discards all remaining queue receipts before clearing session-local memory, and startup discards receipts only after proving their former process dead, so a new or unrelated session never inherits queued work. Queue item detail exposes horizontal Normal/Priority/Steer and Keep/Skip selectors instead of irreversible Delete. Legacy published Priority callbacks retain explicit lane intent. Dispatched/frozen items expose inspection only; an oversized idle Steer item stays editable with a Priority/Normal escape and never splits its source group.
605
616
 
606
- Menu and reaction controls share the same canonical queue state. A menu Keep can clear internal Skip without changing Priority or queue position, but Telegram's Bot API cannot remove a reaction created by the user; the visible user reaction can therefore remain until that user removes it. Once Pi has consumed or dropped a prompt, later reactions cannot retract or restore it.
617
+ Menu and reaction controls share the same canonical queue state. A menu Keep can clear internal Skip without changing Priority or queue position, but Telegram's Bot API cannot remove a reaction created by the user; the visible user reaction can therefore remain until that user removes it. Once a prompt is dispatched, frozen for an attempt, consumed or dropped, later reactions cannot change or reissue it. Steer freezes exact original objects before whole-receipt attempt disposition, which is not SDK/model-use proof. Active `turn_end` inserts one packet without resetting Telegram reply/preview ownership; packet content preserves original text/image blocks with blank-line separators only between prompts, without numbered Steer labels. Idle origin detection retains its Telegram prefix. Idle `sendUserMessage` pins the first original and its tail until `agent_start` projects one reply/voice/attachment owner. Partial/refused/unknown outcomes remain protected without replay, and Steer queue transfer requires independently negotiated `queue-steer-v1` plus base handoff support at every participant before custody changes. Frozen/pending work never transfers as unspent input; missing support or malformed attempted/projection fields fail closed.
607
618
 
608
- Guest Mode is the deliberate exception to deferred Keep/Skip semantics. A guest prompt has no source message reaction surface and may share zero-valued chat/message placeholders with other guest prompts, so queue-menu callbacks address it by immutable `queueOrder` and expose an immediate `Skip`. The leader settles its durable admission, removes it while other agent work may continue, stops placeholder animation, and replaces the inline globe placeholder with non-printing content. Bot API inline messages have no delete method, so this visual clear is the supported deletion equivalent. The guest prompt never reaches a model turn.
619
+ Guest Mode is the deliberate exception to deferred Keep/Skip semantics. A guest prompt has no source message reaction surface and may share zero-valued chat/message placeholders with other guest prompts, so queue-menu callbacks address it by immutable `queueOrder` and expose an immediate `Skip`. The leader settles its durable admission, removes it while other agent work may continue, stops placeholder animation, and replaces the inline globe placeholder with non-printing content. Bot API inline messages have no delete method, so this visual clear is the supported deletion equivalent. A successfully skipped waiting guest prompt never reaches a model turn. Once dispatched, its retained head is inspection-only until `agent_start` consumes that exact reply owner; stale Guest Skip callbacks refuse without receipt disposal or placeholder dismissal. Later waiting guests remain independently skippable.
609
620
 
610
621
  A standalone `/continue` is a source-addressed control-lane prompt, not a Normal/Priority prompt. An authorized negative reaction to its original chat/message immediately settles the exact waiting continuation's receipts before removing it, even while unrelated work is active. Failed or unknown settlement retains it for the existing reaction retry path; duplicate reactions cannot settle twice. Positive reactions and removal of the negative reaction neither change its control lane nor reconstruct cancelled work. The dispatched head awaiting `agent_start` and already-started work are never cancelled; appends and reorders preserve that Pi-owned head. Synthetic model-switch continuations have no source message IDs and are not reaction-addressable by their fallback reply anchor. Reaction authorization and chat/message namespace isolation remain unchanged.
611
622
 
@@ -25,6 +25,10 @@ myext:page:2
25
25
  - Put only opaque ids or small enum values in payloads; do not store secrets, full prompts, or large state.
26
26
  - Treat callbacks as untrusted input. Validate namespace, action, and payload before executing side effects.
27
27
 
28
+ ## Queue Controls
29
+
30
+ The Steer surface owns `queue:lane-set:<chat>:<message>:steer|priority|normal` and independent `queue:skip-set:<chat>:<message>:keep|skip`. Previously published `queue:prio-set` and `queue:prio` controls remain explicit Priority/Normal compatibility routes. Payloads only identify a current view; they cannot grant source/session authority or edit a protected pending/frozen item. Control lanes remain producer-owned.
31
+
28
32
  ## pi-telegram fallback
29
33
 
30
34
  If `pi-telegram` receives callback data that is not owned by its built-in prefixes and no built-in handler consumes it, it forwards the click to Pi as:
@@ -85,6 +85,8 @@ tmp/pi-telegram/state.json / profiles.<profile>.transport -> bus leader identity
85
85
 
86
86
  Followers do not poll. They register with the leader and receive routed inbound updates from it. Followers still own their local queue, active-turn state, previews, final delivery planning, model switches, and Pi lifecycle. The leader owns only Telegram transport and update fanout. Same-session context refresh preserves bus membership; a changed Pi session ID suspends the old receiver/heartbeat and requires acknowledged successor registration before readiness, without an explicit Thread disconnect. A new follower process also attempts a restore-only registration at session startup when the selected profile's local state contains a Workspace binding for the exact `cwd` and session ID. If an active retained manual-follower record blocks that binding, the leader may transfer its claim only for one exact eligible predecessor: no registered peer owns its runtime or target, and an OS probe proves the actual runtime PID absent. A live, inaccessible, missing or malformed PID proof and ambiguous candidates remain protective; neither elapsed time nor an empty registry alone proves death. This is only claim continuity, not receipt settlement, deletion or a fabricated immediate session handoff. The same binding, non-default instance slot, acknowledged title and journal history survive without provisioning a new Thread. The recent-event log distinguishes missing session bindings, disabled Threaded Mode, leader-side restore refusal, and a restore that returned without connecting; none of these refusals silently provisions a new Thread. The Telegram bus belongs to the local set of cooperating visible Pi instances rather than to the first terminal session forever: if the visible terminal leader exits, a live registered follower can take over leadership.
87
87
 
88
+ Explicit connection recovery is capability-gated by `explicit-connect-recovery-v1`. Only a manual `follower.register` may carry `recoverConnection: true`; malformed values and restore-only requests reject it. The leader resets matching untargeted creation metadata under ordinary profile admission before allocation, accepting a possible orphan tab without touching accepted input. Automatic registration never carries this grant. See [Explicit Connection Recovery](./architecture.md#explicit-connection-recovery).
89
+
88
90
  ### Session-Owned Journal Storage
89
91
 
90
92
  Runtime storage lives under `<agent-dir>/tmp/pi-telegram`. The pre-0.52.0 `tmp/telegram` tree is neither written, migrated nor deleted. A live fresh owner there blocks competing polling as read-only evidence; its endpoint and secret grant no current authority.
@@ -273,7 +275,7 @@ Followers first try to re-register after leader reload or unknown-heartbeat resp
273
275
 
274
276
  ### Protocol identity and compatibility
275
277
 
276
- The session-native local wire contract has protocol version `3`, independent from the npm package version. Version 3 moved journal source serialization from the `telegram.json` transaction to the journal-owned `runtime/journals.transaction`; because v2 peers would not exclude v3 journal writers, the base version check rejects them instead of mixing lock semantics. Follower registration and the leader acknowledgement carry `{ protocolVersion, runtimeBuild, capabilities }`. Capability names are canonical, unique, and sorted. A leader rejects missing or mismatched protocol identity before provisioning a target or publishing the follower into live routing; a strict follower likewise rejects an acknowledgement without compatible leader identity. Different package builds remain compatible when their protocol versions agree. `durable-follower-admission-v1` gates source forwarding, while `queue-handoff-v1` independently gates live semantic queue transfer; every participant in a routed handoff must advertise it. `workspace-follower-auto-connect-v1` gates restore-only startup admission. Session identity has been part of the base protocol since v2 rather than an optional capability. Every cwd-scoped registration carries the exact bounded session ID; missing identity is rejected before provisioning or publication. Reconnect, replacement, and promotion preserve it. Older-protocol peers are rejected by the base version check, avoiding mixed semantic branches. `thread-display-mode-v1` gates exact-generation follower display-setting requests and Letters/Directories require compatible connected followers; returning to Names allows legacy peers. Registration checks the current display-mode requirement before provisioning and again before live publication. The leader serializes config persistence and title application. `workspace-thread-rename-v1` independently gates follower rename requests whose exact registration generation is checked before the leader mutates Telegram and persists the Workspace binding. `session-replacement-intent-v1` gates Telegram `/new` from a follower Thread: followers never write `state.json`, so `follower.publishSessionReplacement` asks the leader to CAS-publish the durable intent only when the live registration generation, leader epoch, Profile, exact `cwd`, source session, Thread target, and the leader's own Workspace binding slot/name all match, with a bounded unexpired TTL. The intent records the source runtime instance; only a successor registration from that instance or its authenticated same-process handoff (`previousInstanceId`) may re-key the binding, and `follower.settleSessionReplacement` claims it once only after the leader's store shows that successor session bound to the same target. A missing capability, stale generation, or mismatch fails the command closed.
278
+ The session-native local wire contract has protocol version `3`, independent from the npm package version. Version 3 moved journal source serialization from the `telegram.json` transaction to the journal-owned `runtime/journals.transaction`; because v2 peers would not exclude v3 journal writers, the base version check rejects them instead of mixing lock semantics. Follower registration and the leader acknowledgement carry `{ protocolVersion, runtimeBuild, capabilities }`. Capability names are canonical, unique, and sorted. A leader rejects missing or mismatched protocol identity before provisioning a target or publishing the follower into live routing; a strict follower likewise rejects an acknowledgement without compatible leader identity. Different package builds remain compatible when their protocol versions agree. `durable-follower-admission-v1` gates source forwarding, while `queue-handoff-v1` independently gates live semantic queue transfer; every participant in a routed handoff must advertise it. Local experimental 0.56.0 adds `queue-steer-v1`: donor, leader and recipient must additionally support the unspent Steer lane before offer/staging. Protocol version and package version alone never imply this support. `workspace-follower-auto-connect-v1` gates restore-only startup admission. Session identity has been part of the base protocol since v2 rather than an optional capability. Every cwd-scoped registration carries the exact bounded session ID; missing identity is rejected before provisioning or publication. Reconnect, replacement, and promotion preserve it. Older-protocol peers are rejected by the base version check, avoiding mixed semantic branches. `thread-display-mode-v1` gates exact-generation follower display-setting requests and Letters/Directories require compatible connected followers; returning to Names allows legacy peers. Registration checks the current display-mode requirement before provisioning and again before live publication. The leader serializes config persistence and title application. `workspace-thread-rename-v1` independently gates follower rename requests whose exact registration generation is checked before the leader mutates Telegram and persists the Workspace binding. `session-replacement-intent-v1` gates Telegram `/new` from a follower Thread: followers never write `state.json`, so `follower.publishSessionReplacement` asks the leader to CAS-publish the durable intent only when the live registration generation, leader epoch, Profile, exact `cwd`, source session, Thread target, and the leader's own Workspace binding slot/name all match, with a bounded unexpired TTL. The intent records the source runtime instance; only a successor registration from that instance or its authenticated same-process handoff (`previousInstanceId`) may re-key the binding, and `follower.settleSessionReplacement` claims it once only after the leader's store shows that successor session bound to the same target. A missing capability, stale generation, or mismatch fails the command closed.
277
279
 
278
280
  The current root advertises `workspace-restore-v1`; all participating peers and potential leader successors must be upgraded before operator-controlled use. The retired `leader.replaceFollowerTarget` envelope is no longer parsed or produced, even with current authentication and registration generation. Its `leader.workspaceRestore` envelope carries only an operation ID, exact recipient registration generation and `apply`/`inspect` mode; the receiver derives binding and destination from the retained intent, not caller-supplied target data. Authentication and an explicitly enabled receiver are mandatory. The controller checks both peers' capability and protocol compatibility, captures live registration/session/slot/endpoint authority, sends once without transport retry, then validates the request, operation, recipient, observed target, slot and boolean readiness against the current registration. A lost response leaves the durable issuance consumed; a separate read-only inspection can prove readiness without applying again. Neither the controller nor receiver settles source input or writes follower-owned canonical state. A successor registration/process may inspect only its actual same-session canonical binding and local target; readiness records the new observer separately without changing the original issuance. An old local target cannot be repaired through inspection, and successor apply is rejected. Interrupted startup, terminal source/cleanup settlement and live behavior were accepted in the operator's 0.52.0 live smoke.
279
281
 
@@ -287,7 +289,7 @@ The delivery id excludes the replaceable runtime instance and registration gener
287
289
 
288
290
  Transport leadership does not own already-queued semantics. Each queued journal receipt binds the acquiring runtime instance, OS process birth, session generation, and acquisition. A replacement leader or follower process sees another live process's receipt as foreign and cannot replay or settle it; the original process may complete its local Pi queue after transport moves. Startup preserves foreign and legacy unowned receipts. Before a replacement admission worker starts, tri-state pid/process-birth proof: an absent PID or mismatched stable Linux/macOS birth identity permits recovery, while a live matching owner stays `alive` and Windows or inaccessible birth metadata stays `unverifiable`; both non-dead outcomes preserve the receipt may transactionally recover a dead owner's complete receipt to pending, after which replacement replay creates fresh queue authority. Registration publishes the replacement's exact pid/process-birth identity first; a concurrent recovery that observes it returns `owner-alive`, while live or unverifiable owners remain untouched.
289
291
 
290
- Live-process handoff combines journal CAS with authenticated bounded bus payloads. A donor-generated one-time token is hashed together with the exact receipt, donor acquisition, and recipient runtime/process/session identity. Offering retains donor ownership but freezes donor completion/discard and dead-owner recovery. Prompt payloads serialize queue data; control payloads serialize only stable `status`/`model` identity and rebuild closures at the recipient. Leader and follower receivers require exact live donor/recipient registration generations, reject malformed/oversized payloads, stage one complete receipt idempotently, accept its journal authority, and acknowledge only that exact receipt plus newly minted owner. Receipts name their source journal binding, while the donor derives the recipient follower-journal binding from the authenticated stable profile. The recipient accepts only that matching active lifecycle. Production advertises `queue-handoff-v1` with this exact role/path composition; legacy-unbound or unavailable bindings fail closed.
292
+ Live-process handoff combines journal CAS with authenticated bounded bus payloads. A donor-generated one-time token is hashed together with the exact receipt, donor acquisition, and recipient runtime/process/session identity. Offering retains donor ownership but freezes donor completion/discard and dead-owner recovery. Prompt payloads serialize queue data; control payloads serialize only stable `status`/`model` identity and rebuild closures at the recipient. Leader and follower receivers require exact live donor/recipient registration generations, reject malformed/oversized payloads, stage one complete receipt idempotently, accept its journal authority, and acknowledge only that exact receipt plus newly minted owner. Receipts name their source journal binding, while the donor derives the recipient follower-journal binding from the authenticated stable profile. The recipient accepts only that matching active lifecycle. Production advertises `queue-handoff-v1` with this exact role/path composition; legacy-unbound or unavailable bindings fail closed. The Steer codec preserves original lane/order, content/media, Keep/Skip and exact source receipts; syntax decoding alone grants no custody. Legacy peers refuse before offer, and every routing/receiver boundary rechecks support. Frozen/attempted packets, Pi-owned pending heads, synthetic packet projections and Steer guests cannot become transferable waiting input. Capability loss before staging cancels only an unaccepted offer; existing exact owner/ACK rules govern lost post-acceptance replies.
291
293
 
292
294
  As part of staging, the exact recipient presents the token and atomically receives a fresh acquisition carrying the handoff digest; donor settlement is then stale, and the donor trusts only an ACK carrying that accepted owner. Registration carries recipient process-birth and session generation so journal authority matches the live runtime exactly. The coordinator contract shares one ordering across leader→follower and follower→follower paths: offer, route bounded payload, require the exact staged-and-accepted receipt-and-owner ACK, remove donor work, then publish recipient dispatch readiness. Negative or mismatched acknowledgement cancels the still-unaccepted offer and retains donor work. A lost acknowledgement after recipient acceptance cannot revoke the accepted owner; cancellation fails closed and donor memory remains frozen for exact accepted-owner reconciliation. Repeated exact acceptance is idempotent; a different token cannot claim the accepted receipt. Queue authority has no elapsed-time lease: only exact handoff, owner action, or transaction-rechecked PID/process-birth death proof may move it.
293
295
 
@@ -305,7 +307,7 @@ After the recipient confirms the applied rebind and before the source is release
305
307
 
306
308
  Follower live apply, held-work observation and release-readiness checks use the exact read-only live-rebind Workspace snapshot, including with consolidated storage and no follower publication authority. The captured store/reader identity stays fenced across awaits; a missing or replaced reader refuses without falling back to the publication-backed legacy Restore observer. Profile admission, exact canonical intent/binding and source/custody checks still apply. Observation changes only the follower's local target registration, never leader-owned Workspace evidence; journal reads and receipt settlement remain separate owners.
307
309
 
308
- Every participating peer must negotiate the required capabilities and retain current authentication, registration generation, Pi context/session, journal binding and exact recipient authority. The wire validates syntax; Commands' captured registry decides availability before hold/append. Supported built-ins and registered prompt templates reuse one plan set across compatible leader selections and held followers; extension commands/groups/legacy registrations do not gain follower execution. Continue keeps its control lane, while expanded templates are ordinary default-lane prompts even when their text starts with a slash.
310
+ Every participating peer must negotiate the required capabilities and retain current authentication, registration generation, Pi context/session, journal binding and exact recipient authority. The wire validates syntax; Commands' captured registry decides availability before hold/append. Supported built-ins and registered prompt templates reuse one plan set across compatible leader selections and held followers. Both peers additionally negotiate `held-menu-aliases-v1` for follower `/start` and `/help`: these already-authenticated selections open the restricted main menu using the status plan, never pair a user, bootstrap a Thread or synchronize BotFather commands through ordinary follower transport; extension commands/groups/legacy registrations do not gain follower execution. Continue keeps its control lane, while expanded templates are ordinary default-lane prompts even when their text starts with a slash.
309
311
 
310
312
  Semantic reports, queued receipts, completion observation and delivery remain separate. Native exact ACK and donor CAS still own source disposition; lost or mismatched evidence retains uncertainty without handler replay, another queue or fallback admission. This section owns the unified command-channel policy; [architecture](./architecture.md#live-rebind-module-map) owns the module map. Existing Workspace Restore receivers/readers and queue-handoff retain their custody contracts; the new loaded root prefers live rebinding rather than using legacy Restore as an uncertain-input fallback. Source-enabled bounded cleanup is not proof of an actual deletion or client acceptance. Supported leader/follower prompts, commands and templates use `saved → binding applied → cleanup decision → input issuance → native admission/execution`. The chooser holds its existing profile admission through the unchanged whole first delay and one fresh cleanup attempt, without nested leader admission or another queue. A negotiated follower reports work through `observe-held` before release; binding readiness is not execution permission. `inputRelease: held/issued/accepted` is independent of cleanup: an issued input is never replayed after a lost admission reply, and a finished cleanup with held input remains active binding/source protection. Confirmed, failed or unknown terminal deletion permits the one native input attempt only on the confirmed binding. Issued and unknown old-target evidence remains canonically protected against reuse and generic cleanup, including response/publication interruption; unknown evidence survives input acceptance and is never capacity-evicted or reissued. If earlier work prevents issuance, input proceeds on the confirmed new target and the existing paced scheduler owns deferred cleanup; no old-target clearance is invented. Detached command producers accept their one input grant independently from exact source ACKs and reply publication; menu/reply authority tolerates only that issued-to-accepted progress, never changed binding, cleanup, recipient or generation. Expanded templates retain their captured content/default lane and Continue its control lane. A session-replacing `/new` producer with earlier protected work records positively unissued cleanup plus durable `oldTargetProtected`; it cannot erase that old-target protection or transfer a delete grant into the successor. Actual replacement still uses the existing post-removal Pi gateway and confirmation, not Restore readiness. Follower command/control completion retains the exact prepared-source/native ACK before donor CAS; queued admission is not completion and cannot dispose the donor. Unsupported producers do not gain arbitrary follower execution. Older peers keep the preceding supported channel. In deferred cleanup, old-tab cleanup waits for the recipient session to be idle, which the moved input itself may keep busy; its paced retries keep a whole first delay so a detached command reply still sees the released, cleanup-free row, but after that a leader's `agent_settled` or drained output barrier wakes a waiting retry at once. Publication/command-work, Activity handlers, per-target companion queues and target/chat API counters capture lifetime-fenced notifications when work starts and emit only after their pending observation clears. The session adapter defers the hint without awaiting cleanup and rechecks its captured context generation, transport/profile and leader epoch/operator authority. Streaming technical events can drain the publication queue without ending the agent run. A delivery hint while SDK idle is false, unavailable or throwing is retained in the existing chain but does not clear its timer or acquire Workspace admission. `agent_settled` remains an unconditional recheck hint; after settlement, actual remaining delivery drains can wake the retry. A running attempt retains one coalesced hint without a concurrent check, then re-enters the same idle filter after releasing its admission. A fresh timer/settlement attempt consumes the previously deferred hint; signals arriving during that await are not lost. Timer retries, including busy-session samples, are unchanged. Terminal outcomes discard hints, including unknown issued deletion. The initial one-second delay, retry/window fallback, follower pacing and negotiated IPC contracts remain unchanged. Each chain also retains its original generation/profile-store/epoch/operator lifetime rather than rebasing to a reused context. The wake only re-samples every gate through fresh admission; it never grants cleanup, clears unconfirmed work, occupies the publication tail or holds Pi settlement for deletion. Windows runs the same strict paths, exercised by the CI matrix; actual Pi/client acceptance and release are not inferred from supplied IPC fixtures.
311
313
 
@@ -559,7 +561,7 @@ All files containing routing, chat ids, thread ids, or process details use priva
559
561
  - Each unfinished preservation retains one admission operation ID across sequential attempts, so a lost acquisition acknowledgement reuses and releases the exact lease instead of leaking another. A rejected pre-rename write leaves the owner intact; a committed write with lost acknowledgement is recognized without rewriting first inactivity. A liveness interruption is retryable, not successful settlement. Every attempt reacquires profile admission and the shared mutation gate, rechecks authority at rename, and preserves accepted work. Cancellation or scope loss never guesses away an unresolved admission lease; existing exact lease/dead-owner recovery remains authoritative.
560
562
  - Followers treat rejected/missing heartbeat acknowledgements as registration loss: retain the last known target locally, clear registered truth, show `reconnecting` instead of a healthy `follower` status, try to re-register with the current leader, wait a short leader-reload grace window, and retry. They promote only after the exact leader lease becomes stale or inactive; a live owner with an unreachable endpoint leaves the follower disconnected/retrying rather than creating a competing poller.
561
563
  - Persisted current manual-follower bindings survive abrupt process absence as restoration hints when Thread cleanup is disabled. When enabled, graceful Pi quit requests exact-generation teardown before lifecycle suspension; if that envelope is missed, stale pruning may delete only after the leader's OS confirms the exact registered PID has exited.
562
- - Fresh registration sends one compact connected notice in the assigned thread. Reusing a retained exact-session target resolves its acknowledged Workspace display title, even if the provisional claim uses another instance slot; a generated dictionary name is only fallback when no acknowledged title exists. An exact immediate session handoff uses a target-scoped `sendChatAction` as its synchronous visibility probe, avoiding a duplicate notice while retaining stale/ambiguous recovery; other cross-session restoration keeps the connected notice as its probe.
564
+ - Fresh registration sends one compact connected notice in the assigned thread. Reusing a retained exact-session target resolves its acknowledged Workspace display title, even if the provisional claim uses another instance slot; a generated dictionary name is only fallback when no acknowledged title exists. Cross-process/session restoration, including an exact immediate handoff, uses that connected message as its visibility probe before publishing registration. Typing is not proof that a retained tab accepts messages. A proven stale target enters existing replacement provisioning; an ambiguous send refuses registration without inventing deletion or retry authority.
563
565
  - Registration requires a present generation, and explicit disconnect requires that same exact live generation. Leader-side registration and disconnect mutations serialize per durable follower profile across old and replacement runtime instance IDs, so a replacement registration cannot overtake awaited destructive cleanup and an old disconnect cannot remove its successor's routing authority.
564
566
  - Successful forwarded updates and follower-originated API calls refresh liveness, so active followers are not pruned only because the interval heartbeat tick lagged. Each follower lifecycle owns at most one in-flight heartbeat for its exact registration generation; stop/replacement makes late settlement inert, and recovery or diagnostic failure cannot escape as an unhandled interval Promise.
565
567
  - Destructive follower thread teardown belongs to confirmed `/telegram-disconnect`, graceful Pi quit, or confirmed reconciliation actions, not generic heartbeat pruning. Manual disconnect retains its destructive confirmation and clears restart ownership; quit deletes the tab without prompting when Thread cleanup is enabled (default) but preserves the owner slot independently so a same-directory restart can reclaim leadership. Confirmed leader/follower teardown first persists an exact target/runtime-generation cleanup intent. The active leader attempts deletion under its current epoch; interruption preserves the intent so that leader or a successor can replay it under current authority, and confirmed deletion removes the binding plus intent in the same persisted state transition. If the graceful request is missed, stale heartbeat plus OS-confirmed absence of the exact registered PID may authorize the same cleanup while enabled; this action serializes ahead of replacement registration. Disabled cleanup, silence, heartbeat expiry alone, IPC/auth failure, and live or unknown process liveness remain non-destructive. Incomplete cleanup preserves durable intent for retry. A promoted leader uses its current owned leader epoch even when the inherited record still carries a historical `manual-follower` owner label.
package/docs/ui-style.md CHANGED
@@ -72,32 +72,33 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
72
72
  | Emoji | Meaning | Canonical surfaces | Notes |
73
73
  | --- | --- | --- | --- |
74
74
  | `🟢` | Current/active/enabled `On` | Current option in vertical lists, active state rows, active `On` toggle | One marker per selected list value; inactive list values stay unmarked. |
75
- | `🟡` | Active `Off` or elevated/filter state | Active `Off` toggle, Priority/Scoped active tab | Yellow means intentionally not-normal or off/default-caution, not error. |
75
+ | `🟡` | Active Steer, `Off`, or filter state | Steer queue selector, active `Off` toggle, Scoped active tab | Yellow distinguishes early insertion or off/filter state, not error. |
76
76
  | `🔴` | Active destructive/deferred disposition | Active queue `Skip` selector | Red distinguishes a prompt that will be discarded at dispatch from reversible neutral or elevated state. |
77
- | `🟣` | Normal/default active tab | Normal priority tab, All/default scope tab, active page picker | Use for neutral active tabs. |
77
+ | `🟣` | Active Priority lane or neutral navigation tab | Priority queue selector, All/default scope tab, active page picker | Queue lane meaning is distinct from neutral navigation. |
78
+ | `🔵` | Active Normal queue lane | Normal queue selector | Blue distinguishes ordinary queued tasks from Steer and Priority. |
78
79
  | `⚫️` | Inactive placeholder | Inactive toggle values and inactive tabs | Keeps row width stable. |
79
80
  | `⬆️` | Navigate upward | `⬆️ Main menu`, `⬆️ Back` | Always first row in submenus. |
80
81
 
81
82
  ### Queue Reaction Shortcuts
82
83
 
83
- Queue reactions are shortcut controls for waiting turns. Preserve their semantics across Telegram reactions, queue-menu rows, status previews, and tests. Positive emoji control the Priority/Normal lane; negative emoji control Keep/Skip. These categories are independent, may coexist, and mutate only their own dimension. Crossing lanes appends the prompt at the destination FIFO tail; changing Keep/Skip or changing emoji within one category preserves lane position. Skip wins only when dispatch reaches the prompt.
84
+ Queue reactions are shortcut controls for waiting turns. Preserve their semantics across Telegram reactions, queue-menu rows, status previews, and tests. Effective positive intent controls Steer/Priority/Normal; negative emoji control Keep/Skip. Fire selects Steer, dominates other positive reactions, and removing it falls back to remaining Priority aliases or Normal. Only changed effective intent overrides a menu choice; decorative changes and weaker positives under fire preserve lane/order. These categories are independent, may coexist, and mutate only their own dimension. Crossing lanes appends the prompt at the destination FIFO tail; changing Keep/Skip or changing emoji within one category preserves lane position. Skip wins only when dispatch reaches the prompt.
84
85
 
85
86
  Skipped queue ordinals strike only the numeric position. Detail HTML closes `<s>` before the period; list-button labels place a hair-space boundary between the combining-struck number and the plain period so Telegram font overhang cannot visually strike punctuation.
86
87
 
87
88
  Queue item detail renders two independent selector rows:
88
89
 
89
- - `🟡 Priority` / `⚫️ Normal` or `⚫️ Priority` / `🟣 Normal` selects the lane.
90
+ - `Normal` / `Priority` / `Steer` selects the lane in one horizontal row, increasing intervention from left to right. Active values are `🔵 Normal`, `🟣 Priority`, or `🟡 Steer`; inactive values use `⚫️`. Control is producer-owned, not a selectable lane.
90
91
  - `🟢 Keep` / `⚫️ Skip` or `⚫️ Keep` / `🔴 Skip` selects dispatch disposition.
91
92
 
92
- The menu may clear internal Skip but cannot remove a reaction created by the user through Telegram's Bot API.
93
+ The menu may clear internal Skip but cannot remove a reaction created by the user through Telegram's Bot API. Frozen attempts and the Pi-owned pending head expose inspection/Back only, with no success or model-use claim. An over-capacity idle Steer item remains editable and offers Priority/Normal rather than splitting its source group. Published legacy Priority/Normal callbacks remain explicit lane selections.
93
94
 
94
95
  | Emoji | Meaning | Canonical surfaces | Notes |
95
96
  | --- | --- | --- | --- |
96
97
  | `👍` | Promote to priority | Queue reaction shortcut | Normalized from variants like `👍️`. |
97
- | `⚡` | Promote to priority / fast lane | Queue reaction shortcut, priority fallback badge | Also used as the default priority badge when no specific priority emoji is stored. |
98
+ | `⚡` | Producer-owned Control marker | Control queue rows | Not a positive-reaction alias or command classifier; old stored Priority badges project `👍` without changing lane authority. |
98
99
  | `❤` / `❤️` | Promote to priority | Queue reaction shortcut | Normalize display consistently where code normalizes reactions. |
99
100
  | `🕊` / `🕊️` | Promote to priority | Queue reaction shortcut | Soft/peaceful promotion gesture. |
100
- | `🔥` | Promote to priority | Queue reaction shortcut | Urgent/hot promotion gesture. |
101
+ | `🔥` | Select Steer | Queue reaction shortcut and Steer badge | Dominates `👍`, heart and dove; not immediate issuance or model-use proof. |
101
102
  | `👎` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Reversible until the marked turn reaches dispatch; not negative feedback to the agent. |
102
103
  | `👻` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Disappear/remove metaphor. |
103
104
  | `💔` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Reversible cancel metaphor. |
@@ -181,15 +182,16 @@ Rules:
181
182
  - Use Capitalized labels.
182
183
  - Always show an indicator on every tab to avoid horizontal label shift.
183
184
  - Use active tab color to convey semantics:
184
- - `🟣` for the default / normal state (All models, Normal priority).
185
- - `🟡` for an elevated or filtered state (Scoped models, Priority).
185
+ - `🟣` for a default navigation scope (All models) or the current Priority lane choice.
186
+ - `🟡` for the current Steer lane choice or a filtered scope (Scoped models).
187
+ - `🔵` for the current Normal queue lane choice, independently of Keep/Skip.
186
188
  - `🟣` for neutral navigation controls (page picker).
187
189
  - Mark inactive tabs with `⚫️`.
188
190
 
189
191
  Examples:
190
192
 
191
193
  - `🟡 Scoped` / `⚫️ All`
192
- - `⚫️ Priority` / `🟣 Normal`
194
+ - `⚫️ Priority` / `🔵 Normal`
193
195
 
194
196
  ## Option Lists
195
197
 
@@ -250,6 +252,8 @@ A message (or All-tab command) held in an unbound or temporary tab gets one rout
250
252
 
251
253
  Pi TUI connection notices use plain text: a short known cause and one recovery action. Unknown failures use a generic connection-failed notice with `/telegram-status --debug`; never interpolate raw exceptions, credentials, stack traces or Pi lifecycle guidance. Technical evidence belongs in the redacted runtime recorder. Failed disconnect must retain the instruction to keep Pi open; do not also rethrow the same error as a second Pi banner. These TUI notices are distinct from Telegram bot message cards below.
252
254
 
255
+ The compact TUI status bar shows one `+N` counter in yellow `warning` for total waiting work across Normal, Priority, Steer and Control, hidden when zero. Custom themes control the exact hue. It excludes the pending dispatched head, Skip and frozen attempts; active work never adds to the counter. Queue state and dispatch precedence are unchanged.
256
+
253
257
  The compact TUI status bar preserves the acknowledged Thread display title and its casing, including manual `/name` overrides. Names equal to `Telegram`, `Leader` or `Follower` are ordinary titles, not fallback markers. Connection states, including disconnected and error, do not replace a supplied title; only an absent or empty title uses the generic `telegram` label.
254
258
 
255
259
  ## Message Cards
package/lib/bindings.ts CHANGED
@@ -209,6 +209,10 @@ export interface TelegramQueueBindingRuntime<TContext> {
209
209
  requestNextDispatchAnnouncement: () => void;
210
210
  cancelNextDispatchAnnouncement: () => void;
211
211
  watchdog: Queue.TelegramQueueDispatchWatchdogRuntime<TContext>;
212
+ onTurnEnd: (
213
+ event: Pick<Pi.TurnEndEvent, "turnIndex" | "outcome">,
214
+ ctx: TContext,
215
+ ) => Pi.TurnEndEventResult | undefined;
212
216
  }
213
217
 
214
218
  export function createTelegramQueueBindingRuntime<TContext>(deps: {
@@ -217,8 +221,8 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
217
221
  lifecycle: Pick<
218
222
  Runtime.TelegramBridgeRuntime["lifecycle"],
219
223
  "isCompactionInProgress" | "hasDispatchPending"
220
- >;
221
- activeTurn: Pick<Queue.TelegramActiveTurnStore, "has">;
224
+ > & Partial<Pick<Runtime.TelegramBridgeRuntime["lifecycle"], "getActiveToolExecutions">>;
225
+ activeTurn: Pick<Queue.TelegramActiveTurnStore, "has"> & Partial<Pick<Queue.TelegramActiveTurnStore, "get">>;
222
226
  admission: {
223
227
  getSettlement: () =>
224
228
  | {
@@ -228,7 +232,7 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
228
232
  ) => boolean;
229
233
  isItemReady: (item: Queue.TelegramQueueItem<TContext>) => boolean;
230
234
  onPromptHandedOff?: (
231
- item: Queue.PendingTelegramTurn,
235
+ item: Pick<Queue.PendingTelegramTurn, "admissionReceipts">,
232
236
  ctx: TContext,
233
237
  ) => boolean;
234
238
  onControlSettled: (
@@ -249,6 +253,8 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
249
253
  promptDispatch: Runtime.TelegramPromptDispatchRuntime<TContext>;
250
254
  isIdle: (ctx: TContext) => boolean;
251
255
  hasPendingMessages: (ctx: TContext) => boolean;
256
+ isContextCurrent?: (ctx: TContext) => boolean;
257
+ isContextAborted?: (ctx: TContext) => boolean;
252
258
  updateStatus: (ctx: TContext, error?: string) => void;
253
259
  sendTextReply: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendTextReply"];
254
260
  sendUserMessage: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendUserMessage"];
@@ -269,6 +275,10 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
269
275
  if (!settlement) return false;
270
276
  return settlement.onItemsDiscarded(durableItems, ctx) === true;
271
277
  };
278
+ const settlePromptPacket = (items: readonly Queue.PendingTelegramTurn[], ctx: TContext): boolean => {
279
+ const receipts = items.flatMap(item => item.admissionReceipts ?? []);
280
+ return receipts.length === 0 || deps.admission.getSettlement()?.onPromptHandedOff?.({ admissionReceipts: receipts }, ctx) === true;
281
+ };
272
282
  const mutation = Queue.createTelegramQueueMutationController({
273
283
  ...deps.store,
274
284
  hasPendingDispatch: deps.lifecycle.hasDispatchPending,
@@ -309,6 +319,13 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
309
319
  if (!settlement?.onPromptHandedOff) return false;
310
320
  return settlement.onPromptHandedOff(item, ctx) === true;
311
321
  },
322
+ steerDispatch: {
323
+ commitPacket: settlePromptPacket,
324
+ isContextCurrent: (ctx: TContext) => deps.isContextCurrent?.(ctx) === true,
325
+ canSubmit: (ctx: TContext) => deps.isContextAborted?.(ctx) === false &&
326
+ deps.isIdle(ctx) && !deps.hasPendingMessages(ctx) && !deps.lifecycle.isCompactionInProgress() &&
327
+ !deps.activeTurn.has() && (deps.lifecycle.getActiveToolExecutions?.() ?? 0) === 0,
328
+ },
312
329
  onControlSettled(item, ctx) {
313
330
  deps.admission.getSettlement()?.onControlSettled(item, ctx);
314
331
  },
@@ -323,8 +340,32 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
323
340
  ...deps.promptDispatch,
324
341
  sendUserMessage: deps.sendUserMessage,
325
342
  });
343
+ const steer = Queue.createTelegramSteerBoundaryRuntime({
344
+ ...deps.store,
345
+ getGeneration: deps.deferredDispatch.getGeneration,
346
+ isContextCurrent: (ctx: TContext) => deps.isContextCurrent?.(ctx) === true,
347
+ isBlocked: (ctx: TContext) =>
348
+ deps.isContextAborted?.(ctx) !== false || !deps.deferredDispatch.isBound() ||
349
+ deps.isIdle(ctx) || deps.hasPendingMessages(ctx) ||
350
+ deps.lifecycle.isCompactionInProgress() || deps.lifecycle.hasDispatchPending() ||
351
+ (deps.lifecycle.getActiveToolExecutions?.() ?? 0) > 0 ||
352
+ (deps.activeTurn.has() && !deps.activeTurn.get?.()),
353
+ isItemReady(item: Queue.TelegramQueueItem<TContext>) {
354
+ return deps.transportStamp.isActive(item.transportStamp) &&
355
+ !deps.admission.hasPendingQueueMutationForItem(item) &&
356
+ (deps.admission.getSettlement()?.isItemReady(item) ?? (item.admissionReceipts?.length ?? 0) === 0);
357
+ },
358
+ isItemCurrent: (item) => deps.transportStamp.isActive(item.transportStamp) &&
359
+ !deps.admission.hasPendingQueueMutationForItem(item),
360
+ getActiveTurn: () => deps.activeTurn.get?.(),
361
+ commitPacket: settlePromptPacket,
362
+ discardItem: (item, ctx) => settleDiscardedItems([item], ctx),
363
+ updateStatus: deps.updateStatus,
364
+ recordRuntimeEvent: deps.recordRuntimeEvent,
365
+ });
326
366
  return {
327
367
  mutation,
368
+ onTurnEnd: steer.onTurnEnd,
328
369
  dispatchNext: dispatch.dispatchNext,
329
370
  requestNextDispatchAnnouncement: dispatch.requestNextDispatchAnnouncement,
330
371
  cancelNextDispatchAnnouncement: dispatch.cancelNextDispatchAnnouncement,
@@ -1156,6 +1197,7 @@ interface TelegramLifecycleBindingDeps {
1156
1197
  >["sendTextReply"] &
1157
1198
  NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendTextReply"]>;
1158
1199
  dispatchNextQueuedTelegramTurn: (ctx: Pi.ExtensionContext) => void;
1200
+ onSteerTurnEnd?: TelegramQueueBindingRuntime<Pi.ExtensionContext>["onTurnEnd"];
1159
1201
  onPromptHandedOff?: (
1160
1202
  turn: Queue.PendingTelegramTurn,
1161
1203
  ctx: Pi.ExtensionContext,
@@ -1236,6 +1278,7 @@ export function registerTelegramLifecycleRuntimeHooks({
1236
1278
  sendMarkdownReply,
1237
1279
  sendTextReply,
1238
1280
  dispatchNextQueuedTelegramTurn,
1281
+ onSteerTurnEnd,
1239
1282
  onPromptHandedOff,
1240
1283
  answerGuestQuery,
1241
1284
  deleteMessage,
@@ -1566,6 +1609,12 @@ export function registerTelegramLifecycleRuntimeHooks({
1566
1609
  isSessionActive: isSessionContextActive,
1567
1610
  ...sessionLifecycleRuntime,
1568
1611
  ...agentLifecycleHooks,
1612
+ ...(onSteerTurnEnd ? {
1613
+ onTurnEnd(event: Pi.TurnEndEvent, ctx: Pi.ExtensionContext) {
1614
+ const result = onSteerTurnEnd(event, ctx);
1615
+ return result ? { ...result, entries: [...event.entries, ...(result.entries ?? [])] } : undefined;
1616
+ },
1617
+ } : {}),
1569
1618
  onInput(event) {
1570
1619
  activityRuntime.recordInputSource(event.source ?? "unknown");
1571
1620
  },
@@ -35,13 +35,13 @@ import {
35
35
  getTelegramBusProtocolCompatibility,
36
36
  getTelegramBusSocketPath,
37
37
  hasTelegramBusCapability,
38
+ hasTelegramBusSteerQueueHandoffCapability,
38
39
  isTelegramBusEnvelopeAuthorized,
39
40
  parseTelegramBusEnvelope,
40
41
  resolveTelegramBusSocketPath,
41
42
  sendTelegramBusLocalEnvelope,
42
43
  TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
43
44
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY,
44
- TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET,
45
45
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE,
46
46
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE,
47
47
  TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION,
@@ -51,6 +51,7 @@ import {
51
51
  TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE,
52
52
  TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
53
53
  TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES,
54
+ getTelegramBusHeldCommandCapabilities,
54
55
  TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES,
55
56
  TelegramBusLocalAuthorityError,
56
57
  type TelegramBusAgentMessage,
@@ -177,6 +178,7 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
177
178
  target?: TelegramTarget;
178
179
  previousInstanceId?: string;
179
180
  restoreWorkspace?: boolean;
181
+ recoverConnection?: boolean;
180
182
  },
181
183
  ) => Promise<boolean>;
182
184
  setContext: (ctx: TContext) => void | Promise<void>;
@@ -2929,7 +2931,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
2929
2931
  deps.setActiveAuthSecret?.(undefined);
2930
2932
  await deps.stopReceiving?.();
2931
2933
  };
2932
- const pendingHandoff = options
2934
+ const pendingHandoff = options && !options.recoverConnection
2933
2935
  ? undefined
2934
2936
  : getTelegramFollowerSessionHandoff();
2935
2937
  const pendingHandoffOptions = isTelegramFollowerSessionHandoffFresh(
@@ -2945,8 +2947,9 @@ export function createTelegramBusFollowerRegistrationRuntime<
2945
2947
  target?: TelegramTarget;
2946
2948
  previousInstanceId?: string;
2947
2949
  restoreWorkspace?: boolean;
2950
+ recoverConnection?: boolean;
2948
2951
  }
2949
- | undefined = options ?? pendingHandoffOptions;
2952
+ | undefined = options ? { ...pendingHandoffOptions, ...options } : pendingHandoffOptions;
2950
2953
  const leaderSocketPath =
2951
2954
  leader.busSocketPath ??
2952
2955
  deps.getLeaderSocketPath?.() ??
@@ -2967,6 +2970,8 @@ export function createTelegramBusFollowerRegistrationRuntime<
2967
2970
  : "follower.register",
2968
2971
  requestId: registrationGeneration,
2969
2972
  auth: activeAuthSecret,
2973
+ ...(registrationOptions?.recoverConnection && !registrationOptions.restoreWorkspace
2974
+ ? { recoverConnection: true as const } : {}),
2970
2975
  registration: {
2971
2976
  instanceId: deps.instanceId,
2972
2977
  ...(registrationOptions?.previousInstanceId
@@ -4545,8 +4550,7 @@ export function createTelegramBusForwardedUpdateReceiverRuntime<TContext>(
4545
4550
  ? [
4546
4551
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY,
4547
4552
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE,
4548
- TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET,
4549
- TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY,
4553
+ ...getTelegramBusHeldCommandCapabilities(commandInput.name),
4550
4554
  ]
4551
4555
  : []),
4552
4556
  ];
@@ -4752,6 +4756,10 @@ export function createTelegramBusForwardedUpdateReceiverRuntime<TContext>(
4752
4756
  result: receipt,
4753
4757
  };
4754
4758
  }
4759
+ if (envelope.payload.queueLane === "steer" &&
4760
+ ![deps.getLocalProtocol?.(), deps.getLeaderProtocol?.()].every(hasTelegramBusSteerQueueHandoffCapability)) {
4761
+ throw new Error("Steer queue handoff capability was not negotiated.");
4762
+ }
4755
4763
  if (!deps.handleQueueHandoff) {
4756
4764
  throw new Error(
4757
4765
  "Telegram bus receiver cannot accept queue handoff payloads.",