@llblab/pi-telegram 0.38.0 → 0.39.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.
package/AGENTS.md CHANGED
@@ -80,14 +80,14 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
80
80
  - Foreign forwarding settles as `accepted`, `retryable`, or `terminal-rejected`. Only an authenticated acknowledgement carrying the expected `deliveryId` and `sourceUpdateId` releases leader journal authority. Negative, missing, stale, mismatched, or capacity-failed settlement remains durable; callback error answers are side effects only.
81
81
  - A forwarding delivery id is stable across registration replacement and derives from envelope kind, source `update_id`, and stable recipient binding. Runtime instance and registration generation remain separate attempt fences. Persisted message ownership carries the stable binding so replay can rebind only to its current authenticated registration.
82
82
  - A queued receipt persists its acquiring runtime instance, OS pid/process-birth identity, session generation, acquisition id, and acquisition time. Only exact authority may settle or discard it. Same-process session replacement may reconstruct the claim and the original process may settle after transport ownership moves; a foreign process may neither replay nor settle it through generic removal or a copied acquisition id.
83
- - Startup and elapsed time are not owner-death proof; queued authority has no time lease. Dead-owner recovery groups the complete receipt and transactionally rechecks pid liveness plus process-birth identity: only an absent PID or mismatched stable Linux/macOS birth proof returns all sources to `pending`; a matching proof is `alive`, while Windows or inaccessible birth metadata is `unverifiable`, and both non-dead outcomes keep authority queued. The live-transfer contract is authenticated offer → exact-generation bounded payload staging → recipient CAS acceptance → exact receipt-and-owner ACK → donor removal → recipient readiness. The offer freezes donor settlement/recovery; controls rebuild local closures; negative/mismatched pre-acceptance ACK cancels only an unaccepted offer and retains donor work; a lost post-acceptance ACK cannot cancel recipient authority and leaves donor memory frozen for explicit reconciliation.
83
+ - Startup and elapsed time are not owner-death proof; queued authority has no time lease. Dead-owner cleanup groups the complete receipt and transactionally rechecks pid liveness plus process-birth identity: only an absent PID or mismatched stable Linux/macOS birth proof discards all session-owned sources without replay; a matching proof is `alive`, while Windows or inaccessible birth metadata is `unverifiable`, and both non-dead outcomes keep authority queued. The live-transfer contract is authenticated offer → exact-generation bounded payload staging → recipient CAS acceptance → exact receipt-and-owner ACK → donor removal → recipient readiness. The offer freezes donor settlement/recovery; controls rebuild local closures; negative/mismatched pre-acceptance ACK cancels only an unaccepted offer and retains donor work; a lost post-acceptance ACK cannot cancel recipient authority and leaves donor memory frozen for explicit reconciliation.
84
84
  - Execution failures persist bounded diagnostics and attempt state as `retry-wait`. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity. Missing snapshots left by the retired broad temp cleanup rebuild only from a complete provably empty segment chain, while revisionless snapshots may recover from a validated later segment predecessor; otherwise the snapshot and segments move atomically under `tmp/telegram/recovery/` before a fresh journal is published and startup continues with informational recovery evidence.
85
85
  - An unresolved reaction delays only the exact governed queue item identified by chat/message sources, not unrelated queue work. Queue receipt publication follows in-memory append and precedes dispatch request; receipt-bearing turns remain queued until every exact source commits.
86
86
  - The detailed implementation and release gates live in [`docs/architecture.md`](./docs/architecture.md), [`docs/multi-instance-bus.md`](./docs/multi-instance-bus.md), and [`BACKLOG.md`](./BACKLOG.md).
87
87
 
88
88
  ### 4.4 Queue, Delivery, And User Surfaces
89
89
 
90
- - 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. 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. Complete reaction sets independently derive Priority from recognized positive emoji and Skip from recognized negative emoji; both may coexist, Skip wins only at dispatch, and suppressed turns remain visible without blocking unrelated work.
90
+ - 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. 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. 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.
91
91
  - `/stop`, `/abort`, `/next`, and `/continue` respectively reset+abort, abort while preserving queue, force the next turn, and enqueue a control-lane continuation. Abort-history folding applies only to Telegram-owned active turns.
92
92
  - 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.
93
93
  - Complete assistant/guest model answers use Telegram-native Rich Markdown. Harness-owned menus, status, diagnostics, thinking, and tool evidence remain explicit HTML/plain or their documented native surface. Preserve literal code and structurally safe chunking; never split invalid markup.
