@llblab/pi-telegram 0.55.0 → 0.56.0

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 (50) hide show
  1. package/AGENTS.md +3 -2
  2. package/BACKLOG.md +4 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +27 -18
  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 +5 -1
  9. package/dist/lib/bus-leader.js +9 -1
  10. package/dist/lib/bus.d.ts +5 -0
  11. package/dist/lib/bus.js +17 -6
  12. package/dist/lib/extension.js +14 -2
  13. package/dist/lib/lifecycle.d.ts +2 -1
  14. package/dist/lib/lifecycle.js +28 -20
  15. package/dist/lib/menu-queue.d.ts +1 -0
  16. package/dist/lib/menu-queue.js +102 -99
  17. package/dist/lib/pi.d.ts +3 -2
  18. package/dist/lib/pi.js +3 -0
  19. package/dist/lib/polling.js +3 -1
  20. package/dist/lib/queue.d.ts +57 -5
  21. package/dist/lib/queue.js +313 -25
  22. package/dist/lib/routing.d.ts +1 -1
  23. package/dist/lib/routing.js +261 -29
  24. package/dist/lib/status.d.ts +1 -2
  25. package/dist/lib/status.js +6 -7
  26. package/dist/lib/turns.d.ts +4 -0
  27. package/dist/lib/turns.js +6 -0
  28. package/dist/lib/updates.d.ts +10 -6
  29. package/dist/lib/updates.js +20 -4
  30. package/dist/package.json +1 -1
  31. package/docs/README.md +1 -1
  32. package/docs/architecture.md +17 -14
  33. package/docs/callback-namespaces.md +4 -0
  34. package/docs/multi-instance-bus.md +4 -2
  35. package/docs/ui-style.md +14 -10
  36. package/lib/bindings.ts +52 -3
  37. package/lib/bus-follower.ts +5 -0
  38. package/lib/bus-leader.ts +9 -0
  39. package/lib/bus.ts +20 -3
  40. package/lib/extension.ts +14 -1
  41. package/lib/lifecycle.ts +34 -28
  42. package/lib/menu-queue.ts +116 -162
  43. package/lib/pi.ts +8 -0
  44. package/lib/polling.ts +3 -1
  45. package/lib/queue.ts +328 -33
  46. package/lib/routing.ts +299 -29
  47. package/lib/status.ts +7 -7
  48. package/lib/turns.ts +7 -0
  49. package/lib/updates.ts +24 -10
  50. package/package.json +1 -1
package/AGENTS.md CHANGED
@@ -27,7 +27,7 @@ Canonical terms:
27
27
 
28
28
  Keep each fact in one authoritative layer:
29
29
 
30
- - [`README.md`](./README.md): Public product entrypoint. Preserve the flow identity → install/connect → examples → product model → compact capabilities → controls/safety → docs. Balance strong positioning with a practical catalogue of capabilities, operator gestures and meaningful consequences; keep detailed routing proofs, recovery algorithms and action-markup grammar in indexed `/docs` instead of accumulating release-by-release paragraphs.
30
+ - [`README.md`](./README.md): Public product entrypoint. Preserve the flow identity → install/connect → examples → product model → compact capabilities → controls/safety → docs. Describe current behavior, not version-by-version changes or release progress; compatibility requirements belong here only when needed for installation or safe operation, while historical version details belong in CHANGELOG or indexed technical docs. Balance strong positioning with a practical catalogue of capabilities, operator gestures and meaningful consequences; keep detailed routing proofs, recovery algorithms and action-markup grammar in indexed `/docs` instead of accumulating release-by-release paragraphs.
31
31
  - `AGENTS.md`: Stable engineering boundaries, recurring runtime invariants, and work protocol. Link to evolving subsystem contracts instead of copying them here.
32
32
  - [`BACKLOG.md`](./BACKLOG.md): Canonical unresolved work. Keep only open top-level outcomes with nested decomposition and done criteria. Remove completed outcomes rather than retaining checked history.
33
33
  - [`CHANGELOG.md`](./CHANGELOG.md): Completed user/operator/developer impact. A release has at most eight outcome bullets of at most 512 characters, each beginning with an inline-code domain label and colon. Exclude personal names and real user/chat/message/thread identifiers. Consolidate the current pre-release section before release; do not rewrite historical sections without an explicit retrospective request and evidence pass.
