@llblab/pi-telegram 0.39.5 → 0.41.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.
package/AGENTS.md CHANGED
@@ -92,9 +92,9 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
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.
94
94
  - `preview` owns streaming lifecycle only, not assistant rendering. Finalization waits for active preview flushes and must not issue pre/post-final draft-clear calls that create transient Telegram draft UI.
95
- - Native `sendChatAction(typing)` is the automatic activity signal. Do not invent extra in-chat work indicators or emit activity for startup/connect/reload/recovery alone.
96
- - Public activity handlers and companion delivery are asynchronous, target-bound, generation-fenced surfaces. Token deltas, hidden reasoning, unknown sources, and stale authority never enter public projection.
97
- - UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts; do not restate those evolving details here.
95
+ - Native `sendChatAction(typing)` is the automatic activity signal for unsettled agent and compaction work while Telegram transport is authorized. Extension-owned blocking UI prompts pause it and completion resumes it while either work owner remains active. Do not invent extra in-chat work indicators or emit activity for startup/connect/reload/recovery alone.
96
+ - Public activity handlers and connected companion delivery are asynchronous, target-bound, generation-fenced surfaces. Connected companion projection has no independent opt-out: disconnect or authority loss is its boundary. Token deltas, hidden reasoning, unknown sources, and stale authority never enter public projection.
97
+ - UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable prompt-button labels use `emoji + space + text`; emoji-free text is only a reasoned no-semantic-marker fallback. Non-spatial generated controls default to top-level vertical cells, with nested rows reserved for unmistakably compact peers. Do not restate other evolving UI details here.
98
98
 
99
99
  ## 5. Domain Ownership Index
100
100
 
@@ -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. 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.
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 sentence with a terminal period; menu or chooser headings use the same hierarchy with a terminal colon. Material names may add nested italic emphasis without breaking the outer bold span. 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/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.41.0: Connected Companion Presence
6
+
7
+ - `Always-On Projection`: Treats authorized Telegram connection as the public-output boundary for local, autonomous, and unclassified Pi work, always delivering completed visible blocks in order instead of exposing an independent proactive-push opt-out.
8
+ - `Configuration`: Removes Proactive Push from Telegram Settings and the public config contract, atomically drops retired `assistant.proactivePush` values during normalization, and routes stale pre-upgrade callbacks back to the current Settings surface with an explanatory notice.
9
+ - `Work Presence`: Keeps native Telegram typing aligned with unsettled agent and compaction work, pauses it only while extension-owned local UI prompts await the operator, and resumes it after those prompts even when compaction begins after low-level `agent_end`.
10
+ - `Compaction Notices`: Preserves observed `Compaction started` and terminal notices across Pi 0.84.4's post-`agent_end` automatic-compaction order, flushing them after the final-answer delivery tail at `agent_settled`.
11
+ - `Generated Controls`: Requires semantic emoji-led human-readable labels and defaults non-spatial controls to full-width vertical cells, reserving nested horizontal rows for unmistakably compact peers while retaining reasoned no-marker and genuine spatial-grammar exceptions.
12
+ - `Thread Presence`: Aligns the Threaded Mode instance-connected notice with the common information hierarchy by bolding the complete emoji-led sentence while adding nested bold-italic emphasis to the instance name.
13
+ - `Status Parity`: Renames the ambiguous Usage row to Tokens and mirrors Pi's compact terminal telemetry in one line, including inline `R`/`W` cache tokens and `CH` for the latest request's cache-hit rate rather than a cumulative-session approximation.
14
+ - `Voice Setup`: Links maintained Groq Whisper, Mistral Voxtral, and keyless Edge TTS Skills from the voice guide, documenting their environment requirements and direct `telegram.json` command-template path without a companion extension or local speech model.
15
+
16
+ ## 0.40.0: Pi 0.84.4 Lifecycle Alignment
17
+
18
+ - `Pi Compatibility`: Requires `@earendil-works/pi-coding-agent`, `pi-agent-core`, and `pi-ai` 0.84.4 or newer, aligning the bridge and its validation runtime with native compaction-failure, mid-run compaction, message-finalization, and UI-prompt lifecycle contracts.
19
+ - `Compaction Failure`: Handles `session_compact_failed` immediately by clearing compacting state, stopping observer-owned typing, releasing deferred queue work, abandoning stale activity, and reporting automatic failure or cancellation without duplicating manual `/compact` errors.
20
+ - `Compaction Chronology`: Distinguishes terminal assistant output awaiting Telegram delivery from mid-run tool continuation. Post-answer notices remain ordered after the final reply, while tool-result threshold compaction is reported in place before the next assistant response.
21
+ - `Local UI Waiting`: Pauses Telegram typing while Pi waits on extension-owned local select, confirm, input, editor, or custom prompts and emits coalesced `ui-prompt-start` / `ui-prompt-end` Activity boundaries before resuming active-turn typing.
22
+ - `Inbound Context`: Raises successful inbound handler and built-in text attachment output from 12,000 to 24,000 characters while retaining the 1 MB built-in file admission ceiling and explicit truncation evidence.
23
+
5
24
  ## 0.39.5: Auto-Compaction Notice Chronology Hotfix
6
25
 
7
26
  - `Turn Chronology`: Defers observed automatic-compaction notices while a Telegram turn is still active, preserving the same causal order shown by the terminal: final answer, compaction started, then compaction completed. Abandoned compactions and session shutdown discard deferred notices instead of leaking them into a later turn.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  It is a **runtime adapter**, not a remote terminal. Start or supervise work in the Pi TUI, then continue from Telegram while away from the keyboard. Each Telegram destination follows a running Pi instance and sends prompts into that instance's currently active session; it is not permanently bound to one session file or session identity. The bridge preserves Pi session semantics instead of pretending Telegram is a PTY, shell, process launcher, or session browser. That boundary is the product: Telegram gets safe runtime handles, not raw terminal power.
10
10
 