@@ -125,7 +125,7 @@ The detailed map is canonical in [`docs/architecture.md`](./docs/architecture.md
125
125
  - Keep interfaces consistent with their owning exported contract. Use local structural `*Like`/view types only for deliberate narrow projections, not duplicate source-of-truth models.
126
126
  - Remove dead code immediately. Reachability from composition roots, public exports, tests, registered surfaces, and documented APIs—not recent usefulness—determines whether code is live.
127
127
  - Treat every meaningful `index.ts` edit as a composition-pressure check, but keep one-off live adapter wiring there when extraction would only hide cross-domain state.
128
- - Follow [`docs/ui-style.md`](./docs/ui-style.md) for interface copy, emoji, buttons, menus, and dialogs. Update the registry before assigning a new UI emoji meaning.
128
+ - Follow [`docs/ui-style.md`](./docs/ui-style.md) for interface copy, emoji, buttons, menus, and dialogs. Update the registry before assigning a new UI emoji meaning. Standalone notices use one fully bold emoji-led heading with terminal punctuation; callback alerts preserve equivalent emoji-led plain text because Telegram does not support rich formatting there.
129
129
  - Markdown tables use compact source formatting with `---` separator cells and one surrounding space per cell. Preserve vendored references unchanged.
130
130
  - Treat Windows filesystem, named-pipe, lock, heartbeat, and atomic-rename reports as high-signal evidence; reduce them to regressions or explicit platform caveats.
131
131
  - Route significant runtime failures through the redacted recent-event recorder. Keep the compact TUI status at generic `error`; details belong in diagnostics.
package/BACKLOG.md CHANGED
@@ -2,7 +2,6 @@
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
- - [ ] `Coherent voice reply policy`: Release the validated `manual`, `mirror`, and `always` policy cleanup as `0.38.0`, then reconcile issue #235 and open exact migration issues for `johnlam1968/pi-voice-telegram` and `luxus/pi-xai-voice`, whose TTS providers still consume the removed transcript-caption API.
6
5
  - [ ] `Operator-confirmed pairing`: Replace first-contact automatic ownership with an explicit pending request that the trusted Pi interface can allow or reject before `allowedUserId` is persisted or any Telegram prompt executes; retain manual preconfiguration, bound pending-request lifetime and multiplicity, fail closed across session/transport replacement, and cover unauthorized-first-contact races.
7
6
  - [ ] `Environment-backed bot tokens`: Let each profile store an exact Pi-native `$ENV_VAR` or `${ENV_VAR}` reference in `telegram.json` instead of copying the resolved secret; preserve the originating alias when `/telegram-setup` prefills from a supported environment variable, resolve only at validation/activation boundaries, fail closed with a redacted named-variable diagnostic when unresolved, retain literal-token compatibility, and cover named profiles, reload, persistence, status, and secret-redaction paths.
8
7
  - [ ] [`Inference bypass Generative Apps`](./docs/generative-apps.md): Let the agent install and bind one managed JavaScript app owner for a generated-prompt prefix so deterministic controls can bypass model inference without creating another button grammar.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.39.1: Compaction Failure Formatting Hotfix
6
+
7
+ - `Compaction Failure Formatting`: Preserves the HTML parse mode when a confirmed `/compact` callback reports asynchronous failure, preventing bold standalone failure headings from appearing as literal `<b>…</b>` markup in Telegram.
8
+
9
+ ## 0.39.0: Session-Owned Queue Lifecycle
10
+
11
+ - `Queue Counters`: Excludes Skip-marked prompts immediately from the shared executable queue count used by the Pi status bar and Telegram main menu while preserving physical queue position, deferred drop semantics, and reversible Keep behavior.
12
+ - `Skip Settlement`: Preserves durable receipts while a skipped prompt waits, then settles them atomically when that prompt reaches dispatch before dropping it without inference; settlement failure retains the head instead of creating replay ambiguity.
13
+ - `Session-Owned Queue`: Graceful shutdown discards all remaining queue receipts before clearing memory, while startup atomically discards receipts whose former process is proven dead instead of replaying them; replacement and unrelated sessions therefore start without inherited work.
14
+ - `Consistent Information Headings`: Standardizes standalone progress, success, empty, busy, unavailable, cancellation, and failure notices as fully bold emoji-led headings, while callback alerts retain equivalent plain text and automatic compaction waits for the preceding final answer before announcing its start.
15
+ - `Queue Menu`: Keeps skipped prompts visible in place with their physical ordinal struck through in list and detail views, so deferred removal is legible without renumbering or moving later work.
16
+ - `Agent Context`: Routes connected and Telegram-turn guidance across `telegram-bridge`, `generated-control-surface`, and `generative-apps` by activation role, asks the agent to load only missing Skill instructions, and removes Telegram tool context while transport is unavailable.
17
+ - `Bot API Reference`: Updates the vendored Telegram Bot API reference and lookup Skill through Bot API 10.3 while retaining the 10.0–10.3 freshness history and validated symbol, anchor, and line indexes.
18
+
5
19
  ## 0.38.0: Coherent Voice Reply Policy
6
20
 
7
21
  - `Voice Reply Policy`: Renames the default user-facing `hidden` mode to `manual` while retaining `hidden` as a read-only configuration and callback compatibility alias. `manual`, `mirror`, and `always` remain the complete policy set: explicit `telegram_voice`, modality mirroring, or automatic voice replacement.
package/README.md CHANGED
@@ -199,7 +199,7 @@ Queue policy:
199
199
  - 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.
200
200
  - Priority and Normal are separate FIFO lanes; Priority dispatches first.
201
201
  - 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.
202
- - Keep/Skip never changes lane position. Skip remains reversible while waiting and drops the prompt without a model turn only when dispatch reaches it.
202
+ - 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.
203
203
  - Reactions control two independent dimensions; changing one category preserves the other:
204
204
  - `Positive`: `👍`, `⚡️`, `❤️`, `🕊`, `🔥` — controls Priority.
205
205
  - `Negative`: `👎`, `👻`, `💔`, `💩`, `🗑` — controls Skip.
@@ -213,13 +213,13 @@ Compaction runs under the journal transaction lock when either 256 unapplied seg
213
213
 
214
214
  Queue receipt ownership is independent from the Telegram transport lock. A same-instance, same-process generation may reconstruct its local receipt across a fenced session replacement and may settle it after transport ownership moves. A different process reports the receipt as foreign, never republishes it into its local queue, and cannot complete it even if it reads the acquisition id. Startup no longer treats process replacement as proof that an owner died: foreign and legacy unowned receipts remain durable.
215
215
 
216
- Recovery and live handoff are compare-and-set under the journal transaction. Queue discard during exact queue-lifecycle cancellation requires the exact local owner/acquisition and removes all receipt sources atomically. Before admission worker start, the lifecycle groups each foreign receipt and asks the journal to recheck OS pid liveness plus process-birth identity under the same transaction; a live owner returns `owner-alive` and a live owner without stable birth proof returns `owner-unverifiable`, both without mutation, while exact negative proof converts the complete receipt back to `pending`. Replacement registration carries its exact pid/process-birth before this check; when registration and recovery race, that live identity wins the liveness proof and the queued receipt remains untouched. Only then does the replacement worker start, replay, and acquire a fresh receipt id/acquisition, fencing every stale owner.
216
+ Cleanup and live handoff are compare-and-set under the journal transaction. Queue discard during exact queue-lifecycle cancellation requires the exact local owner/acquisition and removes all receipt sources atomically. Before admission worker start, the lifecycle groups each foreign receipt and asks the journal to recheck OS pid liveness plus process-birth identity under the same transaction; a live owner returns `owner-alive` and a live owner without stable birth proof returns `owner-unverifiable`, both without mutation, while exact negative proof atomically discards the complete session-owned receipt without replay. Replacement registration carries its exact pid/process-birth before this check; when registration and cleanup race, that live identity wins the liveness proof and the queued receipt remains untouched. A replacement or unrelated worker therefore never executes queue work whose prior owner is proven dead.
217
217
 
218
218
  Authenticated live handoff uses journal CAS plus bounded local IPC. The donor creates a one-time high-entropy token and durably offers the complete receipt to one exact recipient runtime/process/session identity; the journal stores only a digest bound to queue kind, receipt sources, donor acquisition, and recipient identity. While offered, donor completion/discard and dead-owner recovery fail closed, so authority cannot disappear during payload transfer. Prompt payloads carry all queue fields; control payloads carry only their stable `status`/`model` identity and rebuild executable closures locally. The separately negotiated `queue-handoff-v1` capability gates this envelope for leader and both peer generations. Each receipt carries its exact source journal binding; the donor derives the recipient follower-journal binding from the authenticated stable follower profile before routing. The bus validates payload shape/size and exact donor/recipient registration generations, and the recipient selects only that matching active lifecycle, stages one complete receipt idempotently, accepts the journal CAS, and returns the exact receipt plus newly minted owner in its ACK. Malformed, legacy-unbound, inactive-generation, or unavailable bindings fail closed.
219
219
 
220
220
  During recipient staging, presenting the token atomically replaces the journal owner with a fresh acquisition carrying the handoff digest, removes the offer, and permanently fences donor settlement. The donor treats only an ACK carrying that exact accepted owner as success and never repeats acceptance against a donor-bound journal runtime. The recipient can repeat the same acceptance idempotently; a different token cannot claim an already accepted receipt. The coordinator contract orders offer → stage/accept exact receipt-and-owner ACK → donor removal → recipient readiness for direct leader→follower and follower→follower routing. Before acceptance, negative or mismatched acknowledgement exactly cancels the offer and keeps donor work. After acceptance, a lost acknowledgement cannot roll authority back: cancellation fails closed and donor memory remains frozen until exact accepted-owner reconciliation removes it. Recipient registration carries exact process-birth/session identity, and staged payloads remain outside the live dispatch store until accepted journal authority has been reconstructed. Production advertises `queue-handoff-v1` only with this exact role/journal selection and uses the same coordinator ordering for direct leader→follower and follower→follower routes.
221
221
 
222
- Queued semantic authority has no elapsed-time lease. A timeout cannot prove either owner death or effect quiescence, so it cannot safely recover a receipt. Resolution is limited to authenticated live handoff, exact owner discard/settlement, or transaction-rechecked negative PID plus process-birth evidence. Live or unverifiable owners remain queued indefinitely rather than risking duplicate execution.
222
+ Queued semantic authority has no elapsed-time lease. A timeout cannot prove either owner death or effect quiescence, so it cannot safely resolve a receipt. Resolution is limited to authenticated live handoff, exact owner discard/settlement, or transaction-rechecked negative PID plus process-birth evidence that permits terminal cleanup without replay. Live or unverifiable owners remain queued rather than risking duplicate or cross-session execution.
223
223
 
224
224
  The initial `offset: -1` cursor bootstrap is allowed only when both cursor and journal are absent or empty. Thereafter process-level ordering is journal atomic rename → one monotonic offset atomic rename → worker signal. Failure before journal publication leaves the offset unchanged; failure after journal publication but before offset publication permits Telegram redelivery and journal dedupe; failure after offset publication but before worker signal replays from the journal on restart. Queue-owner, retry, terminal, handoff, and completion transitions use the same journal publication primitive and therefore share this process-crash boundary. The final completion window is at-least-once, so replay-sensitive external effects must use `update_id` or the stable delivery id as an idempotency key.
225
225
 
@@ -236,7 +236,7 @@ The canonical update transition contract is:
236
236
  - `retry-wait → executing`: only after `nextRetryAtMs`; repeated signals before eligibility do not execute the entry. Automatic retries continue indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at `60s`, while later independent updates continue draining.
237
237
  - Legacy `failed → retry-wait`: startup atomically resumes terminal entries written by earlier `0.28.0` candidates. Runtime policy never silently discards durable inbound authority and exposes no Pi command for manual retry/discard.
238
238
  - `queued → offered → staged → queued`: only the exact persisted donor may offer or cancel a live handoff; an offer preserves donor ownership but freezes ordinary settlement and recovery. Authenticated bounded IPC stages one exact payload/receipt outside the live queue. Exact recipient acceptance mints a fresh acquisition, reconstructs local ownership, removes donor work, then publishes recipient dispatch readiness.
239
- - `queued → completed | pending`: only the exact persisted owner receipt may complete or discard queued sources; generic completion rejects queued state. Process-birth-proven owner death may atomically recover the complete unoffered receipt to `pending`; live, unverifiable, or offered owners remain queued.
239
+ - `queued → completed`: only the exact persisted owner receipt may complete or discard queued sources; generic completion rejects queued state. Process-birth-proven owner death atomically discards the complete unoffered session-owned receipt without replay; live, unverifiable, or offered owners remain queued.
240
240
 
241
241
  The worker executes at most 64 eligible entries from one validated journal snapshot, commits ordinary completions through one journal transaction, then yields through a generation-checked event-loop boundary. Retry, queue, or prior-generation boundaries first flush completed ids and force a fresh snapshot, preserving exact state-transition atomicity without per-entry parse/rewrite churn. A deterministic 2,048-entry stress gate requires exactly 32 completion publications, 33 reads including the final empty snapshot, continued 1ms timer progress, and less than 250ms maximum observed heartbeat delay. Byte-capacity tests cover failed and retry-wait diagnostics, queue receipt/owner and handoff metadata, and operator dispositions; every rejected growth leaves the prior authority bytes unchanged. It still scans later independent entries after retry or terminal persistence. An unresolved reaction remains a queue-mutation dependency even in `retry-wait` or `failed`, but dispatch checks that dependency against the candidate queue item's exact chat and source message ids instead of globally blocking unrelated targets. Successful replay or an exact discard disposition releases the dependency. Worker state, debug status, state snapshots, and redacted runtime events expose journal depth, retry/terminal counts, the next retry, latest terminal identity, copyable operator commands, and the exact first foreign queued owner identity (instance, PID/birth, session, and acquisition) when semantic authority belongs to another process.
242
242
 
@@ -270,7 +270,7 @@ A prompt is one queue object with exactly one active lane membership and one cur
270
270
 
271
271
  Telegram reactions are shortcut controls over those dimensions. Positive reactions (`👍`, `⚡️`, `❤️`, `🕊`, `🔥`) control Priority; negative reactions (`👎`, `👻`, `💔`, `💩`, `🗑`) control Skip. 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 listed order selects the retained display emoji when several recognized emoji from one category coexist; it does not let one category override the other.
272
272
 
273
- Priority and Skip may coexist, including `👍 + 💩`. The prompt remains at its Priority-lane position while waiting, but Skip wins when dispatch reaches it: the dispatcher drops it without a model turn and continues. A skipped prompt remains visible with only its negative emoji, survives authenticated queue handoff, and can return to Keep until it reaches the head. Queue item detail exposes symmetric Priority/Normal and Keep/Skip selectors instead of an irreversible Delete action.
273
+ Priority and Skip may coexist, including `👍 + 💩`. 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.
274
274
 
275
275
  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.
276
276
 
package/docs/ui-style.md CHANGED
@@ -26,8 +26,8 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
26
26
  | `🧠` | Thinking level | `/thinking`, thinking menu headings, thinking status rows | Use only for reasoning/thinking controls. |
27
27
  | `🔢` | Queue list / ordered work | `/queue`, queue menu entrypoints | Queue item rows may also use numeric labels. |
28
28
  | `⏱️` | Queue is ticking / current work is active | Inline main-menu Queue row only | Running-clock queue state: the narrow present moment is being worked now. |
29
- | `⏳` | Queue has waiting prompts | Inline main-menu Queue row only | Hourglass queue state: sand above the neck is future work still waiting. |
30
- | `⌛` | Queue is empty / standing idle | Inline main-menu Queue row only | Standing hourglass queue state: no future work is waiting above the neck. |
29
+ | `⏳` | Waiting / temporarily busy | Inline main-menu Queue row, busy notices | Hourglass means work or availability is pending; the sentence must name what is busy. |
30
+ | `⌛` | Empty / standing idle | Inline main-menu Queue row, empty-queue notices | Standing hourglass means no future work is waiting above the neck. |
31
31
  | `⚙️` | Settings / configuration | Settings menu headings and Settings navigation rows | Extension-injected rows appear before the built-in `⚙️ Settings` row. |
32
32
  | `🧩` | Extension-provided surface | Extension command examples, extension section examples | Companion extensions may choose their own emoji, but `🧩` means generic extension/plugin. |
33
33
  | `👄` | Voice reply policy | Voice reply settings row and detail card | Not a generic audio attachment marker. |
@@ -43,19 +43,28 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
43
43
  | --- | --- | --- | --- |
44
44
  | `🟢` | Start / active / current positive state | `/start`, active row, current selected option, active `On` toggle | In command context it means “open/start menu”; in state context it means selected/active. |
45
45
  | `🗜` | Compact session | `/compact`, compact confirmation action | Do not use for generic cleanup/delete. |
46
- | `⏩` | Force next queued turn | `/next` command and matching menu action | Means skip/advance to next waiting item. |
47
- | `▶️` | Continue/resume generation | `/continue` command and matching menu action | Means resume/continue current session flow, not force-next. |
48
- | `⏹️` | Abort current Pi work | `/abort` command description | Stops active work but is not a destructive queue clear by itself. |
49
- | `🟥` | Stop / abort-and-clear danger | `/stop` command description | Stronger than `⏹️`; use for disruptive stop/clear semantics. |
46
+ | `⏩` | Abort and advance | Busy `/next` command result and matching menu action | Means the active turn is aborted before advancing to queued work. |
47
+ | `▶️` | Play / continue immediately | Idle `/next` result, `/continue` command, and matching menu action | Means work can start or resume directly without first aborting an active turn. |
48
+ | `⏹️` | Abort current Pi work | `/abort` command description and active `/stop` result | Stops active work; accompanying copy states separately when queued work is cleared. |
49
+ | `🟥` | Destructive stop command | `/stop` command description | Strong warning at the command/action entrypoint; standalone results use the more precise idle or abort state icon. |
50
50
  | `🆕` | New session / fresh start | Reserved visible extension command example for `/new`-like flows | Same-thread Telegram `/new` is currently blocked by Pi core API; keep this meaning reserved. |
51
51
  | `🌀` | Refresh | Queue refresh row and future refresh buttons | Re-fetch/re-render current surface, not transport reconnect. |
52
52
  | `↪️` | Reroute to an existing target | Thread chooser buttons that send a captured command/message from one thread to another live thread | Curved arrow means the message arrived here but bends to another target. |
53
53
  | `🔁` | Replace/restore mode | Thread replace/restore chooser entrypoints | Opens a second step for moving a Pi instance binding to the current source thread. |
54
54
  | `➡️` | Choose replacement target | Thread replace/restore target buttons that select which Pi instance should move to the current thread | Use inside the second replace/restore chooser, not for ordinary reroutes. |
55
55
  | `☑️` | Activate / choose this item | Model detail activation action, generated button-only choice heading | Positive selection cue; use `🟢 Active` for already-current state. |
56
- | `❌` | No / cancel | Confirmation cancel buttons | Use for safe cancellation, not destructive removal. |
56
+ | `❌` | No / cancel / terminal failure | Confirmation cancel buttons and terminal failure notices | Do not use for a recoverable operation failure that leaves session state intact. |
57
57
  | `🗑` | Delete / defer removal | Destructive confirmations and removal reaction | In the queue menu, reversible Keep/Skip selectors replace immediate deletion. |
58
58
 
59
+ ### Informational Feedback
60
+
61
+ | Emoji | Meaning | Canonical surfaces | Notes |
62
+ | --- | --- | --- | --- |
63
+ | `💤` | Nothing active | No-active-turn notices | Neutral idle result, not an error. |
64
+ | `✅` | Completed successfully | Compaction and other completion notices | Use only after the operation has completed. |
65
+ | `🚫` | Unavailable, denied, or cancelled operation | Missing capability/auth notices, access denial, and compaction cancellation | Callback alerts use the same emoji without bold markup. |
66
+ | `⚠️` | Recoverable operation failure | Compaction failure notice | The attempted operation failed, but the original session state remains usable. |
67
+
59
68
  ### State Indicators And Button Grammars
60
69
 
61
70
  | Emoji | Meaning | Canonical surfaces | Notes |
@@ -71,6 +80,8 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
71
80
 
72
81
  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.
73
82
 
83
+ 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.
84
+
74
85
  Queue item detail renders two independent selector rows:
75
86
 
76
87
  - `🟡 Priority` / `⚫️ Normal` or `⚫️ Priority` / `🟣 Normal` selects the lane.
@@ -209,11 +220,14 @@ Examples:
209
220
 
210
221
  ## Message Cards
211
222
 
212
- Message cards sent by the bot should start with a strong heading.
223
+ Message cards and standalone informational notices sent by the bot should start with a strong heading.
213
224
 
214
225
  Rules:
215
226
 
216
227
  - Start with a bold heading or, for dialogs, a bold question.
228
+ - Format standalone notices as one fully bold line: relevant emoji, one space, concise sentence, and terminal period or colon.
229
+ - Apply the same hierarchy to success, progress, empty, busy, unavailable, cancellation, and failure notices; do not bold only a fragment of a standalone notice.
230
+ - Callback alerts remain plain text because Telegram does not support rich text there, but still keep the relevant emoji and concise sentence.
217
231
  - Setting detail cards may include an emoji in the heading, then a colon and the current value in `<code>`.
218
232
  - Explain what the setting does and what the options mean only as much as needed.
219
233
  - Order setting value descriptions exactly like the chooser: rows top-to-bottom and values in a shared row left-to-right. Keep `(default)` on the actual default wherever it falls; default status never changes order.
package/index.ts CHANGED
@@ -359,6 +359,7 @@ export default function (pi: Pi.ExtensionAPI) {
359
359
  getActiveToolExecutions: lifecycle.getActiveToolExecutions,
360
360
  hasPendingModelSwitch: pendingModelSwitchStore.has,
361
361
  getQueuedItems: telegramQueueStore.getQueuedItems,
362
+ getQueuedItemCount: Queue.countExecutableTelegramQueueItems,
362
363
  formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
363
364
  getRecentRuntimeEvents: runtimeEvents.getEvents,
364
365
  getRuntimeLockState: lockRuntime.getStatusLabel,
@@ -1234,6 +1235,7 @@ export default function (pi: Pi.ExtensionAPI) {
1234
1235
  prepareTempDir,
1235
1236
  updateStatus,
1236
1237
  unbindDeferredDispatchContext: deferredQueueDispatchRuntime.unbind,
1238
+ discardQueuedItems: queueMutationRuntime.clear,
1237
1239
  clearModelMenuState: modelMenuRuntime.clear,
1238
1240
  getActiveTurnChatId: activeTurnRuntime.getChatId,
1239
1241
  getActiveTurnTarget: activeTurnRuntime.getTarget,
package/lib/bindings.ts CHANGED
@@ -95,11 +95,28 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
95
95
  sendUserMessage: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendUserMessage"];
96
96
  recordRuntimeEvent?: TelegramRuntimeEventRecorder;
97
97
  }): TelegramQueueBindingRuntime<TContext> {
98
+ const settleDiscardedItems = (
99
+ items: readonly Queue.TelegramQueueItem<TContext>[],
100
+ ctx: TContext,
101
+ ): boolean => {
102
+ const durableItems = items.filter(
103
+ (item) => (item.admissionReceipts?.length ?? 0) > 0,
104
+ );
105
+ if (durableItems.length === 0) return true;
106
+ const settlement = deps.admission.getSettlement();
107
+ if (!settlement) return false;
108
+ settlement.onItemsDiscarded(durableItems, ctx);
109
+ return durableItems.every((item) => !settlement.isItemReady(item));
110
+ };
98
111
  const mutation = Queue.createTelegramQueueMutationController({
99
112
  ...deps.store,
100
113
  allocateLaneOrder: deps.queue.allocateItemOrder,
101
114
  onItemsDiscarded(items, ctx) {
102
- deps.admission.getSettlement()?.onItemsDiscarded(items, ctx);
115
+ if (!settleDiscardedItems(items, ctx)) {
116
+ throw new Error(
117
+ "Telegram queue items could not be discarded durably.",
118
+ );
119
+ }
103
120
  },
104
121
  updateStatus: deps.updateStatus,
105
122
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -136,6 +153,9 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
136
153
  onControlSettled(item, ctx) {
137
154
  deps.admission.getSettlement()?.onControlSettled(item, ctx);
138
155
  },
156
+ onPromptSkipped(item, ctx) {
157
+ return settleDiscardedItems([item], ctx);
158
+ },
139
159
  updateStatus: deps.updateStatus,
140
160
  sendTextReply: deps.sendTextReply,
141
161
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -909,6 +929,25 @@ export function registerTelegramLifecycleRuntimeHooks({
909
929
  { replyToPrompt: false },
910
930
  );
911
931
  };
932
+ let activeTurnDeliveryTail = Promise.resolve();
933
+ const scheduleActiveTurnDelivery = (task: () => Promise<void>): void => {
934
+ const previous = activeTurnDeliveryTail;
935
+ activeTurnDeliveryTail = (async () => {
936
+ await previous;
937
+ await new Promise<void>((resolve) => {
938
+ const timer = setTimeout(resolve, 0);
939
+ timer.unref?.();
940
+ });
941
+ await task();
942
+ })().catch((error) => {
943
+ recordRuntimeEvent("delivery", error, {
944
+ phase: "agent-end-background-delivery",
945
+ });
946
+ });
947
+ };
948
+ const waitForActiveTurnDelivery = async (): Promise<void> => {
949
+ await activeTurnDeliveryTail;
950
+ };
912
951
  const agentLifecycleHooks = Queue.createTelegramAgentLifecycleHooks<
913
952
  Queue.PendingTelegramTurn,
914
953
  Pi.ExtensionContext,
@@ -950,16 +989,7 @@ export function registerTelegramLifecycleRuntimeHooks({
950
989
  dispatchNextQueuedTelegramTurn,
951
990
  requestDeferredDispatchNextQueuedTelegramTurn:
952
991
  deferredQueueDispatchRuntime.request,
953
- scheduleActiveTurnDelivery(task) {
954
- const timer = setTimeout(() => {
955
- void task().catch((error) => {
956
- recordRuntimeEvent("delivery", error, {
957
- phase: "agent-end-background-delivery",
958
- });
959
- });
960
- }, 0);
961
- timer.unref?.();
962
- },
992
+ scheduleActiveTurnDelivery,
963
993
  clearPreview: previewRuntime.clear,
964
994
  setPreviewPendingText: previewRuntime.setPendingText,
965
995
  finalizeMarkdownPreview,
@@ -1079,7 +1109,11 @@ export function registerTelegramLifecycleRuntimeHooks({
1079
1109
  activityRuntime.onCompactionStart(Pi.getSessionCompactionReason(event));
1080
1110
  compactionObserver.onSessionBeforeCompact(event, ctx);
1081
1111
  if (shouldNotify) {
1082
- await sendCompactionNotice(Commands.TELEGRAM_COMPACTION_STARTED_TEXT);
1112
+ await waitForActiveTurnDelivery();
1113
+ if (!isSessionContextActive(ctx)) return;
1114
+ await sendCompactionNotice(
1115
+ Commands.TELEGRAM_COMPACTION_STARTED_MARKDOWN,
1116
+ );
1083
1117
  }
1084
1118
  },
1085
1119
  async onSessionCompact(event, ctx) {
@@ -1088,7 +1122,9 @@ export function registerTelegramLifecycleRuntimeHooks({
1088
1122
  compactionObserver.onSessionCompact(event, ctx);
1089
1123
  if (observedAutomaticCompaction) {
1090
1124
  observedAutomaticCompaction = false;
1091
- await sendCompactionNotice(Commands.TELEGRAM_COMPACTION_COMPLETED_TEXT);
1125
+ await sendCompactionNotice(
1126
+ Commands.TELEGRAM_COMPACTION_COMPLETED_MARKDOWN,
1127
+ );
1092
1128
  }
1093
1129
  },
1094
1130
  async onAgentStart(event, ctx) {
package/lib/commands.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  TELEGRAM_DEFAULT_PROFILE_NAME,
10
10
  } from "./config.ts";
11
11
  import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
12
+ import { escapeHtml } from "./rendering.ts";
12
13
  import type { TelegramBridgeStatusLineOptions } from "./status.ts";
13
14
  import {
14
15
  createTelegramControlItemBuilder,
@@ -185,9 +186,24 @@ export function formatTelegramCommandEmojiPrefix(
185
186
  return `${getTelegramCommandEmoji(command)} `;
186
187
  }
187
188
 
189
+ export function formatTelegramInformationHeading(
190
+ emoji: string,
191
+ text: string,
192
+ ): string {
193
+ return `<b>${escapeHtml(emoji)} ${escapeHtml(text)}</b>`;
194
+ }
195
+
188
196
  export const TELEGRAM_COMPACTION_STARTED_TEXT =
189
- `${formatTelegramCommandEmojiPrefix("compact")}Compaction started.`;
190
- export const TELEGRAM_COMPACTION_COMPLETED_TEXT = "✅ Compaction completed.";
197
+ formatTelegramInformationHeading(
198
+ getTelegramCommandEmoji("compact"),
199
+ "Compaction started.",
200
+ );
201
+ export const TELEGRAM_COMPACTION_COMPLETED_TEXT =
202
+ formatTelegramInformationHeading("✅", "Compaction completed.");
203
+ export const TELEGRAM_COMPACTION_STARTED_MARKDOWN =
204
+ `**${formatTelegramCommandEmojiPrefix("compact")}Compaction started.**`;
205
+ export const TELEGRAM_COMPACTION_COMPLETED_MARKDOWN =
206
+ "**✅ Compaction completed.**";
191
207
 
192
208
  function formatTelegramBotCommandDescription(
193
209
  command: TelegramCommandEmojiName,
@@ -585,7 +601,10 @@ export interface TelegramStopCommandDeps {
585
601
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
586
602
  abortCurrentTurn: () => void;
587
603
  updateStatus: () => void;
588
- sendTextReply: (text: string) => Promise<void>;
604
+ sendTextReply: (
605
+ text: string,
606
+ options?: { parseMode?: "HTML" },
607
+ ) => Promise<void>;
589
608
  }
590
609
 
591
610
  export interface TelegramRuntimeEventRecorderPort {
@@ -619,7 +638,10 @@ export interface TelegramCompactCommandDeps extends TelegramRuntimeEventRecorder
619
638
  onComplete: () => void;
620
639
  onError: (error: unknown) => void;
621
640
  }) => void;
622
- sendTextReply: (text: string) => Promise<void>;
641
+ sendTextReply: (
642
+ text: string,
643
+ options?: { parseMode?: "HTML" },
644
+ ) => Promise<void>;
623
645
  suppressStartNotice?: boolean;
624
646
  }
625
647
 
@@ -921,8 +943,11 @@ export function createTelegramCommandTargetRuntime<
921
943
  await deps.sendTextReply(
922
944
  target.chatId,
923
945
  target.replyToMessageId,
924
- "Settings menu is unavailable.",
925
- { target },
946
+ formatTelegramInformationHeading(
947
+ "🚫",
948
+ "Settings menu is unavailable.",
949
+ ),
950
+ { target, parseMode: "HTML" },
926
951
  );
927
952
  return;
928
953
  }
@@ -1108,6 +1133,25 @@ function getTelegramCommandErrorMessage(error: unknown): string {
1108
1133
  return error instanceof Error ? error.message : String(error);
1109
1134
  }
1110
1135
 
1136
+ function formatTelegramCompactionFailure(error: unknown): string {
1137
+ let message = getTelegramCommandErrorMessage(error).trim();
1138
+ const redundantPrefixes = [
1139
+ "Compaction failed: ",
1140
+ "Turn prefix summarization failed: ",
1141
+ ];
1142
+ let stripped = true;
1143
+ while (stripped) {
1144
+ stripped = false;
1145
+ for (const prefix of redundantPrefixes) {
1146
+ if (!message.startsWith(prefix)) continue;
1147
+ message = message.slice(prefix.length).trim();
1148
+ stripped = true;
1149
+ }
1150
+ }
1151
+ const sentence = /[.!?]$/u.test(message) ? message : `${message}.`;
1152
+ return `Compaction failed! ${sentence}`;
1153
+ }
1154
+
1111
1155
  export function parseTelegramCommand(
1112
1156
  text: string,
1113
1157
  ): ParsedTelegramCommand | undefined {
@@ -1165,7 +1209,10 @@ export async function handleTelegramStopCommand(
1165
1209
  ? ` Cleared ${formatTelegramQueuedTurnCount(clearedCount)}.`
1166
1210
  : "";
1167
1211
  if (clearedCount > 0) deps.updateStatus();
1168
- await deps.sendTextReply(`No active turn.${clearedSuffix}`);
1212
+ await deps.sendTextReply(
1213
+ formatTelegramInformationHeading("💤", `No active turn.${clearedSuffix}`),
1214
+ { parseMode: "HTML" },
1215
+ );
1169
1216
  return;
1170
1217
  }
1171
1218
  deps.abortCurrentTurn();
@@ -1174,7 +1221,13 @@ export async function handleTelegramStopCommand(
1174
1221
  clearedCount > 0
1175
1222
  ? ` Cleared ${formatTelegramQueuedTurnCount(clearedCount)}.`
1176
1223
  : "";
1177
- await deps.sendTextReply(`Aborted current turn.${clearedSuffix}`);
1224
+ await deps.sendTextReply(
1225
+ formatTelegramInformationHeading(
1226
+ "⏹️",
1227
+ `Aborted current turn.${clearedSuffix}`,
1228
+ ),
1229
+ { parseMode: "HTML" },
1230
+ );
1178
1231
  }
1179
1232
 
1180
1233
  export async function handleTelegramAbortCommand(deps: {
@@ -1184,17 +1237,26 @@ export async function handleTelegramAbortCommand(deps: {
1184
1237
  abortCurrentTurn: () => void;
1185
1238
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
1186
1239
  updateStatus: () => void;
1187
- sendTextReply: (text: string) => Promise<void>;
1240
+ sendTextReply: (
1241
+ text: string,
1242
+ options?: { parseMode?: "HTML" },
1243
+ ) => Promise<void>;
1188
1244
  }): Promise<void> {
1189
1245
  deps.clearPendingModelSwitch();
1190
1246
  if (!deps.hasAbortHandler()) {
1191
- await deps.sendTextReply("No active turn.");
1247
+ await deps.sendTextReply(
1248
+ formatTelegramInformationHeading("💤", "No active turn."),
1249
+ { parseMode: "HTML" },
1250
+ );
1192
1251
  return;
1193
1252
  }
1194
1253
  deps.setFoldQueuedPromptsIntoHistory(deps.hasActiveTelegramTurn());
1195
1254
  deps.abortCurrentTurn();
1196
1255
  deps.updateStatus();
1197
- await deps.sendTextReply("Aborted current turn.");
1256
+ await deps.sendTextReply(
1257
+ formatTelegramInformationHeading("⏹️", "Aborted current turn."),
1258
+ { parseMode: "HTML" },
1259
+ );
1198
1260
  }
1199
1261
 
1200
1262
  export async function handleTelegramNextCommand(deps: {
@@ -1213,7 +1275,10 @@ export async function handleTelegramNextCommand(deps: {
1213
1275
  }): Promise<void> {
1214
1276
  deps.clearPendingModelSwitch();
1215
1277
  if (!deps.hasQueuedItems()) {
1216
- await deps.sendTextReply("<b>Queue is empty.</b>", { parseMode: "HTML" });
1278
+ await deps.sendTextReply(
1279
+ formatTelegramInformationHeading("⌛", "Queue is empty."),
1280
+ { parseMode: "HTML" },
1281
+ );
1217
1282
  return;
1218
1283
  }
1219
1284
  if (!deps.isIdle() && deps.hasAbortHandler()) {
@@ -1221,17 +1286,30 @@ export async function handleTelegramNextCommand(deps: {
1221
1286
  deps.abortCurrentTurn();
1222
1287
  deps.updateStatus();
1223
1288
  await deps.sendTextReply(
1224
- "Aborted current turn. Dispatching next queued turn.",
1289
+ formatTelegramInformationHeading(
1290
+ "⏩",
1291
+ "Aborted! Dispatching next queued turn.",
1292
+ ),
1293
+ { parseMode: "HTML" },
1225
1294
  );
1226
1295
  return;
1227
1296
  }
1228
1297
  if (!deps.isIdle()) {
1229
- await deps.sendTextReply("Pi is busy. Send /abort or /stop first.");
1298
+ await deps.sendTextReply(
1299
+ formatTelegramInformationHeading(
1300
+ "⏳",
1301
+ "Pi is busy. Send /abort or /stop first.",
1302
+ ),
1303
+ { parseMode: "HTML" },
1304
+ );
1230
1305
  return;
1231
1306
  }
1232
1307
  deps.dispatchNextQueuedTurn();
1233
1308
  deps.updateStatus();
1234
- await deps.sendTextReply("Dispatching next queued turn.");
1309
+ await deps.sendTextReply(
1310
+ formatTelegramInformationHeading("▶️", "Dispatching next queued turn."),
1311
+ { parseMode: "HTML" },
1312
+ );
1235
1313
  }
1236
1314
 
1237
1315
  export async function handleTelegramContinueCommand<TMessage, TContext>(
@@ -1301,15 +1379,15 @@ export async function handleTelegramCompactConfirmationCallback<TContext>(
1301
1379
  const chatId = callbackMessage?.chat?.id;
1302
1380
  const messageId = callbackMessage?.message_id;
1303
1381
  if (typeof chatId !== "number" || typeof messageId !== "number") {
1304
- await deps.answerCallbackQuery(query.id, "Interactive message expired.");
1382
+ await deps.answerCallbackQuery(query.id, "Interactive message expired.");
1305
1383
  return true;
1306
1384
  }
1307
1385
  if (query.data === "compact:cancel") {
1308
1386
  await deps.editInteractiveMessage(
1309
1387
  chatId,
1310
1388
  messageId,
1311
- "Compaction cancelled.",
1312
- "plain",
1389
+ "<b>🚫 Compaction cancelled.</b>",
1390
+ "html",
1313
1391
  { inline_keyboard: [] },
1314
1392
  );
1315
1393
  await deps.answerCallbackQuery(query.id);
@@ -1319,7 +1397,7 @@ export async function handleTelegramCompactConfirmationCallback<TContext>(
1319
1397
  chatId,
1320
1398
  messageId,
1321
1399
  TELEGRAM_COMPACTION_STARTED_TEXT,
1322
- "plain",
1400
+ "html",
1323
1401
  { inline_keyboard: [] },
1324
1402
  );
1325
1403
  await deps.answerCallbackQuery(query.id);
@@ -1345,7 +1423,11 @@ export async function handleTelegramCompactCommand(
1345
1423
  deps.isCompactionInProgress()
1346
1424
  ) {
1347
1425
  await deps.sendTextReply(
1348
- "Cannot compact while Pi or the Telegram queue is busy. Wait for queued turns to finish or send /abort first.",
1426
+ formatTelegramInformationHeading(
1427
+ "⏳",
1428
+ "Cannot compact while Pi or the Telegram queue is busy. Wait for queued turns to finish or send /abort first.",
1429
+ ),
1430
+ { parseMode: "HTML" },
1349
1431
  );
1350
1432
  return;
1351
1433
  }
@@ -1359,7 +1441,9 @@ export async function handleTelegramCompactCommand(
1359
1441
  deps.setCompactionInProgress(false);
1360
1442
  deps.updateStatus();
1361
1443
  dispatchNextQueuedTelegramTurnAfterCompact(deps);
1362
- void deps.sendTextReply(TELEGRAM_COMPACTION_COMPLETED_TEXT);
1444
+ void deps.sendTextReply(TELEGRAM_COMPACTION_COMPLETED_TEXT, {
1445
+ parseMode: "HTML",
1446
+ });
1363
1447
  },
1364
1448
  onError: (error) => {
1365
1449
  deps.stopTypingLoop?.();
@@ -1367,8 +1451,13 @@ export async function handleTelegramCompactCommand(
1367
1451
  deps.updateStatus();
1368
1452
  dispatchNextQueuedTelegramTurnAfterCompact(deps);
1369
1453
  deps.recordRuntimeEvent?.("compact", error);
1370
- const errorMessage = getTelegramCommandErrorMessage(error);
1371
- void deps.sendTextReply(`Compaction failed: ${errorMessage}`);
1454
+ void deps.sendTextReply(
1455
+ formatTelegramInformationHeading(
1456
+ "⚠️",
1457
+ formatTelegramCompactionFailure(error),
1458
+ ),
1459
+ { parseMode: "HTML" },
1460
+ );
1372
1461
  },
1373
1462
  });
1374
1463
  } catch (error) {
@@ -1376,14 +1465,19 @@ export async function handleTelegramCompactCommand(
1376
1465
  deps.setCompactionInProgress(false);
1377
1466
  deps.updateStatus();
1378
1467
  deps.recordRuntimeEvent?.("compact", error);
1379
- const errorMessage = getTelegramCommandErrorMessage(error);
1380
- await deps.sendTextReply(`Compaction failed: ${errorMessage}`);
1468
+ await deps.sendTextReply(
1469
+ formatTelegramInformationHeading(
1470
+ "⚠️",
1471
+ formatTelegramCompactionFailure(error),
1472
+ ),
1473
+ { parseMode: "HTML" },
1474
+ );
1381
1475
  return;
1382
1476
  }
1383
1477
  if (!deps.suppressStartNotice) {
1384
- await deps.sendTextReply(
1385
- TELEGRAM_COMPACTION_STARTED_TEXT,
1386
- );
1478
+ await deps.sendTextReply(TELEGRAM_COMPACTION_STARTED_TEXT, {
1479
+ parseMode: "HTML",
1480
+ });
1387
1481
  }
1388
1482
  }
1389
1483
 
package/lib/journal.ts CHANGED
@@ -3381,15 +3381,8 @@ export function createTelegramUpdateJournalStore(
3381
3381
  const recoveredUpdateIds = [...requestedIds].sort((a, b) => a - b);
3382
3382
  const published = publishMutation(
3383
3383
  current,
3384
- current.file.entries.map((entry) =>
3385
- requestedIds.has(entry.updateId)
3386
- ? {
3387
- updateId: entry.updateId,
3388
- update: entry.update,
3389
- admittedAtMs: entry.admittedAtMs,
3390
- state: "pending" as const,
3391
- }
3392
- : entry,
3384
+ current.file.entries.filter(
3385
+ (entry) => !requestedIds.has(entry.updateId),
3393
3386
  ),
3394
3387
  true,
3395
3388
  );
package/lib/lifecycle.ts CHANGED
@@ -345,25 +345,27 @@ export function createTelegramBridgeSessionLifecycleAssembly<
345
345
  stopPolling: suspendForReplacement,
346
346
  clearPendingMediaGroups: deps.services.suspendGroupedInput,
347
347
  });
348
- const servicesLifecycle = appendTelegramLifecycleHooks(
349
- queueLifecycle,
350
- {
351
- async onSessionStart(event, ctx) {
352
- deps.services.resumeGroupedInput(ctx);
353
- await deps.services.delivery.onSessionStart();
354
- await deps.services.polling.onSessionStart(event, ctx);
355
- deps.services.capabilityMonitor.start(ctx);
356
- deps.services.queueWatchdog.start(ctx);
357
- },
358
- async onSessionShutdown() {
359
- await deps.services.delivery.onSessionShutdown();
360
- deps.services.queueWatchdog.stop();
361
- deps.services.capabilityMonitor.stop();
362
- await deps.services.inboundWorker.onSessionShutdown();
363
- },
348
+ const servicesLifecycle: TelegramSessionLifecycleHooks = {
349
+ async onSessionStart(event, ctx) {
350
+ await queueLifecycle.onSessionStart(event, ctx);
351
+ if (!isSessionActive(ctx)) return;
352
+ deps.services.resumeGroupedInput(ctx);
353
+ await deps.services.delivery.onSessionStart();
354
+ await deps.services.polling.onSessionStart(event, ctx);
355
+ deps.services.capabilityMonitor.start(ctx);
356
+ deps.services.queueWatchdog.start(ctx);
364
357
  },
365
- isSessionActive,
366
- );
358
+ async onSessionShutdown(event, ctx) {
359
+ if (!isSessionActive(ctx)) return;
360
+ await deps.services.delivery.onSessionShutdown();
361
+ if (!isSessionActive(ctx)) return;
362
+ deps.services.queueWatchdog.stop();
363
+ deps.services.capabilityMonitor.stop();
364
+ await queueLifecycle.onSessionShutdown(event, ctx);
365
+ if (!isSessionActive(ctx)) return;
366
+ await deps.services.inboundWorker.onSessionShutdown();
367
+ },
368
+ };
367
369
  const followerLifecycle = appendTelegramLifecycleHooks(
368
370
  servicesLifecycle,
369
371
  {
package/lib/menu-queue.ts CHANGED
@@ -73,6 +73,10 @@ function toTelegramQueueMenuItems<Context>(
73
73
  });
74
74
  }
75
75
 
76
+ function formatSkippedTelegramQueuePosition(position: number): string {
77
+ return Array.from(String(position), (char) => `${char}\u0335`).join("");
78
+ }
79
+
76
80
  function buildTelegramQueueMenuReplyMarkup(
77
81
  items: readonly TelegramQueueMenuItem[],
78
82
  emptyRefreshIndex = 0,
@@ -86,7 +90,7 @@ function buildTelegramQueueMenuReplyMarkup(
86
90
  : "queue:refresh";
87
91
  const refreshRow = [{ text: "🌀 Refresh", callback_data: refreshData }];
88
92
  if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] };
89
- const rows = items.map((item, index) => {
93
+ const rows = items.map((item) => {
90
94
  const prefix = item.reactionSuppressionEmoji
91
95
  ? `${item.reactionSuppressionEmoji} `
92
96
  : item.isPriority
@@ -94,7 +98,11 @@ function buildTelegramQueueMenuReplyMarkup(
94
98
  : item.hasAttachments
95
99
  ? "📎 "
96
100
  : "";
97
- const label = `${index + 1}. ${prefix}${item.statusSummary}`;
101
+ const position = item.reactionSuppressionEmoji
102
+ ? formatSkippedTelegramQueuePosition(item.queuePosition)
103
+ : String(item.queuePosition);
104
+ const ordinalSeparator = item.reactionSuppressionEmoji ? "\u200A" : "";
105
+ const label = `${position}${ordinalSeparator}. ${prefix}${item.statusSummary}`;
98
106
  return [
99
107
  {
100
108
  text: label,
@@ -162,7 +170,10 @@ function getTelegramQueueMenuItemText(item: TelegramQueueMenuItem): string {
162
170
  : item.isPriority
163
171
  ? ` ${item.priorityEmoji ?? "⚡"}`
164
172
  : "";
165
- const heading = `<b>${item.queuePosition}.</b>${badge}`;
173
+ const position = item.reactionSuppressionEmoji
174
+ ? `<s>${item.queuePosition}</s>.`
175
+ : `<b>${item.queuePosition}.</b>`;
176
+ const heading = `${position}${badge}`;
166
177
  const preview = `<pre>${escapeTelegramQueueMenuHtmlPreview(item.promptText)}</pre>`;
167
178
  return `${heading}\n${preview}`;
168
179
  }
@@ -182,7 +193,7 @@ function buildTelegramQueueItemSubmenuReplyMarkup(
182
193
  callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:priority`,
183
194
  },
184
195
  {
185
- text: isPriority ? "⚫️ Normal" : "🟣 Normal",
196
+ text: isPriority ? "⚫️ Normal" : "🔵 Normal",
186
197
  callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:normal`,
187
198
  },
188
199
  ],
package/lib/menu.ts CHANGED
@@ -255,7 +255,10 @@ export interface TelegramMenuActionRuntimeDeps<
255
255
  chatId: number,
256
256
  replyToMessageId: number,
257
257
  text: string,
258
- options?: { target?: { chatId: number; threadId?: number } },
258
+ options?: {
259
+ target?: { chatId: number; threadId?: number };
260
+ parseMode?: "HTML";
261
+ },
259
262
  ) => Promise<unknown>;
260
263
  sectionRegistry?: TelegramSectionRegistry;
261
264
  isVoiceReplyActive?: () => boolean;
@@ -798,8 +801,8 @@ export function createTelegramMenuActionRuntime<
798
801
  await deps.sendTextReply(
799
802
  chatId,
800
803
  replyToMessageId,
801
- "Cannot open status while Pi is busy. Send /abort, /next, or /stop.",
802
- { target: { chatId, threadId } },
804
+ "<b>⏳ Cannot open status while Pi is busy. Send /abort, /next, or /stop.</b>",
805
+ { target: { chatId, threadId }, parseMode: "HTML" },
803
806
  );
804
807
  },
805
808
  getModelMenuState: () => deps.getModelMenuState(chatId, ctx, threadId),
@@ -835,16 +838,16 @@ export function createTelegramMenuActionRuntime<
835
838
  await deps.sendTextReply(
836
839
  chatId,
837
840
  replyToMessageId,
838
- "Cannot switch model while Pi is busy. Send /abort, /next, or /stop.",
839
- { target: { chatId, threadId } },
841
+ "<b>⏳ Cannot switch model while Pi is busy. Send /abort, /next, or /stop.</b>",
842
+ { target: { chatId, threadId }, parseMode: "HTML" },
840
843
  );
841
844
  },
842
845
  sendNoModelsMessage: async () => {
843
846
  await deps.sendTextReply(
844
847
  chatId,
845
848
  replyToMessageId,
846
- "No available models with configured auth.",
847
- { target: { chatId, threadId } },
849
+ "<b>🚫 No available models with configured auth.</b>",
850
+ { target: { chatId, threadId }, parseMode: "HTML" },
848
851
  );
849
852
  },
850
853
  getModelMenuState: () => deps.getModelMenuState(chatId, ctx, threadId),
package/lib/prompts.ts CHANGED
@@ -14,11 +14,11 @@ export const TELEGRAM_DISCONNECTED_CONTEXT_MESSAGE =
14
14
 
15
15
  const LOCAL_SYSTEM_PROMPT_SUFFIX = `
16
16
 
17
- ${TELEGRAM_CONNECTED_CONTEXT_MESSAGE} Load the \`telegram-bridge\` Skill for Telegram-originated turns or explicit requests involving Telegram delivery, actions, Threaded Mode, or diagnosis. Do not use Telegram-specific features from unrelated local/TUI prompts.`;
17
+ ${TELEGRAM_CONNECTED_CONTEXT_MESSAGE} For Telegram work, consult bundled Skills in routing order: \`telegram-bridge\` for the transport and turn protocol, \`generated-control-surface\` when contextual controls materially shorten feedback, then \`generative-apps\` when the interaction warrants a reusable deterministic app. Load a Skill only if its instructions are not already present in the current context. Do not use Telegram-specific features from unrelated local/TUI prompts.`;
18
18
 
19
19
  const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
20
20
 
21
- Telegram turn note: Load and follow the \`telegram-bridge\` Skill.`;
21
+ Telegram turn note: Follow the applicable bundled Telegram Skills in routing order; load only missing instructions.`;
22
22
 
23
23
  export const TELEGRAM_ATTACH_PROMPT_SNIPPET =
24
24
  "Queue files for the active Telegram reply; outside Telegram turns, send files directly to Telegram.";
package/lib/queue.ts CHANGED
@@ -427,12 +427,22 @@ export function createTelegramTransportStampedQueueStore<TContext>(
427
427
  };
428
428
  }
429
429
 
430
+ export function isTelegramQueueItemSkipped<TContext = unknown>(
431
+ item: TelegramQueueItem<TContext>,
432
+ ): boolean {
433
+ return item.kind === "prompt" && Boolean(item.reactionSuppressionEmoji);
434
+ }
435
+
436
+ export function countExecutableTelegramQueueItems<TContext = unknown>(
437
+ items: readonly TelegramQueueItem<TContext>[],
438
+ ): number {
439
+ return items.filter((item) => !isTelegramQueueItemSkipped(item)).length;
440
+ }
441
+
430
442
  export function createTelegramQueueItemCountGetter<TContext = unknown>(
431
443
  store: Pick<TelegramQueueStore<TContext>, "getQueuedItems">,
432
444
  ): () => number {
433
- return () => {
434
- return store.getQueuedItems().length;
435
- };
445
+ return () => countExecutableTelegramQueueItems(store.getQueuedItems());
436
446
  }
437
447
 
438
448
  export function createTelegramActiveTurnStore<
@@ -993,7 +1003,8 @@ export function consumeDispatchedTelegramPrompt<TContext = unknown>(
993
1003
  export function formatQueuedTelegramItemsStatus<TContext = unknown>(
994
1004
  items: TelegramQueueItem<TContext>[],
995
1005
  ): string {
996
- return items.length === 0 ? "" : ` +${items.length}`;
1006
+ const count = countExecutableTelegramQueueItems(items);
1007
+ return count === 0 ? "" : ` +${count}`;
997
1008
  }
998
1009
 
999
1010
  export function truncateTelegramQueueSummary(
@@ -2062,6 +2073,7 @@ export interface TelegramSessionStartRuntimeDeps<TContext, TModel = unknown> {
2062
2073
  export interface TelegramSessionShutdownRuntimeDeps<TQueueItem> {
2063
2074
  isSessionActive?: () => boolean;
2064
2075
  unbindDeferredDispatchContext?: () => void;
2076
+ discardQueuedItems?: () => void;
2065
2077
  applyState: (state: TelegramSessionShutdownState<TQueueItem>) => void;
2066
2078
  clearPendingMediaGroups: () => void;
2067
2079
  clearModelMenuState: () => void;
@@ -2090,6 +2102,7 @@ export interface TelegramSessionLifecycleHookRuntimeDeps<
2090
2102
  updateStatus: (ctx: TContext) => void;
2091
2103
  isSessionActive?: (ctx: TContext) => boolean;
2092
2104
  unbindDeferredDispatchContext?: () => void;
2105
+ discardQueuedItems?: (ctx: TContext) => void;
2093
2106
  applySessionShutdownState: (
2094
2107
  state: TelegramSessionShutdownState<TQueueItem>,
2095
2108
  ) => void;
@@ -2286,6 +2299,7 @@ export async function shutdownTelegramSessionRuntime<TQueueItem>(
2286
2299
  deps.unbindDeferredDispatchContext?.();
2287
2300
  await deps.stopPolling();
2288
2301
  if (deps.isSessionActive?.() === false) return;
2302
+ deps.discardQueuedItems?.();
2289
2303
  deps.applyState(buildTelegramSessionShutdownState<TQueueItem>());
2290
2304
  deps.clearPendingMediaGroups();
2291
2305
  deps.clearModelMenuState();
@@ -2339,6 +2353,7 @@ export function createTelegramSessionLifecycleRuntime<
2339
2353
  updateStatus: deps.updateStatus,
2340
2354
  isSessionActive: deps.isSessionActive,
2341
2355
  unbindDeferredDispatchContext: deps.unbindDeferredDispatchContext,
2356
+ discardQueuedItems: deps.discardQueuedItems,
2342
2357
  applySessionShutdownState: stateApplier.applyShutdownState,
2343
2358
  clearPendingMediaGroups: deps.clearPendingMediaGroups,
2344
2359
  clearModelMenuState: deps.clearModelMenuState,
@@ -2387,6 +2402,10 @@ export function createTelegramSessionLifecycleHooks<
2387
2402
  isSessionActive: () =>
2388
2403
  ctx === undefined ? true : (deps.isSessionActive?.(ctx) ?? true),
2389
2404
  unbindDeferredDispatchContext: deps.unbindDeferredDispatchContext,
2405
+ discardQueuedItems:
2406
+ ctx === undefined || !deps.discardQueuedItems
2407
+ ? undefined
2408
+ : () => deps.discardQueuedItems!(ctx),
2390
2409
  applyState: deps.applySessionShutdownState,
2391
2410
  clearPendingMediaGroups: deps.clearPendingMediaGroups,
2392
2411
  clearModelMenuState: deps.clearModelMenuState,
@@ -2476,14 +2495,8 @@ export function clearTelegramQueueItemsRuntime<TContext>(
2476
2495
  const removedItems = deps.getQueuedItems();
2477
2496
  const removedCount = removedItems.length;
2478
2497
  if (removedCount === 0) return 0;
2498
+ deps.onItemsDiscarded?.(removedItems, deps.ctx);
2479
2499
  deps.setQueuedItems([]);
2480
- try {
2481
- deps.onItemsDiscarded?.(removedItems, deps.ctx);
2482
- } catch (error) {
2483
- deps.recordRuntimeEvent?.("queue", error, {
2484
- phase: "discard-receipt-settlement",
2485
- });
2486
- }
2487
2500
  updateTelegramQueueStatusRuntime(deps);
2488
2501
  return removedCount;
2489
2502
  }
@@ -2877,6 +2890,7 @@ export interface TelegramQueueDispatchControllerDeps<
2877
2890
  item: PendingTelegramControlItem<TContext>,
2878
2891
  ctx: TContext,
2879
2892
  ) => void;
2893
+ onPromptSkipped?: (item: PendingTelegramTurn, ctx: TContext) => boolean;
2880
2894
  }
2881
2895
 
2882
2896
  export interface TelegramQueueDispatchController<TContext = unknown> {
@@ -2940,6 +2954,7 @@ export function createTelegramQueueDispatchRuntime<TContext = unknown>(
2940
2954
  deps.hasPendingInboundQueueMutationForItem,
2941
2955
  isQueueItemAdmissionReady: deps.isQueueItemAdmissionReady,
2942
2956
  onControlSettled: deps.onControlSettled,
2957
+ onPromptSkipped: deps.onPromptSkipped,
2943
2958
  recordRuntimeEvent: deps.recordRuntimeEvent,
2944
2959
  });
2945
2960
  }
@@ -3007,7 +3022,29 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3007
3022
  deps.updateStatus(ctx);
3008
3023
  return;
3009
3024
  }
3025
+ try {
3026
+ if (deps.onPromptSkipped && !deps.onPromptSkipped(candidate, ctx)) {
3027
+ deps.updateStatus(
3028
+ ctx,
3029
+ "Telegram skipped prompt could not be settled durably.",
3030
+ );
3031
+ return;
3032
+ }
3033
+ } catch (error) {
3034
+ deps.recordRuntimeEvent?.("dispatch", error, {
3035
+ phase: "skip-receipt-settlement",
3036
+ });
3037
+ deps.updateStatus(
3038
+ ctx,
3039
+ "Telegram skipped prompt could not be settled durably.",
3040
+ );
3041
+ return;
3042
+ }
3010
3043
  nextActiveIndex += 1;
3044
+ deps.setQueuedItems([
3045
+ ...activeItems.slice(nextActiveIndex),
3046
+ ...protectedInactiveItems,
3047
+ ]);
3011
3048
  }
3012
3049
  }
3013
3050
  const dispatchableItems = activeItems.slice(nextActiveIndex);
@@ -3031,7 +3068,7 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3031
3068
  dispatchableItems,
3032
3069
  canDispatch,
3033
3070
  );
3034
- if (nextActiveIndex > 0 || dispatchPlan.kind !== "none") {
3071
+ if (dispatchPlan.kind !== "none") {
3035
3072
  deps.setQueuedItems([
3036
3073
  ...dispatchPlan.remainingItems,
3037
3074
  ...protectedInactiveItems,
package/lib/routing.ts CHANGED
@@ -1677,9 +1677,12 @@ export function createTelegramInboundRouteRuntime<
1677
1677
  })
1678
1678
  : undefined,
1679
1679
  stopTypingLoop: deps.stopTypingLoop,
1680
- sendTextReply: (text) =>
1680
+ sendTextReply: (text, options) =>
1681
1681
  deps
1682
- .sendTextReply(chatId, replyToMessageId, text, { target })
1682
+ .sendTextReply(chatId, replyToMessageId, text, {
1683
+ target,
1684
+ parseMode: options?.parseMode,
1685
+ })
1683
1686
  .then(() => {}),
1684
1687
  suppressStartNotice: true,
1685
1688
  recordRuntimeEvent: deps.recordRuntimeEvent,
package/lib/status.ts CHANGED
@@ -344,6 +344,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
344
344
  getActiveToolExecutions: () => number;
345
345
  hasPendingModelSwitch: () => boolean;
346
346
  getQueuedItems: () => TQueueItem[];
347
+ getQueuedItemCount?: (items: TQueueItem[]) => number;
347
348
  formatQueuedStatus: (items: TQueueItem[]) => string;
348
349
  getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
349
350
  getRuntimeLockState?: () => string;
@@ -658,6 +659,7 @@ export function createTelegramBridgeStatusRuntime<
658
659
  getStatusBarState: (_ctx, error) => {
659
660
  const config = deps.getConfig();
660
661
  const queuedItems = deps.getQueuedItems();
662
+ const queuedItemCount = deps.getQueuedItemCount?.(queuedItems) ?? queuedItems.length;
661
663
  const hasActiveTurn = deps.hasActiveTurn();
662
664
  const hasPendingDispatch = deps.hasDispatchPending();
663
665
  const hasPendingModelSwitch = deps.hasPendingModelSwitch();
@@ -677,13 +679,13 @@ export function createTelegramBridgeStatusRuntime<
677
679
  hasPendingDispatch ||
678
680
  hasPendingModelSwitch ||
679
681
  activeToolExecutions > 0 ||
680
- queuedItems.length > 0,
682
+ queuedItemCount > 0,
681
683
  processingStatus: getTelegramStatusBarProcessingStatus({
682
684
  hasActiveTurn,
683
685
  hasPendingDispatch,
684
686
  hasPendingModelSwitch,
685
687
  activeToolExecutions,
686
- queuedItems: queuedItems.length,
688
+ queuedItems: queuedItemCount,
687
689
  }),
688
690
  queuedStatus: deps.formatQueuedStatus(queuedItems),
689
691
  error,
package/lib/updates.ts CHANGED
@@ -4583,11 +4583,11 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
4583
4583
  try {
4584
4584
  deps.recordRuntimeEvent?.(
4585
4585
  "inbound-worker",
4586
- "Recovered queued authority from a confirmed-dead process.",
4586
+ "Discarded session-owned queue authority from a confirmed-dead process.",
4587
4587
  {
4588
- phase: "dead-queue-owner-recovery",
4588
+ phase: "dead-queue-owner-cleanup",
4589
4589
  receiptId,
4590
- recoveredUpdateCount: result.recoveredUpdateIds.length,
4590
+ removedUpdateCount: result.recoveredUpdateIds.length,
4591
4591
  },
4592
4592
  );
4593
4593
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.38.0",
3
+ "version": "0.39.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"