@@ -99,7 +99,8 @@ 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. 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 `+N` suffix is a yellow count of executable prompts still waiting, excludes the dispatched head immediately, and never counts current agent work from any source. Each prompt is one object with one active lane and no reserved return slot. Normal and Priority are separate FIFO lanes: crossing lanes removes it from the source and appends it at the destination tail, while Keep/Skip and same-category emoji changes preserve lane position. For ordinary Normal/Priority prompts, complete reaction sets independently derive Priority from recognized positive emoji and Skip from recognized negative emoji; both 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. 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.
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.
103
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
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.
105
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.
package/BACKLOG.md CHANGED
@@ -2,4 +2,7 @@
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
- No open tasks.
5
+ - [ ] **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
+ - [ ] **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.
7
+ - [ ] **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
+ - [ ] **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,20 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.56.0: Steer from Telegram
8
+
9
+ - `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.
10
+ - `Steer`: Send corrections into a busy Pi run with a fire reaction or the queue menu. Ready whole prompts form FIFO packets at public model-step boundaries; idle Steer starts one ordinary task. Waiting input stays in Telegram's queue, not Pi's native steering/follow-up queues. Native pending input and compaction defer attempts; delivery is not a transactional model-use acknowledgement, and uncertain attempts never automatically replay.
11
+ - `Queue controls`: Normal/Priority/Steer and independent Keep/Skip share menu and reaction state. Fire dominates Priority reactions; changing lanes appends at the destination tail. Blue Normal, purple Priority and yellow Steer selectors accompany one yellow terminal waiting-work count. Dispatched/frozen prompts are read-only; a dispatched guest cannot be removed by stale Skip controls or lose its reply owner.
12
+ - `Queue transfer`: Negotiated `queue-steer-v1` support preserves unspent Steer prompts, ordering, attachments and whole receipts across live handoff. Donor, leader and recipient must all support it alongside base queue handoff; incompatible peers, attempted work and pending packets refuse transfer before custody changes.
13
+ - `Session lifetime`: Queue startup/shutdown and follower refresh preserve captured session generations across asynchronous preparation and teardown. Reused context objects cannot let an obsolete operation discard a successor queue, publish stale registration success or start its monitors. Failed durable disposal retains work without replay.
14
+ - `Validation scope`: Operator smoke tests confirmed native and Telegram Steer, ordered text batching, voice-derived input and Normal work after completion. Broader live idle/native-pending, media/transfer/Restore, interruption and native-Windows scenarios remain follow-up acceptance work, not verified release guarantees.
15
+
16
+ ## 0.55.1: Visible Reroute and follower cleanup
17
+
18
+ - `Reroute visibility`: Temporary Reroute forwards text, voice, files and albums into the selected Thread before local/follower admission, then anchors replies to confirmed copies. Source/recipient/generation fences reach HTTP and IPC write/ACK boundaries; one native grant prevents replay after partial/lost ACKs. Unknown cold All copies stay held with Cancel; real native-source recovery remains supported. Journal originals, receipt/group cleanup protections, Restore and cleanup timing are unchanged.
19
+ - `Reroute cleanup`: A follower ACK no longer strands a resolved Routing tab when recipient queue custody blocks its first cleanup check. Metadata-only rechecks and existing delivery-drain wakes wait for fresh source/receipt protection to clear, including retained completed membership after runtime replacement. New input, live bindings, authority loss and unverifiable evidence remain protective; issued or unknown deletion never repeats.
20
+
7
21
  ## 0.55.0: Native Restore and fenced lifecycles
8
22
 
9
23
  - `Restore cleanup`: Live Restore binds the new tab and decides old-tab cleanup before native leader/follower inputs run. Protected work defers cleanup; session replacement carries no deletion grant, and unknown deletion never repeats. Follower execution targets the rebound tab without rewriting journal originals or quoting the former tab. Exact donor ACKs seal routing clocks and retire the menu. Reader/execution capabilities, source custody and detached replies stay separate; busy hints coalesce.
package/README.md CHANGED
@@ -143,7 +143,7 @@ Enable the optional capabilities the bridge needs in the [@BotFather](https://t.
143
143
  | Generative Apps | Install or explicitly replace a reviewed `.mjs` application whose generated JSON button view may mix direct `app::method` actions with ordinary model prompts. | Repeated games, controls, tutors, and adapters compile routine interaction without losing selective model interpretation, explanation, or adaptation. |
144
144
  | Callback routing | Route known callbacks to the owner extension and unknown callbacks back into Pi. | Companion extensions can build UI without polling Telegram themselves. |
145
145
  | Threaded Mode | Run one leader plus visible follower Pi instances through named private-chat threads. | One bot can host a local multi-instance Pi organism without hidden process spawning. |
146
- | Reroute and restore | Give text, voice, files and albums sent from `All`, plus unknown threads, explicit reroute and replace/restore choices. | Forward removes the temporary tab; restore rebinds it and may remove only the replaced old tab after positive cleanup proofs. Blocked cleanup preserves the relocation and protected work. |
146
+ | Reroute and restore | Give text, voice, files and albums sent from `All`, plus unknown threads, explicit reroute and replace/restore choices. | Reroute from a routing tab forwards the prompt into the selected Thread before agent admission and anchors replies there; cleanup still requires source ACKs. Restore rebinds the tab and may remove only the replaced old tab after positive cleanup proofs. Blocked cleanup preserves the relocation and protected work. |
147
147
  | Extension sections | Add menu sections, commands, status rows, settings, callbacks, and delivery helpers from companion extensions. | `pi-telegram` becomes a platform surface for other Pi extensions. |
148
148
  | Runtime diagnostics | Use `/telegram-status` and recent runtime events for connection, role, negotiated bus protocol/build/capabilities, separate polling and inbound-worker progress, journal depth, local/foreign queue ownership, automatic retry waits, transport, and failures. | Compatible build skew, foreign semantic authority, a healthy poller, durable backoff and an infrastructure-blocked worker remain distinguishable without hidden logs. |
149
149
  | Safety and ownership | Pair one owner, lock transport, scope targets, and reject fake terminal behavior. | Remote access remains explicit, bounded, and understandable. |
@@ -206,24 +206,33 @@ In Threaded Mode, `All` is input-only: messages enter temporary routing tabs, an
206
206
 
207
207
  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
208
 
209
- Queue policy:
209
+ Use reactions or the queue menu to control waiting prompts.
210
+
211
+ Priority — when the prompt runs:
212
+ - **Normal — no dedicated reaction emoji.** The default lane for ordinary queued work.
213
+ - **Priority — `👍`.** Moves waiting input ahead of Normal work, without interrupting the active task.
214
+ - **Steer — `🔥`.** Inserts ready input at a model-step boundary, or starts one ordinary task when idle. Fire takes precedence over Priority reactions; removing it falls back to remaining Priority intent or Normal.
215
+
216
+ Disposition — whether the prompt runs:
217
+ - **Keep — no dedicated reaction emoji.** The default disposition: the prompt remains eligible to run in its selected lane.
218
+ - **Skip — `👎`.** Suppresses the prompt without changing its lane or position. Priority and Skip can coexist—for example `👍 + 👎`; Skip wins at dispatch.
210
219
 
211
- - One prompt is one queue object with exactly one current lane and one current position; it never reserves a shadow place in the other lane. The terminal's yellow `+N` suffix counts only executable prompts still waiting, never the current run; a dispatched Telegram prompt stops contributing before that run settles.
212
- - Priority and Normal are separate FIFO lanes; Priority dispatches first.
213
- - Moving `Normal → Priority` removes the prompt from Normal and places it at the Priority tail. Moving `Priority → Normal` removes it from Priority and places it at the Normal tail; no former position is restored.
214
- - Keep/Skip never changes lane position. Skip preserves durable authority while waiting so Keep remains reversible, then settles that authority and drops the prompt without a model turn when dispatch reaches it. Skipped prompts stay visible at their physical queue position with a struck-through ordinal, but are excluded immediately from the executable queue count shown in both the Pi status bar and Telegram main menu. Graceful session shutdown discards all remaining queued authority, so a new session starts empty.
215
- - For ordinary prompts, reactions control two independent dimensions; changing one category preserves the other:
216
- - `Positive`: `👍`, `⚡️`, `❤️`, `🕊`, `🔥` — controls Priority.
217
- - `Negative`: `👎`, `👻`, `💔`, `💩`, `🗑` — controls Skip.
218
- - Priority and Skip can coexist—for example `👍 + 💩`. Skip wins at dispatch, regardless of which negative emoji is selected.
219
- - Menu selectors and reactions share queue state, but the bot cannot remove a user's reaction; Keep may clear internal Skip while the user's emoji remains visible until they remove it.
220
- - A negative reaction to your original standalone `/continue` immediately cancels its exact waiting continuation, even while other work is active. This is irreversible cancellation, not reversible Skip. Positive reactions do not change its control lane; already-dispatched or running work and synthetic model-switch continuations are untouched.
220
+ Queued commands and control actions use a separate **Control** lane ahead of Steer, Priority and Normal. They are marked with `⚡️`; this is a label, not a reaction shortcut.
221
+
222
+ Queue policy:
223
+ - Changing a prompt's lane moves it to the end of that lane; switching back does not restore its former position.
224
+ - Keep/Skip preserves position. Skip stays reversible until the prompt reaches dispatch, then drops it without a model turn. Skipped prompts remain visible with a struck-through number.
225
+ - One yellow terminal counter shows waiting work across all lanes, excluding current or already-dispatched work, Skip and frozen attempts.
226
+ - Dispatched or frozen prompts are inspection-only. An uncertain Steer attempt is not automatically retried.
227
+ - 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.
229
+ - The queue is session-local. Let it drain before reload or shutdown, or accept losing waiting work.
221
230
 
222
- The detailed contract lives in [Priority, Reactions, Keep, and Skip](./docs/architecture.md#priority-reactions-keep-and-skip). If Pi automatically retries a transient provider failure, the active Telegram turn stays bound until the successful reply arrives or Pi confirms that the run has settled.
231
+ See [Queue Behavior and Safety](./docs/architecture.md#priority-reactions-keep-and-skip) for reaction precedence, dispatch rules and recovery details.
223
232
 
224
233
  ### Native Rich Markdown
225
234
 
226
- Rich Markdown is the default model-answer membrane. Complete assistant and guest model replies use Telegram's native Rich Message APIs; valid bot commands and URLs retain Telegram's native clickable affordances. Activity thinking uses persistent headerless expandable HTML, while each completed tool uses one iconless native Rich root details node whose arguments open with the root while secondary JSON evidence stays collapsed; `thinking`, `tools`, and `verbose` select the visible classes, while menus, status rows, queue controls, settings, diagnostics, and other operational UI retain explicit Telegram HTML/plain rendering. Three Settings controls keep the layers separate: `Draft previews` toggles streamed answer drafts, `Activity` chooses `quiet` or `verbose` technical activity, and `Assistant rendering` chooses final-answer delivery (`rich` Native Rich Markdown or `html` legacy Markdown-to-HTML).
235
+ Answers use Telegram-native Rich Markdown, with clickable commands and links. In Settings, use **Draft previews** to control streaming previews, **Activity** to choose the amount of thinking and tool detail, and **Assistant rendering** to switch between Rich Markdown and HTML compatibility mode. See [Rendering and Delivery](./docs/architecture.md#rendering-and-delivery) and [Activity](./docs/activity.md).
227
236
 
228
237
  ### Files And Artifacts
229
238
 
@@ -250,14 +259,14 @@ Classic private DM mode is the base product mode. When Telegram private-chat Thr
250
259
  - When all `A`–`Z` slots are reserved, a new connection may reclaim the oldest inactive, unprotected Thread. **This deletes that Thread and all its messages**, not Pi session history or project files. Active or protected work blocks reclamation; restoring a remembered session does not evict another binding.
251
260
  - Telegram never launches hidden Pi processes.
252
261
 
253
- In Threaded Mode, open Settings → **🧵 Thread display** to choose `letters` (default), `names`, `directory-title`, or `directory-snake` for this bot profile. Unsupported retained values, including the retired `directories` mode, fall back to `letters` without rewriting the saved configuration. Fresh tabs are created with the active mode's title instead of being visibly renamed afterward. Telegram tab titles, Pi terminal status, live Thread choosers/notices, prompt attribution, and named `telegram_message` targeting use the same acknowledged display name; target IDs and live registrations still own routing. Names shows the generated dictionary name chosen for the slot, such as `Anchor` for slot `A`; the directory formats produce `api_tools` or `Api Tools` and append `_a` or ` A` only while two or more authenticated live instances share that exact directory. On Linux/macOS, the current user's exact home directory displays as `home` or `Home`; subdirectories keep their own names, and Windows remains unchanged. Dormant retained bindings do not keep those suffixes visible. After leader election, automatic title contraction waits one follower-staleness window for live registrations to settle, avoiding a temporary suffix removal and restoration during startup. `/name` sets a manual Thread display name; **Reset to automatic** restores the selected automatic projection. Switching preserves Thread IDs, slots, generated recovery identity, and queue ownership. Partial application reports an error and can be retried without recreating Threads.
262
+ In Settings → **🧵 Thread display**, choose Letters (default), Names, Directory Title or Directory Snake. Use `/name` for a custom Thread name and **Reset to automatic** to restore the selected naming style. Renaming preserves the Thread and its queued work. See [Thread Display](./docs/architecture.md#threaded-mode-multi-instance-bus) for naming details.
254
263
 
255
264
  | Mode | Best for | Runtime shape |
256
265
  | --- | --- | --- |
257
266
  | Classic DM | One running Pi instance and its active session controlled from one private bot chat | One polling owner, one queue/runtime surface |
258
267
  | Threaded Mode | Several visible Pi sessions sharing one bot | One leader owns transport; each private-chat Thread retains one session-qualified Workspace binding |
259
268
 
260
- **Live rebinding:** Restore confirms the new binding, makes one old-tab cleanup decision, then releases the saved input through its native prompt, command or template owner. Negotiated followers use the same order without another queue. Earlier protected work defers cleanup; `/new` cannot carry an old-session deletion grant into its replacement. An unknown delete never repeats or blocks ordinary input on the confirmed new tab. Exact source/receipt custody remains separate from replies and model completion. Upgrade participating readers for the new metadata; older peers retain their supported legacy path. See [Multi-Instance Bus](./docs/multi-instance-bus.md#live-rebind-channel).
269
+ **Restore** binds the selected live Pi session to the current Thread and releases the waiting input there. It may delete the session's former Thread and its messages; unfinished work can defer cleanup. An unconfirmed deletion is not automatically retried. See [Restore Behavior and Safety](./docs/multi-instance-bus.md#live-rebind-channel).
261
270
 
262
271
  ## Environment Configuration
263
272
 
@@ -292,9 +301,9 @@ Stable public entrypoints are documented in [Public API](./docs/public-api.md),
292
301
 
293
302
  ## Safety Boundaries
294
303
 
295
- Durable inbound admission is a **process-crash recovery** guarantee. Atomic private-file replacement preserves acknowledged journal authority and its journal-owned `acceptedThroughUpdateId` polling cursor across ordinary process exit, crash, kill, and replacement, but the extension does not flush files or parent directories for host/kernel/filesystem/device/power-loss durability. `telegram.json` contains configuration only. Keep `~/.pi/agent` on appropriately managed storage and backups if that stronger operational guarantee is required. Downgrading below `0.37.0` is unsafe: an older runtime cannot recover the journal's polling cursor and could repoll admitted updates. See [Durable Admission And Recovery](./docs/architecture.md#durable-admission-and-recovery).
304
+ Inbound recovery protects against **process crashes**, not power loss or storage failure. Keep `~/.pi/agent` on reliable storage with appropriate backups. Do not downgrade to a runtime that cannot read the current journal format: it could process already admitted input again. See [Durable Admission and Recovery](./docs/architecture.md#durable-admission-and-recovery).
296
305
 
297
- Runtime files use `tmp/pi-telegram` with only `state.json` and `logs.jsonl` at its root; pre-0.52.0 `tmp/telegram` is left untouched and never migrated. The transport section of `state.json` names a polling journal hosted in a session folder; successor leaders continue it rather than moving its cursor. Follower journals are session-owned. Unbound session families are disposable under the approved sweep policy; in-process `/new` adopts eligible pending input, while cold startup does not. See [Session-Owned Journal Storage](./docs/multi-instance-bus.md#session-owned-journal-storage) for compatibility fallbacks, loss boundaries and pending acceptance.
306
+ Configuration lives in `telegram.json`; runtime data lives under `<agent-dir>/tmp/pi-telegram`. Pending-input retention and cleanup rules are documented in [Session-Owned Journal Storage](./docs/multi-instance-bus.md#session-owned-journal-storage).
298
307
 
299
308
  `pi-telegram` intentionally does not:
300
309
 
@@ -57,17 +57,18 @@ export interface TelegramQueueBindingRuntime<TContext> {
57
57
  requestNextDispatchAnnouncement: () => void;
58
58
  cancelNextDispatchAnnouncement: () => void;
59
59
  watchdog: Queue.TelegramQueueDispatchWatchdogRuntime<TContext>;
60
+ onTurnEnd: (event: Pick<Pi.TurnEndEvent, "turnIndex" | "outcome">, ctx: TContext) => Pi.TurnEndEventResult | undefined;
60
61
  }
61
62
  export declare function createTelegramQueueBindingRuntime<TContext>(deps: {
62
63
  store: Queue.TelegramQueueStateStore<TContext>;
63
64
  queue: Pick<Runtime.TelegramBridgeRuntime["queue"], "allocateItemOrder">;
64
- lifecycle: Pick<Runtime.TelegramBridgeRuntime["lifecycle"], "isCompactionInProgress" | "hasDispatchPending">;
65
- activeTurn: Pick<Queue.TelegramActiveTurnStore, "has">;
65
+ lifecycle: Pick<Runtime.TelegramBridgeRuntime["lifecycle"], "isCompactionInProgress" | "hasDispatchPending"> & Partial<Pick<Runtime.TelegramBridgeRuntime["lifecycle"], "getActiveToolExecutions">>;
66
+ activeTurn: Pick<Queue.TelegramActiveTurnStore, "has"> & Partial<Pick<Queue.TelegramActiveTurnStore, "get">>;
66
67
  admission: {
67
68
  getSettlement: () => {
68
69
  onItemsDiscarded: (items: readonly Queue.TelegramQueueItem<TContext>[], ctx: TContext) => boolean;
69
70
  isItemReady: (item: Queue.TelegramQueueItem<TContext>) => boolean;
70
- onPromptHandedOff?: (item: Queue.PendingTelegramTurn, ctx: TContext) => boolean;
71
+ onPromptHandedOff?: (item: Pick<Queue.PendingTelegramTurn, "admissionReceipts">, ctx: TContext) => boolean;
71
72
  onControlSettled: (item: Queue.PendingTelegramControlItem<TContext>, ctx: TContext) => void;
72
73
  } | undefined;
73
74
  hasPendingQueueMutationForItem: (item: Queue.TelegramQueueItem<TContext>) => boolean;
@@ -77,6 +78,8 @@ export declare function createTelegramQueueBindingRuntime<TContext>(deps: {
77
78
  promptDispatch: Runtime.TelegramPromptDispatchRuntime<TContext>;
78
79
  isIdle: (ctx: TContext) => boolean;
79
80
  hasPendingMessages: (ctx: TContext) => boolean;
81
+ isContextCurrent?: (ctx: TContext) => boolean;
82
+ isContextAborted?: (ctx: TContext) => boolean;
80
83
  updateStatus: (ctx: TContext, error?: string) => void;
81
84
  sendTextReply: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendTextReply"];
82
85
  sendUserMessage: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendUserMessage"];
@@ -272,6 +275,7 @@ interface TelegramLifecycleBindingDeps {
272
275
  sendMarkdownReply: Queue.TelegramAgentEndHookRuntimeDeps<Queue.PendingTelegramTurn, Pi.ExtensionContext, Pi.AgentEndEvent["messages"][number], Keyboard.TelegramInlineKeyboardMarkup>["sendMarkdownReply"];
273
276
  sendTextReply: Queue.TelegramAgentEndHookRuntimeDeps<Queue.PendingTelegramTurn, Pi.ExtensionContext, Pi.AgentEndEvent["messages"][number], Keyboard.TelegramInlineKeyboardMarkup>["sendTextReply"] & NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendTextReply"]>;
274
277
  dispatchNextQueuedTelegramTurn: (ctx: Pi.ExtensionContext) => void;
278
+ onSteerTurnEnd?: TelegramQueueBindingRuntime<Pi.ExtensionContext>["onTurnEnd"];
275
279
  onPromptHandedOff?: (turn: Queue.PendingTelegramTurn, ctx: Pi.ExtensionContext) => void;
276
280
  answerGuestQuery: TelegramApi.TelegramBridgeApiRuntime["answerGuestQuery"];
277
281
  deleteMessage: TelegramApi.TelegramBridgeApiRuntime["deleteMessage"];
@@ -296,5 +300,5 @@ interface TelegramLifecycleBindingDeps {
296
300
  /** Settled-session hint for owners that wait on agent idleness, such as old-tab cleanup after a live rebind. */
297
301
  onSessionSettled?: (ctx: Pi.ExtensionContext) => void;
298
302
  }
299
- export declare function registerTelegramLifecycleRuntimeHooks({ pi, publicationRuntime, activityRuntime, activityVerbosityRuntime, diagnostics, assistantOutputRuntime, sessionLifecycleRuntime, configStore, abort, typing, lifecycle, activeTurnRuntime, telegramQueueStore, modelSwitchController, previewRuntime, promptDispatchRuntime, deferredQueueDispatchRuntime, modelContextAvailabilityRuntime, disconnectOnQuit, onSessionStarted, shutdownGenerativeAppLiveSurfaces, resolveAutomaticThreadCleanupEnabled, buttonActionStore, callMultipart, sendChatAction, sendRecordVoiceAction, sendMarkdownReply, sendTextReply, dispatchNextQueuedTelegramTurn, onPromptHandedOff, answerGuestQuery, deleteMessage, sendGuestReply, editGuestReply, stopGuestPlaceholder, preparePreviewDelivery, finalizeMarkdownPreview, proactivePushTargetGetter, getAssistantRenderingMode, recordMessageOwnership, canSendAgentActivity, isSessionContextActive, getSessionGeneration, isTurnTransportActive, updateStatus, recordRuntimeEvent, onSessionSettled, }: TelegramLifecycleBindingDeps): void;
303
+ export declare function registerTelegramLifecycleRuntimeHooks({ pi, publicationRuntime, activityRuntime, activityVerbosityRuntime, diagnostics, assistantOutputRuntime, sessionLifecycleRuntime, configStore, abort, typing, lifecycle, activeTurnRuntime, telegramQueueStore, modelSwitchController, previewRuntime, promptDispatchRuntime, deferredQueueDispatchRuntime, modelContextAvailabilityRuntime, disconnectOnQuit, onSessionStarted, shutdownGenerativeAppLiveSurfaces, resolveAutomaticThreadCleanupEnabled, buttonActionStore, callMultipart, sendChatAction, sendRecordVoiceAction, sendMarkdownReply, sendTextReply, dispatchNextQueuedTelegramTurn, onSteerTurnEnd, onPromptHandedOff, answerGuestQuery, deleteMessage, sendGuestReply, editGuestReply, stopGuestPlaceholder, preparePreviewDelivery, finalizeMarkdownPreview, proactivePushTargetGetter, getAssistantRenderingMode, recordMessageOwnership, canSendAgentActivity, isSessionContextActive, getSessionGeneration, isTurnTransportActive, updateStatus, recordRuntimeEvent, onSessionSettled, }: TelegramLifecycleBindingDeps): void;
300
304
  export {};
@@ -119,6 +119,10 @@ export function createTelegramQueueBindingRuntime(deps) {
119
119
  return false;
120
120
  return settlement.onItemsDiscarded(durableItems, ctx) === true;
121
121
  };
122
+ const settlePromptPacket = (items, ctx) => {
123
+ const receipts = items.flatMap(item => item.admissionReceipts ?? []);
124
+ return receipts.length === 0 || deps.admission.getSettlement()?.onPromptHandedOff?.({ admissionReceipts: receipts }, ctx) === true;
125
+ };
122
126
  const mutation = Queue.createTelegramQueueMutationController({
123
127
  ...deps.store,
124
128
  hasPendingDispatch: deps.lifecycle.hasDispatchPending,
@@ -159,6 +163,13 @@ export function createTelegramQueueBindingRuntime(deps) {
159
163
  return false;
160
164
  return settlement.onPromptHandedOff(item, ctx) === true;
161
165
  },
166
+ steerDispatch: {
167
+ commitPacket: settlePromptPacket,
168
+ isContextCurrent: (ctx) => deps.isContextCurrent?.(ctx) === true,
169
+ canSubmit: (ctx) => deps.isContextAborted?.(ctx) === false &&
170
+ deps.isIdle(ctx) && !deps.hasPendingMessages(ctx) && !deps.lifecycle.isCompactionInProgress() &&
171
+ !deps.activeTurn.has() && (deps.lifecycle.getActiveToolExecutions?.() ?? 0) === 0,
172
+ },
162
173
  onControlSettled(item, ctx) {
163
174
  deps.admission.getSettlement()?.onControlSettled(item, ctx);
164
175
  },
@@ -172,8 +183,31 @@ export function createTelegramQueueBindingRuntime(deps) {
172
183
  ...deps.promptDispatch,
173
184
  sendUserMessage: deps.sendUserMessage,
174
185
  });
186
+ const steer = Queue.createTelegramSteerBoundaryRuntime({
187
+ ...deps.store,
188
+ getGeneration: deps.deferredDispatch.getGeneration,
189
+ isContextCurrent: (ctx) => deps.isContextCurrent?.(ctx) === true,
190
+ isBlocked: (ctx) => deps.isContextAborted?.(ctx) !== false || !deps.deferredDispatch.isBound() ||
191
+ deps.isIdle(ctx) || deps.hasPendingMessages(ctx) ||
192
+ deps.lifecycle.isCompactionInProgress() || deps.lifecycle.hasDispatchPending() ||
193
+ (deps.lifecycle.getActiveToolExecutions?.() ?? 0) > 0 ||
194
+ (deps.activeTurn.has() && !deps.activeTurn.get?.()),
195
+ isItemReady(item) {
196
+ return deps.transportStamp.isActive(item.transportStamp) &&
197
+ !deps.admission.hasPendingQueueMutationForItem(item) &&
198
+ (deps.admission.getSettlement()?.isItemReady(item) ?? (item.admissionReceipts?.length ?? 0) === 0);
199
+ },
200
+ isItemCurrent: (item) => deps.transportStamp.isActive(item.transportStamp) &&
201
+ !deps.admission.hasPendingQueueMutationForItem(item),
202
+ getActiveTurn: () => deps.activeTurn.get?.(),
203
+ commitPacket: settlePromptPacket,
204
+ discardItem: (item, ctx) => settleDiscardedItems([item], ctx),
205
+ updateStatus: deps.updateStatus,
206
+ recordRuntimeEvent: deps.recordRuntimeEvent,
207
+ });
175
208
  return {
176
209
  mutation,
210
+ onTurnEnd: steer.onTurnEnd,
177
211
  dispatchNext: dispatch.dispatchNext,
178
212
  requestNextDispatchAnnouncement: dispatch.requestNextDispatchAnnouncement,
179
213
  cancelNextDispatchAnnouncement: dispatch.cancelNextDispatchAnnouncement,
@@ -627,7 +661,7 @@ export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, pe
627
661
  },
628
662
  });
629
663
  }
630
- export function registerTelegramLifecycleRuntimeHooks({ pi, publicationRuntime, activityRuntime, activityVerbosityRuntime, diagnostics, assistantOutputRuntime, sessionLifecycleRuntime, configStore, abort, typing, lifecycle, activeTurnRuntime, telegramQueueStore, modelSwitchController, previewRuntime, promptDispatchRuntime, deferredQueueDispatchRuntime, modelContextAvailabilityRuntime, disconnectOnQuit, onSessionStarted, shutdownGenerativeAppLiveSurfaces, resolveAutomaticThreadCleanupEnabled, buttonActionStore, callMultipart, sendChatAction, sendRecordVoiceAction, sendMarkdownReply, sendTextReply, dispatchNextQueuedTelegramTurn, onPromptHandedOff, answerGuestQuery, deleteMessage, sendGuestReply, editGuestReply, stopGuestPlaceholder, preparePreviewDelivery, finalizeMarkdownPreview, proactivePushTargetGetter, getAssistantRenderingMode, recordMessageOwnership, canSendAgentActivity, isSessionContextActive = () => true, getSessionGeneration, isTurnTransportActive, updateStatus, recordRuntimeEvent, onSessionSettled, }) {
664
+ export function registerTelegramLifecycleRuntimeHooks({ pi, publicationRuntime, activityRuntime, activityVerbosityRuntime, diagnostics, assistantOutputRuntime, sessionLifecycleRuntime, configStore, abort, typing, lifecycle, activeTurnRuntime, telegramQueueStore, modelSwitchController, previewRuntime, promptDispatchRuntime, deferredQueueDispatchRuntime, modelContextAvailabilityRuntime, disconnectOnQuit, onSessionStarted, shutdownGenerativeAppLiveSurfaces, resolveAutomaticThreadCleanupEnabled, buttonActionStore, callMultipart, sendChatAction, sendRecordVoiceAction, sendMarkdownReply, sendTextReply, dispatchNextQueuedTelegramTurn, onSteerTurnEnd, onPromptHandedOff, answerGuestQuery, deleteMessage, sendGuestReply, editGuestReply, stopGuestPlaceholder, preparePreviewDelivery, finalizeMarkdownPreview, proactivePushTargetGetter, getAssistantRenderingMode, recordMessageOwnership, canSendAgentActivity, isSessionContextActive = () => true, getSessionGeneration, isTurnTransportActive, updateStatus, recordRuntimeEvent, onSessionSettled, }) {
631
665
  const agentEndResetter = Runtime.createTelegramAgentEndResetter({
632
666
  abort,
633
667
  typing,
@@ -877,6 +911,12 @@ export function registerTelegramLifecycleRuntimeHooks({ pi, publicationRuntime,
877
911
  isSessionActive: isSessionContextActive,
878
912
  ...sessionLifecycleRuntime,
879
913
  ...agentLifecycleHooks,
914
+ ...(onSteerTurnEnd ? {
915
+ onTurnEnd(event, ctx) {
916
+ const result = onSteerTurnEnd(event, ctx);
917
+ return result ? { ...result, entries: [...event.entries, ...(result.entries ?? [])] } : undefined;
918
+ },
919
+ } : {}),
880
920
  onInput(event) {
881
921
  activityRuntime.recordInputSource(event.source ?? "unknown");
882
922
  },
@@ -388,6 +388,7 @@ export declare function createTelegramBusFollowerClientRuntime<TContext, TReacti
388
388
  message: TMessage;
389
389
  ownership: TelegramBusForwardOwnership;
390
390
  ctx: TContext;
391
+ assertAuthority?: () => void;
391
392
  }) => Promise<import("./bus.ts").TelegramBusForeignUpdateSettlement>;
392
393
  forwardEditedMessage: (input: {
393
394
  message: TMessage;
@@ -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, 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_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";
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";
@@ -3033,6 +3033,10 @@ export function createTelegramBusForwardedUpdateReceiverRuntime(deps) {
3033
3033
  result: receipt,
3034
3034
  };
3035
3035
  }
3036
+ if (envelope.payload.queueLane === "steer" &&
3037
+ ![deps.getLocalProtocol?.(), deps.getLeaderProtocol?.()].every(hasTelegramBusSteerQueueHandoffCapability)) {
3038
+ throw new Error("Steer queue handoff capability was not negotiated.");
3039
+ }
3036
3040
  if (!deps.handleQueueHandoff) {
3037
3041
  throw new Error("Telegram bus receiver cannot accept queue handoff payloads.");
3038
3042
  }
@@ -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, 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, 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";
@@ -2120,6 +2120,10 @@ export function createTelegramBusLeaderEnvelopeHandler(deps) {
2120
2120
  !hasTelegramBusCapability(recipient?.protocol, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF)) {
2121
2121
  return rejectTelegramBusRequest(envelope.requestId, "Telegram queue handoff capability was not negotiated.");
2122
2122
  }
2123
+ if (envelope.payload.queueLane === "steer" &&
2124
+ ![deps.protocolIdentity, donor.protocol, recipient?.protocol].every(hasTelegramBusSteerQueueHandoffCapability)) {
2125
+ return rejectTelegramBusRequest(envelope.requestId, "Steer queue handoff capability was not negotiated.");
2126
+ }
2123
2127
  if (!recipient?.registrationGeneration ||
2124
2128
  envelope.recipientRegistrationGeneration !==
2125
2129
  recipient.registrationGeneration) {
@@ -3026,6 +3030,10 @@ export function createTelegramBusLeaderRuntime(deps) {
3026
3030
  !hasTelegramBusCapability(recipient?.protocol, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF)) {
3027
3031
  return rejectTelegramBusRequest(input.requestId, "Telegram queue handoff capability was not negotiated.");
3028
3032
  }
3033
+ if (input.payload.queueLane === "steer" &&
3034
+ ![deps.protocolIdentity, recipient?.protocol].every(hasTelegramBusSteerQueueHandoffCapability)) {
3035
+ return rejectTelegramBusRequest(input.requestId, "Steer queue handoff capability was not negotiated.");
3036
+ }
3029
3037
  if (!recipient?.registrationGeneration ||
3030
3038
  input.recipientRegistrationGeneration !== recipient.registrationGeneration) {
3031
3039
  return rejectTelegramBusRequest(input.requestId, "Stale Telegram queue handoff recipient registration generation.");
package/dist/lib/bus.d.ts CHANGED
@@ -36,6 +36,8 @@ export declare function createTelegramBusProcessRuntime(input: {
36
36
  export declare function createTelegramBusAuthSecret(): string;
37
37
  export declare const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION: "durable-follower-admission-v1";
38
38
  export declare const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF: "queue-handoff-v1";
39
+ /** Unspent Steer lane codec; independent from baseline receipt handoff support. */
40
+ export declare const TELEGRAM_BUS_CAPABILITY_QUEUE_STEER: "queue-steer-v1";
39
41
  export declare const TELEGRAM_BUS_CAPABILITY_INPUT_CUSTODY_REFERENCE: "input-custody-reference-v1";
40
42
  export declare const TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME: "workspace-thread-rename-v1";
41
43
  export declare const TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE: "thread-display-mode-v1";
@@ -192,6 +194,7 @@ export declare function createTelegramBusProtocolIdentity(input: {
192
194
  }): TelegramBusProtocolIdentity;
193
195
  export declare function createTelegramCurrentBusProtocolIdentity(capabilities?: readonly string[]): TelegramBusProtocolIdentity;
194
196
  export declare function hasTelegramBusCapability(identity: TelegramBusProtocolIdentity | undefined, capability: string): boolean;
197
+ export declare function hasTelegramBusSteerQueueHandoffCapability(identity: TelegramBusProtocolIdentity | undefined): boolean;
195
198
  export declare function getTelegramInputCustodyPeerReadiness(followers: readonly Pick<TelegramBusFollowerView, "registrationGeneration" | "protocol">[]): ("ready" | "legacy" | "unknown")[];
196
199
  /** Both peers advertise every required capability over a compatible protocol; reads only the two identities. */
197
200
  export declare function hasTelegramBusSharedCapabilities(local: TelegramBusProtocolIdentity, remote: TelegramBusProtocolIdentity | undefined, required: readonly string[]): boolean;
@@ -670,6 +673,8 @@ export declare function createTelegramBusForeignOwnedUpdateForwarder<TContext, T
670
673
  message: TMessage;
671
674
  ownership: TelegramBusForwardOwnership;
672
675
  ctx: TContext;
676
+ /** Process-local source/recipient lifetime; never part of the wire envelope. */
677
+ assertAuthority?: () => void;
673
678
  }) => Promise<TelegramBusForeignUpdateSettlement>;
674
679
  forwardEditedMessage: (input: {
675
680
  message: TMessage;
package/dist/lib/bus.js CHANGED
@@ -50,6 +50,8 @@ export function createTelegramBusAuthSecret() {
50
50
  const TELEGRAM_BUS_PROTOCOL_VERSION = 3;
51
51
  export const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION = "durable-follower-admission-v1";
52
52
  export const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF = "queue-handoff-v1";
53
+ /** Unspent Steer lane codec; independent from baseline receipt handoff support. */
54
+ export const TELEGRAM_BUS_CAPABILITY_QUEUE_STEER = "queue-steer-v1";
53
55
  export const TELEGRAM_BUS_CAPABILITY_INPUT_CUSTODY_REFERENCE = "input-custody-reference-v1";
54
56
  export const TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME = "workspace-thread-rename-v1";
55
57
  export const TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE = "thread-display-mode-v1";
@@ -173,6 +175,10 @@ export function createTelegramCurrentBusProtocolIdentity(capabilities = []) {
173
175
  export function hasTelegramBusCapability(identity, capability) {
174
176
  return identity?.capabilities.includes(capability) ?? false;
175
177
  }
178
+ export function hasTelegramBusSteerQueueHandoffCapability(identity) {
179
+ return hasTelegramBusCapability(identity, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF) &&
180
+ hasTelegramBusCapability(identity, TELEGRAM_BUS_CAPABILITY_QUEUE_STEER);
181
+ }
176
182
  export function getTelegramInputCustodyPeerReadiness(followers) {
177
183
  return followers.map((follower) => {
178
184
  if (!follower.registrationGeneration || !follower.protocol)
@@ -971,7 +977,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
971
977
  sourceReference,
972
978
  };
973
979
  };
974
- const send = async (envelope, ownership) => {
980
+ const send = async (envelope, ownership, assertAuthority) => {
975
981
  if (deps.validateForwardOwnership &&
976
982
  !deps.validateForwardOwnership(ownership))
977
983
  return reject({
@@ -991,10 +997,15 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
991
997
  socketPath,
992
998
  envelope,
993
999
  timeoutMs: deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CONTROL_TIMEOUT_MS,
994
- retry: getTelegramBusTransportRetryPolicy({
1000
+ retry: assertAuthority ? { attempts: 1, delayMs: 0 } : getTelegramBusTransportRetryPolicy({
995
1001
  endpoint: socketPath,
996
1002
  operation: "operation",
997
1003
  }),
1004
+ ...(assertAuthority || deps.validateForwardOwnership ? { assertAuthority() {
1005
+ assertAuthority?.();
1006
+ if (deps.validateForwardOwnership && !deps.validateForwardOwnership(ownership))
1007
+ throw new Error("Telegram follower forwarding ownership changed.");
1008
+ } } : {}),
998
1009
  });
999
1010
  }
1000
1011
  catch (error) {
@@ -1066,7 +1077,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
1066
1077
  return { status: "accepted", delivery: envelope.delivery };
1067
1078
  };
1068
1079
  // A recipient already holding the source reference is only woken; it never receives a second payload copy.
1069
- const forward = (kind, value, ownership, build) => {
1080
+ const forward = (kind, value, ownership, build, assertAuthority) => {
1070
1081
  const prepared = prepare(kind, value, ownership);
1071
1082
  if ("settlement" in prepared)
1072
1083
  return Promise.resolve(prepared.settlement);
@@ -1078,7 +1089,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
1078
1089
  };
1079
1090
  return send(prepared.sourceReference
1080
1091
  ? { kind: "leader.wakeInputCustody", ...base, sentAtMs: getNowMs() }
1081
- : build(base, getNowMs()), ownership);
1092
+ : build(base, getNowMs()), ownership, assertAuthority);
1082
1093
  };
1083
1094
  return {
1084
1095
  forwardCallback: ({ query, ownership }) => forward("leader.forwardCallback", query, ownership, (base, sentAtMs) => ({
@@ -1093,7 +1104,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
1093
1104
  reactionUpdate,
1094
1105
  sentAtMs,
1095
1106
  })),
1096
- forwardMessage: ({ message, ownership }) => forward("leader.forwardMessage", message, ownership, (base, sentAtMs) => {
1107
+ forwardMessage: ({ message, ownership, assertAuthority }) => forward("leader.forwardMessage", message, ownership, (base, sentAtMs) => {
1097
1108
  const forwardCommentBatchPosition = deps.getForwardCommentBatchPosition?.(message);
1098
1109
  return {
1099
1110
  kind: "leader.forwardMessage",
@@ -1104,7 +1115,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
1104
1115
  : {}),
1105
1116
  sentAtMs,
1106
1117
  };
1107
- }),
1118
+ }, assertAuthority),
1108
1119
  forwardEditedMessage: ({ message, ownership }) => forward("leader.forwardEditedMessage", message, ownership, (base, sentAtMs) => ({
1109
1120
  kind: "leader.forwardEditedMessage",
1110
1121
  ...base,
@@ -59,6 +59,7 @@ import * as WorkspaceRetirement from "./workspace-retirement.js";
59
59
  const telegramBusProtocolIdentity = Bus.createTelegramCurrentBusProtocolIdentity([
60
60
  Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION,
61
61
  Bus.TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF,
62
+ Bus.TELEGRAM_BUS_CAPABILITY_QUEUE_STEER,
62
63
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT,
63
64
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
64
65
  Bus.TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
@@ -557,7 +558,6 @@ export default function (pi) {
557
558
  },
558
559
  getQueuedItems: telegramQueueStore.getQueuedItems,
559
560
  getQueuedItemCount: Queue.countExecutableTelegramQueueItems,
560
- formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
561
561
  getRecentRuntimeEvents: runtimeEvents.getEvents,
562
562
  getRuntimeLockState: lockRuntime.getStatusLabel,
563
563
  ...threadStatusProjectionRuntime,
@@ -796,7 +796,7 @@ export default function (pi) {
796
796
  editMessageText: editTelegramMessageText,
797
797
  },
798
798
  });
799
- const { mutation: queueMutationRuntime, dispatchNext: dispatchNextQueuedTelegramTurn, requestNextDispatchAnnouncement, cancelNextDispatchAnnouncement, watchdog: queueDispatchWatchdogRuntime, } = Bindings.createTelegramQueueBindingRuntime({
799
+ const { mutation: queueMutationRuntime, dispatchNext: dispatchNextQueuedTelegramTurn, requestNextDispatchAnnouncement, cancelNextDispatchAnnouncement, watchdog: queueDispatchWatchdogRuntime, onTurnEnd: onSteerTurnEnd, } = Bindings.createTelegramQueueBindingRuntime({
800
800
  store: telegramQueueStore,
801
801
  queue,
802
802
  lifecycle,
@@ -807,6 +807,8 @@ export default function (pi) {
807
807
  promptDispatch: promptDispatchRuntime,
808
808
  isIdle,
809
809
  hasPendingMessages,
810
+ isContextCurrent: telegramSessionContextStore.isCurrent,
811
+ isContextAborted: Pi.isExtensionContextAbortRequested,
810
812
  updateStatus,
811
813
  sendTextReply,
812
814
  sendUserMessage,
@@ -881,6 +883,7 @@ export default function (pi) {
881
883
  });
882
884
  // --- Queue And Settings Menus ---
883
885
  const queueMenuRuntime = MenuQueue.createTelegramQueueMenuRuntime({
886
+ hasPendingDispatch: lifecycle.hasDispatchPending,
884
887
  telegramQueueStore,
885
888
  queueMutationRuntime,
886
889
  sendInteractiveMessage,
@@ -1858,6 +1861,14 @@ export default function (pi) {
1858
1861
  recordRuntimeEvent,
1859
1862
  });
1860
1863
  queueHandoffReconciliationBinding.set(Updates.createTelegramQueueHandoffReconciliationRuntimeAssembly({
1864
+ hasPendingDispatch: lifecycle.hasDispatchPending,
1865
+ canHandoffSteerWithRecipient(recipient) {
1866
+ const live = telegramBusFollowerRegistry.get(recipient.instanceId);
1867
+ return live?.registrationGeneration === recipient.registrationGeneration &&
1868
+ [telegramBusProtocolIdentity, live?.protocol,
1869
+ ...(telegramBusFollowerRegistrationState.isRegistered() ? [telegramBusFollowerRegistrationState.getLeaderProtocol()] : [])]
1870
+ .every(Bus.hasTelegramBusSteerQueueHandoffCapability);
1871
+ },
1861
1872
  ownsDirect: lockRuntime.owns,
1862
1873
  isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
1863
1874
  isBusEnabled: isTelegramBusRuntimeEnabled,
@@ -2461,6 +2472,7 @@ export default function (pi) {
2461
2472
  sendMarkdownReply,
2462
2473
  sendTextReply,
2463
2474
  dispatchNextQueuedTelegramTurn,
2475
+ onSteerTurnEnd,
2464
2476
  onPromptHandedOff(turn, ctx) {
2465
2477
  updateAdmissionRuntimeBinding
2466
2478
  .getSettlement()
@@ -5,7 +5,7 @@
5
5
  * Transport authority, durable bindings, and queue custody remain with their owners.
6
6
  */
7
7
  import * as BusFollower from "./bus-follower.ts";
8
- import type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionCompactFailedEvent, SessionShutdownEvent, SessionStartEvent, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolExecutionUpdateEvent, UIPromptEndEvent, UIPromptStartEvent } from "./pi.ts";
8
+ import type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AssistantMessageEvent, BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, InputEvent, MessageEndEvent, SessionBeforeCompactEvent, SessionCompactEvent, SessionCompactFailedEvent, SessionShutdownEvent, SessionStartEvent, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolExecutionUpdateEvent, TurnEndEvent, TurnEndEventResult, UIPromptEndEvent, UIPromptStartEvent } from "./pi.ts";
9
9
  import * as Machine from "./machine.ts";
10
10
  import * as Queue from "./queue.ts";
11
11
  export declare function createAgentStartDedupHook(inner: (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>, resetReplyDedup: () => void, schedulePublication?: (task: () => Promise<void>) => void): (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>;
@@ -30,6 +30,7 @@ export interface TelegramLifecycleRegistrationDeps {
30
30
  onModelSelect: (event: {
31
31
  model: TelegramLifecycleModel;
32
32
  }, ctx: ExtensionContext) => Promise<void> | void;
33
+ onTurnEnd?: (event: TurnEndEvent, ctx: ExtensionContext) => TurnEndEventResult | undefined;
33
34
  onAgentStart: (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>;
34
35
  onToolExecutionStart: (event: ToolExecutionStartEvent, ctx: ExtensionContext) => Promise<void> | void;
35
36
  onToolExecutionUpdate?: (event: ToolExecutionUpdateEvent, ctx: ExtensionContext) => Promise<void> | void;