11
- Every completed intermediate commentary block from a Telegram-originated turn is delivered once as its own message before the existing final reply. Proactive push is enabled by default for local, autonomous, and unclassified extension follow-up work: `assistant.proactivePush` projects visible checkpoints and the final answer to the authorized Telegram target once and in order, preserving assistant-authored `telegram_button` comments as interactive prompt buttons; set it explicitly to `false` to disable only that local/autonomous projection. Neither path mirrors local prompts, thinking, tool traffic, token deltas, or stale-generation work. The separate `Activity` setting defaults to `verbose` so new installations discover collapsed provider-exposed thinking and tool evidence immediately; operators can narrow it to one class or choose `quiet`. See [Outbound](docs/outbound.md#public-assistant-output) and the [configuration reference](docs/public-api.md#configuration-api).
11
+ Every completed intermediate commentary block from a Telegram-originated turn is delivered once as its own message before the existing final reply. While Telegram is connected, local, autonomous, and unclassified extension follow-up work also projects visible checkpoints and the final answer to the authorized Telegram target once and in order, preserving assistant-authored `telegram_button` comments as interactive prompt buttons. This connected companion projection is always active rather than configurable. Neither path mirrors local prompts, thinking, tool traffic, token deltas, or stale-generation work. The separate `Activity` setting defaults to `verbose` so new installations discover collapsed provider-exposed thinking and tool evidence immediately; operators can narrow it to one class or choose `quiet`. See [Outbound](docs/outbound.md#public-assistant-output) and the [configuration reference](docs/public-api.md#configuration-api).
12
12
 
13
13
  This repository is an actively maintained fork of [`badlogic/pi-telegram`](https://github.com/badlogic/pi-telegram). It started from upstream commit [`cb34008`](https://github.com/badlogic/pi-telegram/commit/cb34008460b6c1ca036d92322f69d87f626be0fc) and has since diverged substantially.
14
14
 
package/docs/activity.md CHANGED
@@ -96,7 +96,7 @@ Source classification follows evidence, not guesses:
96
96
 
97
97
  Automatic retries, overflow compaction retries, and tool continuations inherit the current activity identity/source until `agent_settled`. A new unrelated `agent_start` after settlement allocates a new activity id.
98
98
 
99
- A standalone compaction owns a temporary activity only until `session_compact`. Pi does not expose a sibling-extension cancellation/failure callback after `session_before_compact`, so pi-telegram abandons that temporary identity at the first provable fallback boundary: the next `agent_start`, a replacement compaction start, session shutdown, or the existing five-minute compaction-observer timeout. A late `session_compact` after abandonment is ignored and cannot attach to the next run. Compaction inside an existing agent activity never clears that agent's identity.
99
+ A standalone compaction owns a temporary activity until `session_compact` or `session_compact_failed`. Native failure or cancellation immediately abandons that identity, clears compacting state, releases deferred queue work, and stops observer-owned typing; the five-minute observer timeout remains a compatibility fallback for a host that never emits a terminal compaction event. A late `session_compact` after abandonment is ignored and cannot attach to the next run. Compaction inside an existing agent activity never clears that agent's identity.
100
100
 
101
101
  ## Event Contract
102
102
 
@@ -151,6 +151,12 @@ export type TelegramActivityEvent = TelegramActivityEnvelope & (
151
151
  type: "compaction-end";
152
152
  reason: "manual" | "threshold" | "overflow" | "unknown";
153
153
  }
154
+ | {
155
+ type: "ui-prompt-start";
156
+ kind: "select" | "confirm" | "input" | "editor" | "custom";
157
+ title?: string;
158
+ }
159
+ | { type: "ui-prompt-end" }
154
160
  | { type: "agent-end" }
155
161
  | { type: "agent-settled" }
156
162
  );
@@ -236,7 +242,7 @@ The Delivery API independently serializes concrete Telegram operations per targe
236
242
 
237
243
  ### Core assistant-output projection
238
244
 
239
- Activity's built-in assistant-output projection uses the same normalized `assistant-segment` boundary exposed to public handlers. For `telegram` activity it always projects complete `intermediate` commentary to the immutable originating target, while final and terminal-partial segments remain with active-turn settlement. For `local`, `autonomous`, or unclassified extension follow-up activity, `assistant.proactivePush` enables projection of every completed public block, including intermediate commentary/checkpoints and the final block. This closes actor-follow-up delivery without reclassifying it as direct user input. It never projects text token deltas, reasoning events, tool events or payloads, or empty text.
245
+ Activity's built-in assistant-output projection uses the same normalized `assistant-segment` boundary exposed to public handlers. For `telegram` activity it always projects complete `intermediate` commentary to the immutable originating target, while final and terminal-partial segments remain with active-turn settlement. For `local`, `autonomous`, or unclassified extension follow-up activity, every completed public blockincluding intermediate commentary/checkpoints and the final block—is projected whenever this Pi instance retains authorized connected transport. This closes actor-follow-up delivery without reclassifying it as direct user input, and the connected companion contract has no projection opt-out. It never projects text token deltas, reasoning events, tool events or payloads, or empty text.
240
246
 
241
247
  The projection does not delay Activity dispatch or Pi lifecycle. Its ordered admission tail deduplicates normalized event identity, while existing routing and outbound owners revalidate the immutable admission-time target, profile/token transport generation, direct leader epoch or follower registration generation, and session generation immediately before each send. Active-turn final delivery waits for admitted commentary inside its existing background task. A replacement or stale owner drops queued work rather than rerouting it, and an already-started non-idempotent Bot API mutation follows the normal `commit-unknown` no-replay contract.
242
248
 
@@ -250,9 +256,10 @@ The bridge maps Pi hooks as follows:
250
256
  - `agent_start`: allocate or reuse activity identity and emit `agent-start` after Telegram queue consumption establishes active-turn ownership.
251
257
  - `message_update.assistantMessageEvent`: normalize text/reasoning/provider boundaries.
252
258
  - `tool_execution_start/update/end`: emit executed tool events.
253
- - `session_before_compact` / `session_compact`: emit compaction boundaries and preserve activity identity across retry compaction; abandon an unterminated standalone compaction at the next lifecycle boundary or observer timeout rather than merging it into another run. A missing or unrecognized reason maps to `unknown` rather than guessing.
254
- - `agent_end`: emit low-level run completion but keep identity alive for retry/follow-up work.
255
- - `agent_settled`: emit terminal settlement, flush pending terminal segments, and release activity identity.
259
+ - `session_before_compact` / `session_compact` / `session_compact_failed`: emit successful compaction boundaries, abandon failed or cancelled work immediately, and preserve activity identity across retry compaction. Mid-run threshold compaction stays between tool results and the next assistant response, while terminal assistant output awaiting transport keeps later notices behind its final reply. A missing or unrecognized reason maps to `unknown` rather than guessing.
260
+ - `ui_prompt_start` / `ui_prompt_end`: emit one coalesced waiting span for extension-owned local UI, pause Telegram typing while Pi waits for the operator, and resume typing when the prompt closes if agent or compaction work remains unsettled.
261
+ - `agent_end`: emit low-level run completion but keep identity and connected work presence alive for retry, compaction, or follow-up work.
262
+ - `agent_settled`: emit terminal settlement, flush pending terminal segments, release activity identity, and end agent-owned connected work presence.
256
263
  - `session_shutdown`: stop dispatch, clear pending normalization state, and invalidate delivery generation through the existing delivery lifecycle.
257
264
 
258
265
  ## Diagnostics
@@ -79,7 +79,7 @@ The repository uses a **Flat Domain DAG**:
79
79
  - `keyboard`: shared inline-keyboard reply-markup shape only; feature domains own labels, callback data, and behavior.
80
80
  - `preview` / `replies` / `rendering`: throttled native Rich Markdown draft delivery, native final reply delivery, reply parameters, transport-limit chunking, and remaining Telegram HTML rendering for bridge-owned UI/compatibility surfaces.
81
81
  - `delivery`: public extension operational-view delivery, active-turn/instance/aggregate/authorized target policy, logical chunk handles, per-target ordering, runtime generation fencing, and the process-local runtime membrane. Its bridge adapter composes the established UI/compat reply renderer with narrow bus-aware Telegram API and ownership ports; it never exposes bot clients or Pi contexts.
82
- - `activity`: public normalized Pi lifecycle registration, activity/source identity, assistant segment and reasoning normalization, executed-tool events, non-blocking per-handler queues, delivery contexts, compatibility adapters, and shutdown fencing. The same domain extends assistant-output observation for proactive push: eligible completed local/autonomous public segments retain source order and deduplicate event identity. `bindings` assembles observation, authority, sender, and failure-projection ports; routing owns exact delivery authority, outbound composes established transformations and reply delivery, and Bot API domains implement transport. No separate proactive state-machine domain exists.
82
+ - `activity`: public normalized Pi lifecycle registration, activity/source identity, assistant segment and reasoning normalization, executed-tool events, non-blocking per-handler queues, delivery contexts, compatibility adapters, and shutdown fencing. The same domain extends assistant-output observation for connected companion projection: eligible completed local/autonomous public segments retain source order and deduplicate event identity. `bindings` assembles observation, authority, sender, and failure-projection ports; routing owns exact delivery authority, outbound composes established transformations and reply delivery, and Bot API domains implement transport. No separate proactive state-machine domain exists.
83
83
  - `outbound-markup`: top-level assistant action comment parsing, attribute parsing, voice reply planning, and preview/delivery stripping.
84
84
  - `outbound`: outbound text transformations, voice/button artifact delivery, and generated callback actions.
85
85
  - `generative-apps`: managed deterministic application identity, canonical installation and explicit replacement, content-addressed module loading, state timelines, cross-process transition serialization, bounded executable-plus-argv adaptation, `telegram_bind`, and pre-model-queue `app::method` invocation. It does not own Telegram transport, arbitrary shell execution, or the external application adapted by one Generative App.
@@ -315,14 +315,15 @@ Queue and menu mutations are reachable through Telegram updates handled by the c
315
315
 
316
316
  ### Compaction And Typing Status
317
317
 
318
- Manual `/compact` requires inline confirmation because accidental taps are disruptive. Confirmed manual compaction and auto-compaction both set the bridge compaction flag, block queued prompt dispatch, retain that flag in explicit diagnostics, and clear it on compact completion, timeout fallback, or session shutdown. Pi owns its terminal compaction lifecycle; pi-telegram keeps `Active` scoped to Telegram-owned work and otherwise preserves the stable connected/leader/follower role.
318
+ Manual `/compact` requires inline confirmation because accidental taps are disruptive. Confirmed manual compaction and auto-compaction both set the bridge compaction flag, block queued prompt dispatch, retain that flag in explicit diagnostics, and clear it on native compact completion or failure, timeout fallback, or session shutdown. Pi owns its terminal compaction lifecycle; pi-telegram keeps `Active` scoped to Telegram-owned work and otherwise preserves the stable connected/leader/follower role. Mid-run threshold compaction reports notices in place between tool output and the next assistant response; compaction observed after terminal assistant output waits for final Telegram delivery so transport chronology matches the terminal.
319
319
 
320
320
  Native typing during compaction follows connected-instance activity rather than terminal status:
321
321
 
322
322
  - Confirmed manual `/compact` starts a native `typing` keepalive in the command target and stops it on completion/failure.
323
323
  - Automatic/session compaction with an active Telegram turn reuses that turn's target.
324
324
  - Automatic/session compaction without an active Telegram turn uses the connected instance's assigned target; an unconnected instance sends nothing.
325
- - Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface; completion, timeout, and shutdown stop the keyed loop.
325
+ - Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface; completion, native failure, timeout, and shutdown stop the keyed loop.
326
+ - Pi `ui_prompt_start` pauses typing while an extension-owned local prompt waits for the operator; `ui_prompt_end` emits the matching Activity boundary and resumes typing whenever agent or compaction work remains unsettled.
326
327
 
327
328
  At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Each loop keeps one action in flight, while the leader API runtime coalesces identical chat/thread/action calls across local and follower traffic for two seconds; expired gates prune opportunistically and at most 256 currently active keys are retained. A Telegram 429 response opens the exact action's shared `retry_after` suppression window without scheduling delayed retries or projecting expected activity throttling as a terminal status error. Assistant message start/update hooks still re-arm it during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
328
329
 
@@ -389,13 +390,13 @@ The bridge does not mirror arbitrary `ctx.ui.confirm/input/select/custom` prompt
389
390
 
390
391
  ## Diagnostics And Operational Behavior
391
392
 
392
- Status rendering distinguishes connected, active, dispatching, queued, tool-running, model-switching, and compacting states; the Telegram status menu gives compaction precedence over generic active or pending work. Observed automatic compaction sends the same start and completion notices as the manual command without duplicating notices for command-owned compaction. If a queue mutation removes the last waiting item while Telegram-owned work still has running tools, status remains active instead of degrading to connected.
393
+ Status rendering distinguishes connected, active, dispatching, queued, tool-running, model-switching, and compacting states; the Telegram status menu gives compaction precedence over generic active or pending work. Its Tokens row mirrors Pi's terminal footer, including inline `R` cache-read tokens, `W` cache-write tokens, and `CH` for the latest assistant request's cache-read share of prompt tokens rather than a misleading cumulative-session ratio; the label remains distinct from companion-provided usage limits. Observed automatic compaction sends the same start and completion notices as the manual command without duplicating notices for command-owned compaction. If a queue mutation removes the last waiting item while Telegram-owned work still has running tools, status remains active instead of degrading to connected.
393
394
 
394
395
  Queue reaction behavior, lane-tail transitions, Keep/Skip independence, multi-reaction precedence, and the Bot API reaction-removal limitation are defined in [Priority, Reactions, Keep, and Skip](#priority-reactions-keep-and-skip). Reaction changes first flush a matching delayed text or media group so the governed turn exists before mutation, and dropping marked heads cannot leave status permanently queued.
395
396
 
396
397
  `/telegram-status` records grouped diagnostics for transport/API, polling/update, prompt dispatch, controls, typing, compaction, setup, session lifecycle, attachment queue/delivery, and recent redacted runtime events. Polling diagnostics expose the exact phase, phase start, current update, last successful `getUpdates` response, and stop reason; outbound success never substitutes for inbound progress. Expected preview noise such as unchanged edit responses is filtered out. The compact TUI status renders only `error`; detailed failure text remains in diagnostics and profile-scoped logs instead of expanding the status line.
397
398
 
398
- Complete intermediate assistant text blocks from Telegram-originated activity are sent once to the immutable originating target before active-turn final delivery; final and terminal-partial segments stay with settlement so replies are not duplicated. When `assistant.proactivePush` is enabled and this instance has exact direct or follower transport authority, completed public blocks from local/autonomous work are also sent once and in source order to the instance's authorized target. Both paths use the configured Rich or HTML renderer and exclude reasoning, tool traffic, token deltas, local prompt text, unknown sources, and stale generations. Each admitted block remains fenced to its exact target, profile/token stamp, leader epoch or follower registration generation, and session generation; non-idempotent acknowledgement ambiguity never authorizes replay.
399
+ Complete intermediate assistant text blocks from Telegram-originated activity are sent once to the immutable originating target before active-turn final delivery; final and terminal-partial segments stay with settlement so replies are not duplicated. While this instance has exact direct or follower transport authority, completed public blocks from local/autonomous work are always sent once and in source order to the instance's authorized target. Connected companion projection is not configurable; disconnect or authority loss is its boundary. Both paths use the configured Rich or HTML renderer and exclude reasoning, tool traffic, token deltas, local prompt text, unknown sources, and stale generations. Each admitted block remains fenced to its exact target, profile/token stamp, leader epoch or follower registration generation, and session generation; non-idempotent acknowledgement ambiguity never authorizes replay.
399
400
 
400
401
  `assistant.activity` is an independent bridge-owned projection over normalized Activity events. Each process reloads the shared file-backed setting at `agent-start` before activity admission, so multi-instance mode cannot continue projecting a stale broader process-local selection. Omitted values resolve to `verbose`, while invalid values fail closed to `quiet`; `thinking` and `tools` select one technical class, while `verbose` enables both. Provider-exposed thinking uses persistent ordinary HTML containing only a standard expandable blockquote with a bounded redacted latest-text window and inline Markdown rendered as Telegram HTML. Completed executed tools use native Rich Messages: each closed `<Tool>: <status>` root details node renders snake-case names as title words while preserving an uppercase two- or three-letter repeated prefix per word, then the native disclosure chevron reveals an open-by-default `arguments` child plus closed retained `update N` and `result`/`error` child details with lowercase monospaced, marker-free summaries and JSON pre blocks; known-safe Rich rejections fall back to the previous HTML disclosure. The projection captures the exact target and transport stamp at activity admission, serializes updates, preserves tool-start order, closes coalescing across assistant/thinking boundaries, bounds retained text/update memory plus edit frames and message/tool size, disables previews and HTTP(S) auto-link recognition inside technical evidence, and never replays a possibly committed send. Session generations own independent queues, so replacement drops queued old work without waiting on an old call. Both proactive prose and active-turn final delivery wait for the admitted activity queue inside their extension-owned delivery tasks, preserving technical-before-semantic ordering without delaying Pi lifecycle completion. Settlement, replacement, disconnect, failure, or stale authority clears only local ownership; already-sent activity messages remain in chat.
401
402
 
@@ -418,7 +419,7 @@ This is limited to Telegram-owned runs. If Pi is busy with non-Telegram work, th
418
419
 
419
420
  Non-critical timers are `unref()`ed so print/headless processes are not kept alive only by Telegram housekeeping. This includes typing keepalive intervals, bounded typing-idle waits, deferred queue dispatch, media/text-group debounce windows, preview flush timers, and polling retry sleeps. Polling retry sleep is abort-aware, so shutdown does not wait for the normal retry delay after a polling error.
420
421
 
421
- Non-interactive `pi -p` runs must remain passive unless Pi provides a live Telegram session lifecycle. Loading the extension with `telegram.json`, proactive push settings, or existing lock state must not by itself keep the print-mode process alive or let a non-owner send proactive Telegram output.
422
+ Non-interactive `pi -p` runs must remain passive unless Pi provides a live Telegram session lifecycle. Loading the extension with `telegram.json` or existing lock state must not by itself keep the print-mode process alive or let a non-owner send companion Telegram output.
422
423
 
423
424
  ## Related
424
425
 
package/docs/outbound.md CHANGED
@@ -8,9 +8,9 @@ Text handlers use the portable [Command Template Standard](./command-templates.m
8
8
 
9
9
  ## Public Assistant Output
10
10
 
11
- Every completed `assistant-segment` with `placement: "intermediate"` from a Telegram-originated turn is delivered as its own message to the immutable originating target before the ordinary active-turn final reply. This commentary path always applies; `assistant.proactivePush` does not disable it. Final and terminal-partial Telegram segments remain owned by active-turn settlement so the final answer, voice, buttons, previews, and artifacts are not duplicated.
11
+ Every completed `assistant-segment` with `placement: "intermediate"` from a Telegram-originated turn is delivered as its own message to the immutable originating target before the ordinary active-turn final reply. Final and terminal-partial Telegram segments remain owned by active-turn settlement so the final answer, voice, buttons, previews, and artifacts are not duplicated.
12
12
 
13
- Proactive projection defaults on for local, autonomous, and unclassified extension follow-up Pi work. With `assistant.proactivePush` omitted or set to `true`, every completed public block—including visible commentary/checkpoints and the final answer—is projected to the instance's authorized target in source order; set it explicitly to `false` to opt out of only this local/autonomous projection. Both paths consume normalized complete Activity segments rather than raw token deltas, reasoning, or tool traffic.
13
+ While Telegram is connected, local, autonomous, and unclassified extension follow-up Pi work always projects every completed public block—including visible commentary/checkpoints and the final answer—to the instance's authorized target in source order. There is no projection setting or opt-out: disconnecting Telegram is the boundary that stops this companion surface. Both paths consume normalized complete Activity segments rather than raw token deltas, reasoning, or tool traffic.
14
14
 
15
15
  Projected blocks use `assistant.rendering` independently of voice policy. Rich mode sends native Rich Markdown and HTML mode keeps the established HTML renderer; assistant-authored `telegram_button` comments are planned into prompt buttons before either renderer runs, while projection does not synthesize voice or attach queued files merely because Rich rendering is active. Ordered admission revalidates the exact target, profile/token transport generation, leader epoch or follower registration generation, and session generation before each send. Active-turn final delivery waits for admitted commentary inside its existing background delivery task, preserving commentary-before-final order without blocking Pi lifecycle completion. A `commit-unknown` outcome never permits replay.
16
16
 
@@ -94,7 +94,6 @@ interface TelegramConfig {
94
94
  assistant?: {
95
95
  draftPreviews?: boolean;
96
96
  rendering?: "rich" | "html";
97
- proactivePush?: boolean;
98
97
  activity?: "quiet" | "thinking" | "tools" | "verbose";
99
98
  timeInjection?: "hidden" | "always" | "interval";
100
99
  };
@@ -117,10 +116,10 @@ The file is global across Pi instances and contains configuration only. The per-
117
116
  Hidden/default semantics are represented by absence:
118
117
 
119
118
  - `threads.automaticCleanup` defaults to `true`; graceful Pi quit deletes the instance's bound Threaded Mode tab without prompting but preserves the owner slot as independent restart intent. Set it to `false`, or use `🧹 Thread cleanup` in Telegram Settings, to preserve the tab too. A confirmed `/telegram-disconnect`, unlike quit, clears restart ownership. Settings views and cleanup reload shared config before evaluating this switch, so another live Pi instance's update takes effect without restarting. Confirmed leader/follower teardown persists an exact target/runtime-generation cleanup intent before Telegram deletion; an interrupted attempt remains retryable by the current or successor leader under current authority and clears only after confirmed deletion. A same-profile replacement leader first adopts any still-active binding and cancels its superseded cleanup, so startup never deletes and recreates a reusable thread. If a follower's graceful envelope is missed, the leader may create the same fenced cleanup only after its heartbeat is stale, the OS confirms the exact registered PID no longer exists, cleanup remains enabled, and no replacement registration can overtake deletion. Heartbeat loss alone, live/unknown process liveness, IPC failure, and auth failure remain non-destructive. Invalid-config recovery makes the setting unresolved and therefore skips destructive cleanup. Manual `/telegram-disconnect` keeps its confirmation and teardown behavior regardless of this setting.
120
- - Every complete intermediate assistant text block from a Telegram-originated turn is delivered once to its immutable target before the existing final reply. This active-turn commentary path remains enabled when `assistant.proactivePush` is `false`; final and terminal-partial segments stay with settlement to prevent duplicate replies. `assistant.proactivePush` defaults to `true` only for local/autonomous work: omit it to project every completed public block, including commentary and the final block, or set it explicitly to `false` to disable that projection. Both paths exclude token deltas, hidden reasoning, tool calls/arguments/results, empty blocks, unknown sources, and stale authority. Projection uses the configured Rich or HTML assistant renderer and binds admitted work to the exact target, profile/token transport generation, direct leader epoch or follower registration generation, and session generation. The old top-level `proactivePush` key is ignored; move the setting manually under `assistant`.
119
+ - Every complete intermediate assistant text block from a Telegram-originated turn is delivered once to its immutable target before the existing final reply; final and terminal-partial segments stay with settlement to prevent duplicate replies. While Telegram transport remains authorized, local/autonomous work also projects every completed public block, including commentary and the final block. This connected companion projection is always active and excludes token deltas, hidden reasoning, tool calls/arguments/results, empty blocks, unknown sources, and stale authority. Projection uses the configured Rich or HTML assistant renderer and binds admitted work to the exact target, profile/token transport generation, direct leader epoch or follower registration generation, and session generation. Retired top-level and `assistant.proactivePush` keys are ignored; the nested key is removed during config normalization.
121
120
  - `assistant.activity` accepts exactly `"quiet"`, `"thinking"`, `"tools"`, or `"verbose"`; omitted values default to `"verbose"`, explicit values remain unchanged, and invalid values fail closed to `"quiet"`. Each Pi process reloads the shared file-backed value at `agent-start`, so multi-instance activity isolation never relies on a stale process-local config snapshot. `thinking` shows only provider-exposed thinking, `tools` shows only completed tool activity, and `verbose` shows both. Thinking uses persistent ordinary HTML `sendMessage`/`editMessageText` disclosure with a standard expandable blockquote, a `🧠` header carrying the current Pi thinking level, and bounded redacted text whose inline Markdown renders as Telegram HTML. Tools use native Rich Messages with one header followed by separate closed details and JSON pre blocks for bounded redacted arguments, retained updates, and results/errors. Thinking disables link previews on every HTML send/edit and neutralizes HTTP(S) auto-link detection; Rich tool output disables automatic entity detection, with the same protections retained by its HTML fallback. Consecutive tools coalesce only inside the same ordered activity segment and bounded message. Legacy `assistant.activityVerbosity` is read only when `assistant.activity` is absent and is removed by the next Activity Settings write.
122
121
  - Voice Reply `hidden`: no `voice.replyMode` key is persisted; legacy `manual` resolves to this silent default. `mirror` adds `[voice] delivery: automatic voice` only to voice/audio-input turns, while `always` adds the same effective line to every Telegram turn.
123
- - Agent activity status is not configurable in this release. Telegram uses native `sendChatAction(typing)` / product `...active` status as the only automatic in-chat work signal before the final reply.
122
+ - Agent activity status is not configurable. While Telegram transport remains authorized, Telegram uses native `sendChatAction(typing)` / product `...active` status as the automatic in-chat work signal for unsettled agent and compaction work. Extension-owned blocking UI prompts pause it and completion resumes it while either work owner remains active.
124
123
  - `assistant.timeInjection` accepts `hidden`, `always`, or `interval` and defaults to `interval` when absent without migrating an explicit stored value. Settings writes the selected value there, including `hidden`; the old `time.injectionMode` key is ignored and is not migrated. `time.interval` remains the optional interval duration in milliseconds.
125
124
 
126
125
  With `assistant.rendering: "rich"` (the default), assistant Markdown delivery is native: final replies are sent as `InputRichMessage.markdown` via `sendRichMessage`, and draft previews use `sendRichMessageDraft` when a structurally closed preview frame is available. Draft-frame failures are recorded and skipped rather than converted into raw plain preview messages, because partial Markdown can be temporarily invalid while the final answer remains valid. Long native replies are split at Telegram Rich Message transport limits, with oversized fenced code, display-math, and fully wrapped inline-formatting blocks rewrapped per chunk so persisted chunks remain structurally valid. Guest replies use `InputRichMessageContent` in `answerGuestQuery` results. Bridge-owned UI surfaces such as menus, status, queue controls, commands, and sections keep explicit Telegram HTML/plain rendering by default because those texts are authored by the bridge or companion extensions for Telegram UI. Companion extension sections may explicitly request `"markdown"`, `"html"`, or `"plain"` per view. `assistant.rendering: "html"` keeps the compatibility path that converts assistant Markdown to Telegram HTML before ordinary message delivery. The bridge sets `skip_entity_detection: true` for assistant and guest Markdown so technical text such as `/commands`, hashtags, URLs, phone numbers, and card-like numbers does not gain unintended automatic entities; explicit Markdown links still belong in the Markdown source.
@@ -337,7 +336,7 @@ Contract:
337
336
  - Providers are synchronous because `/start` status text is rendered inline with the menu.
338
337
  - Return `undefined` when the line is not relevant for the active model.
339
338
  - Provider failures are isolated and skipped so optional companion status cannot break the core Telegram menu.
340
- - The bridge renders rows as `<Label>: <value>` in the same HTML status block as Status, Usage, Cost, and Context, capitalizing the first label character for Telegram UI consistency.
339
+ - The bridge renders rows as `<Label>: <value>` in the same HTML status block as Status, Tokens, Cost, and Context, capitalizing the first label character for Telegram UI consistency. Tokens mirrors Pi's footer telemetry: `↑` input, `↓` output, `R` cache-read tokens, `W` cache-write tokens, and `CH` for the latest assistant request's `cacheRead / (input + cacheRead + cacheWrite)`, shown to one decimal place only after the session reports cache activity. The precise Tokens label avoids conflating token telemetry with companion-provided usage-limit rows.
341
340
 
342
341
  ## Updates
343
342
 
package/docs/ui-style.md CHANGED
@@ -32,7 +32,6 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
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. |
34
34
  | `🕒` | Time injection / wall-clock context | Time injection settings row and detail card | Clock-face marker with hands; not a generic duration/progress marker. |
35
- | `📌` | Proactive push / pinned behavior | Proactive push settings row and detail card | Not generic active/selected state. |
36
35
  | `🔬` | Activity / technical detail | Activity settings row and detail card | Chooses quiet, thinking, tools, or verbose bridge activity; not a generic diagnostics marker. |
37
36
  | `🧠` | Model thinking controls | Thinking menus and status rows | Thinking activity quotes omit this icon and their header entirely to minimize chat height. |
38
37
  | `📎` | Attachment | Attachment summaries, queue rows for attachment-only turns | Not for thread binding. |
@@ -138,7 +137,6 @@ Rules:
138
137
  Examples:
139
138
 
140
139
  - `🟢 Active` — model detail, navigates back to model list
141
- - `📌 Proactive push: On` — settings row, opens the toggle submenu
142
140
  - `👄 Voice reply: Mirror` — settings row, opens the option list
143
141
 
144
142
  ## Boolean Toggles
@@ -199,7 +197,7 @@ Examples:
199
197
 
200
198
  ## Generated Prompt Buttons
201
199
 
202
- A button-only assistant reply uses the standard Rich Markdown heading `☑️ **Choose an option:**`: semantic icon first, one space, bold heading text, and a final colon. Assistant-generated prompt buttons use the default app style before selection. After queue admission, edit only the selected button to its agent-configured `selected_style`: `primary` (default/blue), `success` (green), or `danger` (red). Preserve its agent-authored text and emoji, leave other choices at their default style, and always queue the selected prompt regardless of color. The callback acknowledgement remains the compatibility fallback when a client does not render button styles.
200
+ A button-only assistant reply uses the standard Rich Markdown heading `☑️ **Choose an option:**`: semantic icon first, one space, bold heading text, and a final colon. Every generated human-readable action label starts with the most semantically appropriate emoji, one ASCII space, then concise action text. Emoji selection is part of authoring the control, including compact label-equals-prompt forms. Emoji-free text remains a syntax-compatible fallback only when no honest semantic marker exists after considering the action, domain, and state—not merely for convenience or label pressure; genuine coordinate and symbolic spatial controls retain their established grammar. Generated non-spatial controls default to vertical full-width buttons represented as top-level matrix cells. Nested row arrays are a compact-peer exception only when every label is unmistakably short—roughly 15 visible characters or fewer including emoji and space; this is a judgment heuristic rather than a mechanical count, and any plausible ellipsis or wrapping risk returns the controls to vertical rows. Assistant-generated prompt buttons use the default app style before selection. After queue admission, edit only the selected button to its agent-configured `selected_style`: `primary` (default/blue), `success` (green), or `danger` (red). Preserve its agent-authored text and emoji, leave other choices at their default style, and always queue the selected prompt regardless of color. The callback acknowledgement remains the compatibility fallback when a client does not render button styles.
203
201
 
204
202
  ## Navigation
205
203
 
@@ -225,8 +223,9 @@ Message cards and standalone informational notices sent by the bot should start
225
223
  Rules:
226
224
 
227
225
  - 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.
226
+ - Format standalone notices as one fully bold line: relevant emoji, one space, concise sentence, and terminal period. Menu or chooser headings use the same fully bold form but end in a colon when controls or detail follow.
227
+ - Keep the emoji and complete sentence or heading inside the single bold span; do not bold only a fragment. A material name or phrase may receive nested italic emphasis without breaking the outer bold hierarchy—for example `<b>📡 Instance <i>Cedar</i> connected.</b>`.
228
+ - Apply the same hierarchy to success, progress, empty, busy, unavailable, cancellation, and failure notices.
230
229
  - Callback alerts remain plain text because Telegram does not support rich text there, but still keep the relevant emoji and concise sentence.
231
230
  - Setting detail cards may include an emoji in the heading, then a colon and the current value in `<code>`.
232
231
  - Explain what the setting does and what the options mean only as much as needed.
package/docs/voice.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Voice Integration
2
2
 
3
- Voice messages flow through an **inbound transcription → outbound voice reply** pipeline. This document describes the bridge's role in that pipeline; provider-specific mechanics (TTS/STT backends, voice IDs, languages) are owned by voice provider extensions. This is a first-class extension surface: one companion extension can provide STT fallbacks for inbound voice/audio files and TTS fallbacks for outbound Telegram voice replies without owning a second bot polling loop.
3
+ Voice messages flow through an **inbound transcription → outbound voice reply** pipeline. This document describes the bridge's role in that pipeline; provider-specific mechanics (TTS/STT backends, voice IDs, languages) are owned by configured handler scripts or voice provider extensions. This is a first-class extension surface: one companion extension can provide STT fallbacks for inbound voice/audio files and TTS fallbacks for outbound Telegram voice replies without owning a second bot polling loop.
4
4
 
5
5
  ## Overview
6
6
 
@@ -18,7 +18,17 @@ Use the smallest path that fits the operator's available capabilities:
18
18
  2. **Companion extension:** Register programmatic STT/TTS providers when installation, provider-owned settings, lifecycle integration, or zero-config reuse justifies code.
19
19
  3. **Hybrid:** Keep explicit operator command templates first and let installed providers supply progressive fallbacks.
20
20
 
21
- pi-telegram does not catalog speech providers. Configuration agents should discover applicable Skills or trusted local executables, verify required environment variables by presence without exposing values, preserve unrelated `telegram.json` state, ensure TTS ends as OGG/Opus, and test each stage before the live Telegram path. `hidden` remains the safe and useful default: it disables only automatic voice replies, while explicit `telegram_voice` actions continue to use the configured synthesis pipeline.
21
+ pi-telegram does not maintain a built-in or exhaustive speech-provider catalog. Configuration agents should discover applicable Skills or trusted local executables, verify required environment variables by presence without exposing values, preserve unrelated `telegram.json` state, ensure TTS ends as OGG/Opus, and test each stage before the live Telegram path. `hidden` remains the safe and useful default: it disables only automatic voice replies, while explicit `telegram_voice` actions continue to use the configured synthesis pipeline.
22
+
23
+ ### Ready-made command-template examples
24
+
25
+ The public [`llblab/skills`](https://github.com/llblab/skills) repository provides three maintained Skills with standalone scripts that can be wired directly into `telegram.json`; no additional Pi extension or local inference engine is required:
26
+
27
+ - [`groq-stt`](https://github.com/llblab/skills/tree/main/groq-stt) — Groq Whisper speech-to-text through `scripts/transcribe.sh`. It outputs plain transcript text and requires `GROQ_API_KEY` in the Pi process environment.
28
+ - [`mistral-stt`](https://github.com/llblab/skills/tree/main/mistral-stt) — Mistral Voxtral speech-to-text through `scripts/transcribe.sh`. It outputs plain transcript text and requires `MISTRAL_API_KEY` in the Pi process environment.
29
+ - [`edge-tts`](https://github.com/llblab/skills/tree/main/edge-tts) — Microsoft Edge neural text-to-speech through `scripts/say.sh`. It requires internet access but no account or API key and can write MP3 output for the outbound pipeline.
30
+
31
+ The two hosted STT options avoid running a local transcription model and offer useful free-tier capacity after provider registration; provider limits and terms may change. Install or clone the Skill repository, verify the required API-key variable by presence without printing its value, and point the matching `inboundHandlers` template at the Skill's transcription script. For Edge TTS, point an `outboundHandlers` voice pipeline at `say.sh --file - --write-media {mp3}`, then convert `{mp3}` to `{ogg}` with ffmpeg as shown in [Outbound Voice Handlers](#outbound-voice-handlers), because Telegram native voice notes require OGG/Opus. Read each linked Skill's current `SKILL.md` for its exact CLI, defaults, dependencies, and optional language/model controls.
22
32
 
23
33
  ## Voice Detection
24
34
 
package/index.ts CHANGED
@@ -548,7 +548,6 @@ export default function (pi: Pi.ExtensionAPI) {
548
548
  } = Bindings.createTelegramActivityBindingRuntime({
549
549
  generation: deliveryGenerationSeed,
550
550
  assistantOutput: {
551
- isEnabled: configControls.isProactivePushEnabled,
552
551
  authority: {
553
552
  getPreferredTarget: proactivePushTargetGetter,
554
553
  getFallbackChatId: proactivePushChatIdGetter,
package/lib/activity.ts CHANGED
@@ -85,6 +85,12 @@ export type TelegramActivityPayload =
85
85
  type: "compaction-end";
86
86
  reason: "manual" | "threshold" | "overflow" | "unknown";
87
87
  }
88
+ | {
89
+ type: "ui-prompt-start";
90
+ kind: "select" | "confirm" | "input" | "editor" | "custom";
91
+ title?: string;
92
+ }
93
+ | { type: "ui-prompt-end" }
88
94
  | { type: "agent-end" }
89
95
  | { type: "agent-settled" };
90
96
 
@@ -440,6 +446,12 @@ export function createTelegramActivityBridgeRuntime(deps: {
440
446
  onCompactionAbandoned() {
441
447
  getRuntime()?.onCompactionAbandoned();
442
448
  },
449
+ onUiPromptStart(kind, title) {
450
+ getRuntime()?.onUiPromptStart(kind, title);
451
+ },
452
+ onUiPromptEnd() {
453
+ getRuntime()?.onUiPromptEnd();
454
+ },
443
455
  onAgentEnd() {
444
456
  getRuntime()?.onAgentEnd();
445
457
  },
@@ -502,6 +514,11 @@ export interface TelegramActivityRuntime {
502
514
  reason: "manual" | "threshold" | "overflow" | "unknown",
503
515
  ) => void;
504
516
  onCompactionAbandoned: () => void;
517
+ onUiPromptStart: (
518
+ kind: "select" | "confirm" | "input" | "editor" | "custom",
519
+ title?: string,
520
+ ) => void;
521
+ onUiPromptEnd: () => void;
505
522
  onAgentEnd: () => void;
506
523
  onAgentSettled: () => void;
507
524
  onSessionShutdown: () => void;
@@ -533,6 +550,7 @@ export function createTelegramActivityRuntime(deps: {
533
550
  let pendingAssistantSegment: PendingAssistantSegment | undefined;
534
551
  let compactionInProgress = false;
535
552
  let compactionOwnedActivity = false;
553
+ let uiPromptInProgress = false;
536
554
  const ensureActivity = (
537
555
  activeTelegramTarget?: TelegramActivityTarget,
538
556
  ): string => {
@@ -592,6 +610,7 @@ export function createTelegramActivityRuntime(deps: {
592
610
  pendingAssistantSegment = undefined;
593
611
  compactionInProgress = false;
594
612
  compactionOwnedActivity = false;
613
+ uiPromptInProgress = false;
595
614
  };
596
615
  const abandonCompaction = (): void => {
597
616
  if (!compactionInProgress) return;
@@ -685,6 +704,16 @@ export function createTelegramActivityRuntime(deps: {
685
704
  onCompactionAbandoned() {
686
705
  abandonCompaction();
687
706
  },
707
+ onUiPromptStart(kind, title) {
708
+ if (!activityId || uiPromptInProgress) return;
709
+ uiPromptInProgress = true;
710
+ emit({ type: "ui-prompt-start", kind, title });
711
+ },
712
+ onUiPromptEnd() {
713
+ if (!activityId || !uiPromptInProgress) return;
714
+ uiPromptInProgress = false;
715
+ emit({ type: "ui-prompt-end" });
716
+ },
688
717
  onAgentEnd() {
689
718
  if (activityId) emit({ type: "agent-end" });
690
719
  },
@@ -712,7 +741,6 @@ export interface TelegramAssistantOutputRuntime {
712
741
  }
713
742
 
714
743
  export function createTelegramAssistantOutputRuntime<TAuthority = undefined>(deps: {
715
- isEnabled: () => boolean;
716
744
  captureAuthority?: () => TAuthority;
717
745
  isAuthorityActive?: (authority: TAuthority) => boolean;
718
746
  canDeliver: (event: TelegramAssistantSegmentEvent) => boolean;
@@ -732,10 +760,9 @@ export function createTelegramAssistantOutputRuntime<TAuthority = undefined>(dep
732
760
  const admitted = new Set<string>();
733
761
  const isEligibleEvent = (event: TelegramAssistantSegmentEvent): boolean =>
734
762
  (event.source === "telegram" && event.placement === "intermediate") ||
735
- ((event.source === "local" ||
736
- event.source === "autonomous" ||
737
- event.source === "unknown") &&
738
- deps.isEnabled());
763
+ event.source === "local" ||
764
+ event.source === "autonomous" ||
765
+ event.source === "unknown";
739
766
 
740
767
  return {
741
768
  start() {
package/lib/bindings.ts CHANGED
@@ -337,7 +337,6 @@ export interface TelegramAssistantOutputBindingRuntime<TTransportStamp> {
337
337
  export function createTelegramAssistantOutputBindingRuntime<
338
338
  TTransportStamp,
339
339
  >(deps: {
340
- isEnabled: () => boolean;
341
340
  authority: {
342
341
  getPreferredTarget: () =>
343
342
  | OutboundAttachments.TelegramQueuedOutboundAttachmentTurnView["target"]
@@ -364,7 +363,6 @@ export function createTelegramAssistantOutputBindingRuntime<
364
363
  deps.sender,
365
364
  );
366
365
  const runtime = Activity.createTelegramAssistantOutputRuntime({
367
- isEnabled: deps.isEnabled,
368
366
  ...authority,
369
367
  async send(event, authority, isAuthorityActive) {
370
368
  await deps.waitForActivityIdle?.();
@@ -1012,8 +1010,9 @@ export function registerTelegramLifecycleRuntimeHooks({
1012
1010
  const agentStartWithDedupReset = Lifecycle.createAgentStartDedupHook(
1013
1011
  agentLifecycleHooks.onAgentStart,
1014
1012
  );
1013
+ let uiPromptActive = false;
1015
1014
  const startAgentActivityTypingLoop = (ctx: Pi.ExtensionContext): boolean => {
1016
- if (!canSendAgentActivity(ctx)) return false;
1015
+ if (uiPromptActive || !canSendAgentActivity(ctx)) return false;
1017
1016
  const turn = activeTurnRuntime.get();
1018
1017
  const target = turn?.target ?? proactivePushTargetGetter();
1019
1018
  promptDispatchRuntime.startTypingLoop(ctx, turn?.chatId ?? target?.chatId, {
@@ -1022,12 +1021,15 @@ export function registerTelegramLifecycleRuntimeHooks({
1022
1021
  return true;
1023
1022
  };
1024
1023
  const startActiveTurnTypingLoop = (ctx: Pi.ExtensionContext): void => {
1024
+ if (uiPromptActive) return;
1025
1025
  const turn = activeTurnRuntime.get();
1026
1026
  promptDispatchRuntime.startTypingLoop(ctx, turn?.chatId, {
1027
1027
  target: turn?.target,
1028
1028
  });
1029
1029
  };
1030
1030
  let observedAutomaticCompaction = false;
1031
+ let agentWorkActive = false;
1032
+ let terminalAssistantMessagePendingDelivery = false;
1031
1033
  const deferredAutomaticCompactionNotices: string[] = [];
1032
1034
  const sendCompactionNotice = async (text: string): Promise<void> => {
1033
1035
  const turn = activeTurnRuntime.get();
@@ -1094,6 +1096,9 @@ export function registerTelegramLifecycleRuntimeHooks({
1094
1096
  activityVerbosityRuntime?.reset();
1095
1097
  assistantOutputRuntime.stop();
1096
1098
  observedAutomaticCompaction = false;
1099
+ agentWorkActive = false;
1100
+ terminalAssistantMessagePendingDelivery = false;
1101
+ uiPromptActive = false;
1097
1102
  deferredAutomaticCompactionNotices.length = 0;
1098
1103
  compactionObserver.onSessionShutdown();
1099
1104
  if (event.reason === "quit" && disconnectOnQuit) {
@@ -1116,7 +1121,7 @@ export function registerTelegramLifecycleRuntimeHooks({
1116
1121
  activityRuntime.onCompactionStart(Pi.getSessionCompactionReason(event));
1117
1122
  compactionObserver.onSessionBeforeCompact(event, ctx);
1118
1123
  if (shouldNotify) {
1119
- if (activeTurnRuntime.has()) {
1124
+ if (terminalAssistantMessagePendingDelivery) {
1120
1125
  deferredAutomaticCompactionNotices.push(
1121
1126
  Commands.TELEGRAM_COMPACTION_STARTED_MARKDOWN,
1122
1127
  );
@@ -1135,10 +1140,7 @@ export function registerTelegramLifecycleRuntimeHooks({
1135
1140
  compactionObserver.onSessionCompact(event, ctx);
1136
1141
  if (observedAutomaticCompaction) {
1137
1142
  observedAutomaticCompaction = false;
1138
- if (
1139
- activeTurnRuntime.has() ||
1140
- deferredAutomaticCompactionNotices.length > 0
1141
- ) {
1143
+ if (deferredAutomaticCompactionNotices.length > 0) {
1142
1144
  deferredAutomaticCompactionNotices.push(
1143
1145
  Commands.TELEGRAM_COMPACTION_COMPLETED_MARKDOWN,
1144
1146
  );
@@ -1149,8 +1151,27 @@ export function registerTelegramLifecycleRuntimeHooks({
1149
1151
  }
1150
1152
  }
1151
1153
  },
1154
+ async onSessionCompactFailed(event, ctx) {
1155
+ if (!isSessionContextActive(ctx)) return;
1156
+ const shouldNotify = observedAutomaticCompaction;
1157
+ const deferredNotices = deferredAutomaticCompactionNotices.splice(0);
1158
+ const shouldDefer =
1159
+ deferredNotices.length > 0 || terminalAssistantMessagePendingDelivery;
1160
+ compactionObserver.onSessionCompactFailed(event, ctx);
1161
+ if (!shouldNotify) return;
1162
+ const notice = event.aborted
1163
+ ? "**⚠️ Compaction cancelled.**"
1164
+ : "**⚠️ Compaction failed.**";
1165
+ if (shouldDefer) {
1166
+ deferredAutomaticCompactionNotices.push(...deferredNotices, notice);
1167
+ } else {
1168
+ await sendCompactionNotice(notice);
1169
+ }
1170
+ },
1152
1171
  async onAgentStart(event, ctx) {
1153
1172
  if (!isSessionContextActive(ctx)) return;
1173
+ agentWorkActive = true;
1174
+ terminalAssistantMessagePendingDelivery = false;
1154
1175
  await agentStartWithDedupReset(event, ctx);
1155
1176
  activityRuntime.onAgentStart(activeTurnRuntime.get()?.target);
1156
1177
  startAgentActivityTypingLoop(ctx);
@@ -1195,19 +1216,45 @@ export function registerTelegramLifecycleRuntimeHooks({
1195
1216
  }
1196
1217
  await messageActivityHooks.onMessageUpdate(event, ctx);
1197
1218
  },
1219
+ onMessageEnd(event, ctx) {
1220
+ if (!isSessionContextActive(ctx)) return;
1221
+ terminalAssistantMessagePendingDelivery =
1222
+ event.message.role === "assistant" &&
1223
+ event.message.stopReason !== "toolUse" &&
1224
+ event.message.stopReason !== "error" &&
1225
+ event.message.stopReason !== "aborted";
1226
+ },
1227
+ onUiPromptStart(event, ctx) {
1228
+ if (!isSessionContextActive(ctx)) return;
1229
+ uiPromptActive = true;
1230
+ typing.stop();
1231
+ activityRuntime.onUiPromptStart(event.kind, event.title);
1232
+ updateStatus(ctx);
1233
+ },
1234
+ onUiPromptEnd(_event, ctx) {
1235
+ if (!isSessionContextActive(ctx)) return;
1236
+ uiPromptActive = false;
1237
+ activityRuntime.onUiPromptEnd();
1238
+ if (agentWorkActive || lifecycle.isCompactionInProgress()) {
1239
+ startAgentActivityTypingLoop(ctx);
1240
+ }
1241
+ updateStatus(ctx);
1242
+ },
1198
1243
  async onAgentEnd(event, ctx) {
1199
1244
  if (!isSessionContextActive(ctx)) return;
1200
1245
  activityRuntime.onAgentEnd();
1201
1246
  await agentLifecycleHooks.onAgentEnd(event, ctx);
1247
+ },
1248
+ async onAgentSettled(event, ctx) {
1249
+ if (!isSessionContextActive(ctx)) return;
1250
+ await agentLifecycleHooks.onAgentSettled(event, ctx);
1202
1251
  if (deferredAutomaticCompactionNotices.length > 0) {
1203
1252
  await waitForActiveTurnDelivery();
1204
1253
  if (!isSessionContextActive(ctx)) return;
1205
1254
  await flushDeferredAutomaticCompactionNotices();
1206
1255
  }
1207
- },
1208
- async onAgentSettled(event, ctx) {
1209
- if (!isSessionContextActive(ctx)) return;
1210
- await agentLifecycleHooks.onAgentSettled(event, ctx);
1256
+ terminalAssistantMessagePendingDelivery = false;
1257
+ agentWorkActive = false;
1211
1258
  activityRuntime.onAgentSettled();
1212
1259
  modelContextAvailabilityRuntime.reconcile();
1213
1260
  },
package/lib/bus-leader.ts CHANGED
@@ -335,7 +335,7 @@ export function createTelegramBusInstanceLifecycleAnnouncement(input: {
335
335
  }): TelegramBusFollowerLifecycleAnnouncement {
336
336
  return {
337
337
  target: { ...input.target },
338
- text: `📡 Instance <b>${formatTelegramBusInstanceLabel(input)}</b> ${input.state}.`,
338
+ text: `<b>📡 Instance <i>${formatTelegramBusInstanceLabel(input)}</i> ${input.state}.</b>`,
339
339
  parseMode: "HTML",
340
340
  };
341
341
  }
package/lib/config.ts CHANGED
@@ -105,7 +105,6 @@ export interface TelegramConfig {
105
105
  assistant?: {
106
106
  draftPreviews?: boolean;
107
107
  rendering?: TelegramAssistantRenderingMode;
108
- proactivePush?: boolean;
109
108
  activity?: TelegramActivityVerbosity;
110
109
  timeInjection?: TelegramTimeMode;
111
110
  /** @deprecated use activity */
@@ -454,10 +453,32 @@ function omitTelegramRootProfileFields(config: TelegramConfig): TelegramConfig {
454
453
  return sharedConfig;
455
454
  }
456
455
 
456
+ function omitRetiredProactivePush(config: TelegramConfig): {
457
+ config: TelegramConfig;
458
+ changed: boolean;
459
+ } {
460
+ const assistant = config.assistant as
461
+ | (NonNullable<TelegramConfig["assistant"]> & { proactivePush?: unknown })
462
+ | undefined;
463
+ if (!assistant || !Object.hasOwn(assistant, "proactivePush")) {
464
+ return { config, changed: false };
465
+ }
466
+ const { proactivePush: _proactivePush, ...remainingAssistant } = assistant;
467
+ const next = { ...config };
468
+ if (Object.keys(remainingAssistant).length > 0) {
469
+ next.assistant = remainingAssistant;
470
+ } else {
471
+ delete next.assistant;
472
+ }
473
+ return { config: next, changed: true };
474
+ }
475
+
457
476
  export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
458
477
  config: TelegramConfig;
459
478
  changed: boolean;
460
479
  } {
480
+ const retiredProactivePush = omitRetiredProactivePush(config);
481
+ config = retiredProactivePush.config;
461
482
  const hasLegacyRootProfile = [
462
483
  "botToken",
463
484
  "botUsername",
@@ -465,7 +486,9 @@ export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
465
486
  "allowedUserId",
466
487
  "lastUpdateId",
467
488
  ].some((field) => Object.hasOwn(config, field));
468
- if (!hasLegacyRootProfile) return { config, changed: false };
489
+ if (!hasLegacyRootProfile) {
490
+ return { config, changed: retiredProactivePush.changed };
491
+ }
469
492
  const canonicalProfile = config.profiles?.[TELEGRAM_DEFAULT_PROFILE_NAME];
470
493
  const legacyToken = config.botToken?.trim();
471
494
  if (Object.hasOwn(config, "botToken") && !legacyToken) {
@@ -712,27 +735,6 @@ export function createTelegramConfigStore(
712
735
  };
713
736
  }
714
737
 
715
- export function createTelegramProactivePushChecker(
716
- configStore: Pick<TelegramConfigStore, "get">,
717
- ): () => boolean {
718
- return () => configStore.get().assistant?.proactivePush ?? true;
719
- }
720
-
721
- export function createTelegramProactivePushSetter(
722
- configStore: TelegramMutableConfigStore,
723
- ): (enabled: boolean) => Promise<void> {
724
- return async (enabled) => {
725
- await loadLatestTelegramConfig(configStore);
726
- const current = configStore.get();
727
- const config: TelegramConfig = {
728
- ...current,
729
- assistant: { ...current.assistant, proactivePush: enabled },
730
- };
731
- configStore.set(config);
732
- await configStore.persist(config);
733
- };
734
- }
735
-
736
738
  export function createTelegramDraftPreviewsChecker(
737
739
  configStore: Pick<TelegramConfigStore, "get">,
738
740
  ): () => boolean {
@@ -1019,8 +1021,6 @@ export function createTelegramConfigControls(
1019
1021
  configStore: TelegramMutableConfigStore,
1020
1022
  ) {
1021
1023
  return {
1022
- isProactivePushEnabled: createTelegramProactivePushChecker(configStore),
1023
- setProactivePushEnabled: createTelegramProactivePushSetter(configStore),
1024
1024
  areDraftPreviewsEnabled: createTelegramDraftPreviewsChecker(configStore),
1025
1025
  setDraftPreviewsEnabled: createTelegramDraftPreviewsSetter(configStore),
1026
1026
  getAssistantRenderingMode:
package/lib/inbound.ts CHANGED
@@ -19,7 +19,7 @@ import { getTelegramVoiceTranscriptionProviders } from "./voice.ts";
19
19
 
20
20
  const DEFAULT_INBOUND_HANDLER_TIMEOUT_MS = 120_000;
21
21
  const INBOUND_HANDLER_REGISTRY_KEY = "__piTelegramInboundHandlers__";
22
- const MAX_INBOUND_HANDLER_OUTPUT_LENGTH = 12_000;
22
+ const MAX_INBOUND_HANDLER_OUTPUT_LENGTH = 24_000;
23
23
  const MAX_INBOUND_HANDLER_FAILURE_STREAM_LENGTH = 4_000;
24
24
 
25
25
  type TelegramInboundCommandTemplateConfig =
package/lib/lifecycle.ts CHANGED
@@ -16,13 +16,17 @@ import type {
16
16
  ExtensionAPI,
17
17
  ExtensionContext,
18
18
  InputEvent,
19
+ MessageEndEvent,
19
20
  SessionBeforeCompactEvent,
20
21
  SessionCompactEvent,
22
+ SessionCompactFailedEvent,
21
23
  SessionShutdownEvent,
22
24
  SessionStartEvent,
23
25
  ToolExecutionEndEvent,
24
26
  ToolExecutionStartEvent,
25
27
  ToolExecutionUpdateEvent,
28
+ UIPromptEndEvent,
29
+ UIPromptStartEvent,
26
30
  } from "./pi.ts";
27
31
 
28
32
  let resetTransportReplyDedupFn: (() => void) | undefined;
@@ -78,6 +82,10 @@ export interface TelegramLifecycleRegistrationDeps {
78
82
  event: SessionCompactEvent,
79
83
  ctx: ExtensionContext,
80
84
  ) => Promise<void> | void;
85
+ onSessionCompactFailed?: (
86
+ event: SessionCompactFailedEvent,
87
+ ctx: ExtensionContext,
88
+ ) => Promise<void> | void;
81
89
  onBeforeAgentStart: (
82
90
  event: TelegramBeforeAgentStartEvent,
83
91
  ctx: ExtensionContext,
@@ -113,6 +121,18 @@ export interface TelegramLifecycleRegistrationDeps {
113
121
  },
114
122
  ctx: ExtensionContext,
115
123
  ) => Promise<void>;
124
+ onMessageEnd?: (
125
+ event: MessageEndEvent,
126
+ ctx: ExtensionContext,
127
+ ) => Promise<void> | void;
128
+ onUiPromptStart?: (
129
+ event: UIPromptStartEvent,
130
+ ctx: ExtensionContext,
131
+ ) => Promise<void> | void;
132
+ onUiPromptEnd?: (
133
+ event: UIPromptEndEvent,
134
+ ctx: ExtensionContext,
135
+ ) => Promise<void> | void;
116
136
  onAgentEnd: (event: AgentEndEvent, ctx: ExtensionContext) => Promise<void>;
117
137
  onAgentSettled?: (
118
138
  event: AgentSettledEvent,
@@ -416,6 +436,10 @@ export interface TelegramCompactionObserverRuntime<TContext> {
416
436
  ctx: TContext,
417
437
  ) => void;
418
438
  onSessionCompact: (event: SessionCompactEvent, ctx: TContext) => void;
439
+ onSessionCompactFailed: (
440
+ event: SessionCompactFailedEvent,
441
+ ctx: TContext,
442
+ ) => void;
419
443
  onSessionShutdown: () => void;
420
444
  }
421
445
 
@@ -471,6 +495,16 @@ export function createTelegramCompactionObserverRuntime<TContext>(
471
495
  deps.updateStatus(ctx);
472
496
  requestDispatch();
473
497
  },
498
+ onSessionCompactFailed: (_event, ctx) => {
499
+ clearFallbackTimer();
500
+ if (deps.isContextActive && !deps.isContextActive(ctx)) return;
501
+ deps.setCompactionInProgress(false);
502
+ if (typingStartedByObserver) deps.stopTypingLoop?.();
503
+ typingStartedByObserver = false;
504
+ deps.updateStatus(ctx);
505
+ deps.onCompactionAbandoned?.();
506
+ requestDispatch();
507
+ },
474
508
  onSessionShutdown: () => {
475
509
  clearFallbackTimer();
476
510
  if (typingStartedByObserver) deps.stopTypingLoop?.();
@@ -590,6 +624,10 @@ export function registerTelegramLifecycleHooks(
590
624
  if (!isActive(ctx)) return;
591
625
  await deps.onSessionCompact?.(event, ctx);
592
626
  });
627
+ pi.on("session_compact_failed", async (event, ctx) => {
628
+ if (!isActive(ctx)) return;
629
+ await deps.onSessionCompactFailed?.(event, ctx);
630
+ });
593
631
  // The Pi SDK still types this result as a string; compatible runtimes may
594
632
  // preserve ordered system prompt blocks through the same public hook.
595
633
  const registerBeforeAgentStart = pi.on.bind(pi) as unknown as (
@@ -630,6 +668,18 @@ export function registerTelegramLifecycleHooks(
630
668
  if (!isActive(ctx)) return;
631
669
  await deps.onMessageUpdate(event, ctx);
632
670
  });
671
+ pi.on("message_end", async (event, ctx) => {
672
+ if (!isActive(ctx)) return;
673
+ await deps.onMessageEnd?.(event, ctx);
674
+ });
675
+ pi.on("ui_prompt_start", async (event, ctx) => {
676
+ if (!isActive(ctx)) return;
677
+ await deps.onUiPromptStart?.(event, ctx);
678
+ });
679
+ pi.on("ui_prompt_end", async (event, ctx) => {
680
+ if (!isActive(ctx)) return;
681
+ await deps.onUiPromptEnd?.(event, ctx);
682
+ });
633
683
  pi.on("agent_end", async (event, ctx) => {
634
684
  if (!isActive(ctx)) return;
635
685
  await deps.onAgentEnd(event, ctx);
@@ -21,7 +21,6 @@ import type { TelegramVoiceReplyMode } from "./voice.ts";
21
21
  export type TelegramSettingsMenuReplyMarkup = TelegramInlineKeyboardMarkup;
22
22
 
23
23
  export interface TelegramSettingsStateDeps {
24
- isProactivePushEnabled: () => boolean;
25
24
  areDraftPreviewsEnabled: () => boolean;
26
25
  getAssistantRenderingMode: () => TelegramAssistantRenderingMode;
27
26
  getActivityVerbosity: () => TelegramActivityVerbosity;
@@ -32,7 +31,6 @@ export interface TelegramSettingsStateDeps {
32
31
  }
33
32
 
34
33
  export interface TelegramSettingsMutationDeps extends TelegramSettingsStateDeps {
35
- setProactivePushEnabled: (enabled: boolean) => Promise<void>;
36
34
  setDraftPreviewsEnabled: (enabled: boolean) => Promise<void>;
37
35
  setAssistantRenderingMode: (
38
36
  mode: TelegramAssistantRenderingMode,
@@ -139,7 +137,6 @@ export interface TelegramSettingsMenuRuntimeDeps<
139
137
  export const SETTINGS_MENU_TITLE = "<b>⚙️ Settings:</b>";
140
138
  export const AUTOMATIC_THREAD_CLEANUP_SETTINGS_TITLE =
141
139
  "<b>🧹 Thread cleanup:</b>";
142
- export const PROACTIVE_PUSH_SETTINGS_TITLE = "<b>📌 Proactive push:</b>";
143
140
  export const DRAFT_PREVIEWS_SETTINGS_TITLE = "<b>📝 Draft previews:</b>";
144
141
  export const ASSISTANT_RENDERING_SETTINGS_TITLE =
145
142
  "<b>🧾 Assistant rendering:</b>";
@@ -181,19 +178,6 @@ export function buildAutomaticThreadCleanupSettingsText(
181
178
  ].join("\n");
182
179
  }
183
180
 
184
- export function buildProactivePushSettingsText(
185
- proactivePushEnabled: boolean,
186
- ): string {
187
- return [
188
- `${PROACTIVE_PUSH_SETTINGS_TITLE} <code>${proactivePushEnabled ? "on" : "off"}</code>`,
189
- "",
190
- "Control whether public assistant output from local/autonomous work is projected to Telegram.",
191
- "",
192
- "<code>-</code> <code>on</code> (default): send each completed public block, including visible checkpoints and the final answer, while connected.",
193
- "<code>-</code> <code>off</code>: keep local/autonomous assistant blocks in Pi; Telegram-originated replies still use their normal delivery path.",
194
- ].join("\n");
195
- }
196
-
197
181
  export function buildDraftPreviewsSettingsText(enabled: boolean): string {
198
182
  return [
199
183
  `${DRAFT_PREVIEWS_SETTINGS_TITLE} <code>${enabled ? "on" : "off"}</code>`,
@@ -265,7 +249,6 @@ export function buildTimeInjectionModeSettingsText(
265
249
  }
266
250
 
267
251
  export function buildTelegramSettingsMenuReplyMarkup(
268
- proactivePushEnabled: boolean,
269
252
  draftPreviewsEnabled: boolean,
270
253
  assistantRenderingModeOrVoiceReplyMode:
271
254
  TelegramAssistantRenderingMode | TelegramVoiceReplyMode,
@@ -325,10 +308,6 @@ export function buildTelegramSettingsMenuReplyMarkup(
325
308
  text: `🔬 Activity: ${activityVerbosity}`,
326
309
  callback_data: "settings:open:activity-verbosity",
327
310
  },
328
- {
329
- text: `📌 Proactive push: ${proactivePushEnabled ? "on" : "off"}`,
330
- callback_data: "settings:open:proactive",
331
- },
332
311
  {
333
312
  text: `🕒 Time injection: ${getTelegramSettingsStateValueLabel(timeInjectionMode)}`,
334
313
  callback_data: "settings:open:time-injection",
@@ -362,7 +341,6 @@ export async function openTelegramSettingsMenu<
362
341
  state,
363
342
  buildTelegramSettingsMenuText(),
364
343
  buildTelegramSettingsMenuReplyMarkup(
365
- deps.isProactivePushEnabled(),
366
344
  deps.areDraftPreviewsEnabled(),
367
345
  deps.getAssistantRenderingMode(),
368
346
  deps.getVoiceReplyMode(),
@@ -399,26 +377,6 @@ export function buildAutomaticThreadCleanupSettingsReplyMarkup(
399
377
  };
400
378
  }
401
379
 
402
- export function buildProactivePushSettingsReplyMarkup(
403
- proactivePushEnabled: boolean,
404
- ): TelegramSettingsMenuReplyMarkup {
405
- return {
406
- inline_keyboard: [
407
- [{ text: "⬆️ Back", callback_data: "settings:list" }],
408
- [
409
- {
410
- text: proactivePushEnabled ? "🟢 On" : "⚫️ On",
411
- callback_data: "settings:set:proactive:on",
412
- },
413
- {
414
- text: proactivePushEnabled ? "⚫️ Off" : "🟡 Off",
415
- callback_data: "settings:set:proactive:off",
416
- },
417
- ],
418
- ],
419
- };
420
- }
421
-
422
380
  export function buildDraftPreviewsSettingsReplyMarkup(
423
381
  enabled: boolean,
424
382
  ): TelegramSettingsMenuReplyMarkup {
@@ -521,7 +479,6 @@ export async function updateTelegramSettingsMenuMessage(
521
479
  await deps.updateSettingsMessage(
522
480
  buildTelegramSettingsMenuText(),
523
481
  buildTelegramSettingsMenuReplyMarkup(
524
- deps.isProactivePushEnabled(),
525
482
  deps.areDraftPreviewsEnabled(),
526
483
  deps.getAssistantRenderingMode(),
527
484
  deps.getVoiceReplyMode(),
@@ -544,16 +501,6 @@ export async function updateAutomaticThreadCleanupSettingsMessage(
544
501
  );
545
502
  }
546
503
 
547
- export async function updateProactivePushSettingsMessage(
548
- deps: TelegramSettingsMenuCallbackDeps,
549
- ): Promise<void> {
550
- const proactivePushEnabled = deps.isProactivePushEnabled();
551
- await deps.updateSettingsMessage(
552
- buildProactivePushSettingsText(proactivePushEnabled),
553
- buildProactivePushSettingsReplyMarkup(proactivePushEnabled),
554
- );
555
- }
556
-
557
504
  export async function updateDraftPreviewsSettingsMessage(
558
505
  deps: TelegramSettingsMenuCallbackDeps,
559
506
  ): Promise<void> {
@@ -621,9 +568,16 @@ export async function handleTelegramSettingsMenuCallbackAction(
621
568
  await deps.answerCallbackQuery(callbackQueryId);
622
569
  return true;
623
570
  }
624
- if (data === "settings:open:proactive") {
625
- await updateProactivePushSettingsMessage(deps);
626
- await deps.answerCallbackQuery(callbackQueryId);
571
+ if (
572
+ data === "settings:open:proactive" ||
573
+ data === "settings:set:proactive:on" ||
574
+ data === "settings:set:proactive:off"
575
+ ) {
576
+ await updateTelegramSettingsMenuMessage(deps, deps.sectionRegistry);
577
+ await deps.answerCallbackQuery(
578
+ callbackQueryId,
579
+ "Public assistant output is always delivered while Telegram is connected.",
580
+ );
627
581
  return true;
628
582
  }
629
583
  if (
@@ -751,19 +705,6 @@ export async function handleTelegramSettingsMenuCallbackAction(
751
705
  );
752
706
  return true;
753
707
  }
754
- if (
755
- data === "settings:set:proactive:on" ||
756
- data === "settings:set:proactive:off"
757
- ) {
758
- const enabled = data.endsWith(":on");
759
- await deps.setProactivePushEnabled(enabled);
760
- await updateProactivePushSettingsMessage(deps);
761
- await deps.answerCallbackQuery(
762
- callbackQueryId,
763
- `Proactive push ${enabled ? "enabled" : "disabled"}`,
764
- );
765
- return true;
766
- }
767
708
  await deps.answerCallbackQuery(callbackQueryId);
768
709
  return true;
769
710
  }
@@ -781,7 +722,6 @@ export function createTelegramSettingsMenuRuntime<
781
722
  return openTelegramSettingsMenu(
782
723
  {
783
724
  getModelMenuState: () => deps.getModelMenuState(chatId, ctx),
784
- isProactivePushEnabled: deps.isProactivePushEnabled,
785
725
  areDraftPreviewsEnabled: deps.areDraftPreviewsEnabled,
786
726
  getAssistantRenderingMode: deps.getAssistantRenderingMode,
787
727
  getActivityVerbosity: deps.getActivityVerbosity,
@@ -805,7 +745,6 @@ export function createTelegramSettingsMenuRuntime<
805
745
  await deps.reloadConfig?.();
806
746
  return updateTelegramSettingsMenuMessage(
807
747
  {
808
- isProactivePushEnabled: deps.isProactivePushEnabled,
809
748
  areDraftPreviewsEnabled: deps.areDraftPreviewsEnabled,
810
749
  getAssistantRenderingMode: deps.getAssistantRenderingMode,
811
750
  getActivityVerbosity: deps.getActivityVerbosity,
@@ -849,7 +788,6 @@ export function createTelegramSettingsMenuRuntime<
849
788
  deps.storeModelMenuState(state);
850
789
  }
851
790
  return handleTelegramSettingsMenuCallbackAction(query.id, query.data, {
852
- isProactivePushEnabled: deps.isProactivePushEnabled,
853
791
  areDraftPreviewsEnabled: deps.areDraftPreviewsEnabled,
854
792
  getAssistantRenderingMode: deps.getAssistantRenderingMode,
855
793
  getActivityVerbosity: deps.getActivityVerbosity,
@@ -857,7 +795,6 @@ export function createTelegramSettingsMenuRuntime<
857
795
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
858
796
  getTimeInjectionMode: deps.getTimeInjectionMode,
859
797
  isAutomaticThreadCleanupEnabled: deps.isAutomaticThreadCleanupEnabled,
860
- setProactivePushEnabled: deps.setProactivePushEnabled,
861
798
  setDraftPreviewsEnabled: deps.setDraftPreviewsEnabled,
862
799
  setAssistantRenderingMode: deps.setAssistantRenderingMode,
863
800
  setActivityVerbosity: deps.setActivityVerbosity,
package/lib/pi.ts CHANGED
@@ -14,11 +14,14 @@ import {
14
14
  type ExtensionCommandContext,
15
15
  type ExtensionContext,
16
16
  type InputEvent,
17
+ type MessageEndEvent,
17
18
  type SessionBeforeCompactEvent,
18
19
  type SessionCompactEvent,
19
20
  type SessionShutdownEvent,
20
21
  type SessionStartEvent,
21
22
  type SlashCommandInfo,
23
+ type UIPromptEndEvent,
24
+ type UIPromptStartEvent,
22
25
  SettingsManager,
23
26
  } from "@earendil-works/pi-coding-agent";
24
27
 
@@ -32,13 +35,25 @@ export type {
32
35
  ExtensionCommandContext,
33
36
  ExtensionContext,
34
37
  InputEvent,
38
+ MessageEndEvent,
35
39
  SessionBeforeCompactEvent,
36
40
  SessionCompactEvent,
37
41
  SessionShutdownEvent,
38
42
  SessionStartEvent,
39
43
  SlashCommandInfo,
44
+ UIPromptEndEvent,
45
+ UIPromptStartEvent,
40
46
  };
41
47
 
48
+ export interface SessionCompactFailedEvent {
49
+ type: "session_compact_failed";
50
+ reason: "manual" | "threshold" | "overflow";
51
+ errorMessage?: string;
52
+ aborted: boolean;
53
+ willRetry: boolean;
54
+ fromExtension: boolean;
55
+ }
56
+
42
57
  export interface ToolExecutionStartEvent {
43
58
  type: "tool_execution_start";
44
59
  toolCallId: string;
package/lib/status.ts CHANGED
@@ -14,6 +14,7 @@ export interface TelegramUsageStats {
14
14
  totalCacheRead: number;
15
15
  totalCacheWrite: number;
16
16
  totalCost: number;
17
+ latestCacheHitRate?: number;
17
18
  }
18
19
 
19
20
  interface TelegramUsageMessage {
@@ -1473,6 +1474,9 @@ function collectUsageStats(ctx: TelegramStatusContext): TelegramUsageStats {
1473
1474
  stats.totalCacheRead += usage.cacheRead;
1474
1475
  stats.totalCacheWrite += usage.cacheWrite;
1475
1476
  stats.totalCost += usage.cost.total;
1477
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
1478
+ stats.latestCacheHitRate =
1479
+ promptTokens > 0 ? (usage.cacheRead / promptTokens) * 100 : undefined;
1476
1480
  }
1477
1481
  return stats;
1478
1482
  }
@@ -1494,6 +1498,12 @@ function buildUsageSummary(stats: TelegramUsageStats): string | undefined {
1494
1498
  tokenParts.push(`R${formatTokens(stats.totalCacheRead)}`);
1495
1499
  if (stats.totalCacheWrite)
1496
1500
  tokenParts.push(`W${formatTokens(stats.totalCacheWrite)}`);
1501
+ if (
1502
+ (stats.totalCacheRead > 0 || stats.totalCacheWrite > 0) &&
1503
+ stats.latestCacheHitRate !== undefined
1504
+ ) {
1505
+ tokenParts.push(`CH${stats.latestCacheHitRate.toFixed(1)}%`);
1506
+ }
1497
1507
  return tokenParts.length > 0 ? tokenParts.join(" ") : undefined;
1498
1508
  }
1499
1509
 
@@ -1549,7 +1559,7 @@ export function buildStatusHtml(
1549
1559
  const usageSummary = buildUsageSummary(stats);
1550
1560
  const costSummary = buildCostSummary(stats, usesSubscription);
1551
1561
  if (usageSummary) {
1552
- lines.push(buildStatusRow("Usage", usageSummary));
1562
+ lines.push(buildStatusRow("Tokens", usageSummary));
1553
1563
  }
1554
1564
  if (costSummary) {
1555
1565
  lines.push(buildStatusRow("Cost", costSummary));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.39.5",
3
+ "version": "0.41.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -71,9 +71,9 @@
71
71
  "image": "https://raw.githubusercontent.com/llblab/pi-telegram/main/screenshot.png"
72
72
  },
73
73
  "peerDependencies": {
74
- "@earendil-works/pi-agent-core": ">=0.80.6",
75
- "@earendil-works/pi-ai": ">=0.80.6",
76
- "@earendil-works/pi-coding-agent": ">=0.80.6",
74
+ "@earendil-works/pi-agent-core": ">=0.84.4",
75
+ "@earendil-works/pi-ai": ">=0.84.4",
76
+ "@earendil-works/pi-coding-agent": ">=0.84.4",
77
77
  "@sinclair/typebox": "*"
78
78
  },
79
79
  "devDependencies": {
@@ -136,11 +136,11 @@ Use the transport's canonical prompt-button syntax. For pi-telegram, one top-lev
136
136
 
137
137
  Model the control surface as an ordered ragged sequence of independently sized rows, not as a rectangular matrix to fill. Rectangular grids are one specialization for genuinely spatial or coordinate-bearing state; most interfaces should vary row width according to hierarchy, grouping, label pressure, and action priority.
138
138
 
139
- - Default to one full-width button per row for non-spatial controls. Put controls in one compact row only when they are genuine peers that answer the same local question or form one coherent toolbar/navigation group **and** their rendered labels comfortably fit a narrow phone-width chat.
139
+ - Default to one full-width button per row for non-spatial controls. In pi-telegram matrix syntax, emit these vertical controls as top-level cells without wrapping them in a nested row array. A nested array explicitly opts into horizontal grouping and is justified only when the controls are genuine peers and every label is unmistakably compact—roughly no more than 15 visible characters including its emoji and space. This is a judgment heuristic, not a request to mechanically count characters: if fit is uncertain, wording is multi-phrase, or truncation is plausible, keep top-level vertical buttons.
140
140
  - Use a singleton full-width row for a structurally independent, pinned, primary, summary, or high-consequence action, and whenever label length makes horizontal grouping cramped or ambiguous.
141
141
  - Vary row widths intentionally—for example `1 → 2 → 4 → 1 → 2`—and never pad a row with empty, duplicate, or no-op controls merely to produce uniform dimensions.
142
142
  - Preserve reading order across rows: orientation and structural navigation first, primary content or choices next, secondary controls afterward, and destructive actions visibly separated when present.
143
- - Treat two columns as an earned compact mode, not the default: a pair normally fits when each label is no more than one emoji plus roughly two average-length words. If either label has more words, unusually long words, qualifiers, or likely wrapping, place each button on its own row. Use at most two columns for readable text labels; move additional peer choices into more semantic rows rather than compressing textual buttons across a phone-width line. Three through five columns are for short symbols, glyphs, coordinates, or compact codes whose position carries meaning. Six through eight may be used only for single-glyph or similarly minimal position-bearing labels whose grouping materially improves the interaction; a row of emoji-only controls may therefore legitimately use up to eight columns. Eight is the phone-width UX maximum: never generate a row of nine or more controls even though the parser has no artificial width cap. Never shorten necessary wording merely to increase row density; regroup or use full-width rows when labels need explanation, wrap ambiguously, or lose meaning without prose.
143
+ - Treat two columns as an earned compact exception, never the default: a pair normally fits only when each label is unmistakably short—approximately one emoji plus one or two short words and roughly 15 visible characters or fewer. Do not count mechanically to justify density. If either label has more words, unusually long words, qualifiers, or any plausible wrapping or ellipsis risk, place each button on its own top-level row. Use at most two columns for readable text labels; move additional peer choices into more semantic rows rather than compressing textual buttons across a phone-width line. Three through five columns are for short symbols, glyphs, coordinates, or compact codes whose position carries meaning. Six through eight may be used only for single-glyph or similarly minimal position-bearing labels whose grouping materially improves the interaction; a row of emoji-only controls may therefore legitimately use up to eight columns. Eight is the phone-width UX maximum: never generate a row of nine or more controls even though the parser has no artificial width cap. Never shorten necessary wording merely to increase row density; regroup or use full-width rows when labels need explanation, wrap ambiguously, or lose meaning without prose.
144
144
 
145
145
  Treat vertical extent independently from horizontal density. A genuinely spatial surface may retain many rows—such as an `8×16` field—when vertical continuity, coordinates, and one-glance topology matter; do not paginate merely to make its height match its width. For non-spatial collections, however, a tall button wall should yield to semantic grouping, progressive disclosure, or pagination. Keep compact state and instructions above a tall surface, preserve stable coordinates across regeneration, and avoid repeating prose between rows.
146
146
 
@@ -187,7 +187,7 @@ Button prompts must:
187
187
  - Request fresh inspection when state may have changed.
188
188
  - Avoid embedding volatile output that should be rediscovered.
189
189
 
190
- Labels stay short, distinct, and scannable. Prefer an explicit `label` over exposing a long prompt as button text. Emoji are explicitly allowed and encouraged when one consistent semantic marker improves scanning or expressiveness; keep their meaning consistent across sibling controls, avoid decorative noise, and do not rely on emoji or color alone. If buttons are unavailable, render the same control surface as a numbered choice list.
190
+ Labels stay short, distinct, and scannable. Prefer an explicit `label` over exposing a long prompt as button text. Every generated human-readable action label must start with the most semantically appropriate emoji, followed by one ASCII space and concise action text; selecting that emoji is a required design step, not optional polish. When label and prompt are intentionally identical, use the same emoji-prefixed value rather than dropping the marker through compact syntax. Keep emoji meaning consistent across sibling controls, avoid decorative noise, and do not rely on emoji or color alone. An emoji-free text label remains a transport-compatible fallback only when no honest semantic emoji can be identified after considering the action, domain, and state; convenience, label pressure, or uncertainty between several reasonable candidates is not sufficient. Genuine coordinates, established symbolic controls, and intentionally emoji-only spatial cells keep their domain grammar. If buttons are unavailable, render the same control surface as a numbered choice list.
191
191
 
192
192
  ## Capability Adapters
193
193
 
@@ -243,6 +243,8 @@ Before sending a surface, verify:
243
243
  - Complete versus filtered or adapted output is labeled honestly.
244
244
  - No secret appears in visible text or button payloads.
245
245
  - Every button carries a valid self-contained next intent and measurably shortens likely feedback.
246
+ - Every human-readable action label uses `emoji + space + text`; an emoji-free fallback has an explicit semantic reason rather than mere convenience.
247
+ - Non-spatial action buttons are top-level vertical cells by default; every nested row has an obvious compact-peer justification with no plausible truncation risk.
246
248
  - The surface preserves free-form feedback when choices are not exhaustive.
247
249
  - High-impact operations route through confirmation.
248
250
  - Back/Up and Refresh appear only when useful.