@llblab/pi-telegram 0.8.1 → 0.9.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
@@ -131,6 +131,7 @@ The canonical detailed ownership map lives in [`docs/architecture.md`](./docs/ar
131
131
  - For `/telegram-setup`, prefer the locally saved bot token over environment variables on repeat setup runs; env vars are the bootstrap path when no local token exists, and persisted `telegram.json` writes must remain atomic plus private because status/setup/polling paths may read it concurrently
132
132
  - Command help plus prompt-template commands and status/model/thinking/queue controls are driven through `/start`'s Telegram inline application menu and callback queries; the Queue button shows the queued-item count, model-menu scope/pagination controls stay at the top under Main menu, the model pagination indicator opens a compact page picker, and thinking-menu text stays a compact heading because the current level is marked by button state; `/status`, `/model`, `/thinking`, and `/queue` are hidden compatibility shortcuts
133
133
  - Shared inline-keyboard structure belongs to `keyboard`; application-control button labels, callback data, and callback behavior stay in `menu`/`menu-model`/`menu-thinking`/`menu-status`/`menu-queue` while core queue mechanics stay in `queue`
134
+ - Telegram `/settings` options should open nested detail submenus by default: checkbox options show a description plus Back, On, and Off; list options show Back plus selectable values. One-shot actions such as syncing may run directly without a submenu when there is no meaningful choice or description step.
134
135
  - Inbound text/media may be transformed through configured `inboundHandlers` before queueing; legacy `attachmentHandlers` are deprecated compatibility aliases appended after `inboundHandlers`; outbound files must flow through `telegram_attach`
135
136
  - Long Telegram text split recovery belongs to `text-groups`: keep it conservative, short-debounced, same chat/user/message-id contiguous, and gated by near-limit human text so normal rapid follow-ups and slash commands stay separate
136
137
  - Inbound handlers and command-backed outbound handlers use command templates as the standard integration contract; built-in outbound buttons use inline keyboards plus callback routing because no external command execution is needed
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0: Hidden Settings And Proactive Push
4
+
5
+ - `[Settings Menu]` Added hidden Telegram `/settings` with a proactive push checkbox detail submenu plus `/telegram-settings` in the terminal. Impact: operators can see green/black binary flag state, use green/black/yellow On/Off checkbox controls from Telegram, and toggle the same proactive push flag locally without adding a visible bot-command entry.
6
+ - `[Proactive Push]` `telegram.json` now supports `proactivePush`; when enabled, successful local non-Telegram π final replies are sent to the paired Telegram chat if no Telegram turn is active and the current session still owns the Telegram lock. Local prompt text stays private because the bot does not own or mirror terminal user messages. Impact: long local tasks can notify the phone with result context without leaking from stale bridge owners or failed/aborted turns.
7
+ - `[Queue UI]` Empty queue states now use the bottom-filled `⌛` hourglass while non-empty queue states keep `⏳`. Queue item details now show the selected queue position above the raw prompt preview, preserve reaction-specific priority emoji in the heading, and use side-by-side Priority/Normal tabs that refresh the heading marker immediately. The terminal status bar now stays yellow active while Telegram-owned work still has running tools even if a queued prompt is removed by reaction. Impact: queue emptiness has a small visual easter egg, item submenus stay oriented without changing queue semantics, and queue-removal reactions no longer visually degrade active work to connected.
8
+ - `[Model Menu]` Model rows now open a detail submenu with Back, ☑️ Activate/🟢 Active selection, and yellow/black-marked Scoped/All membership tabs. Impact: model selection remains one tap away while scoped model membership can be managed from Telegram.
9
+ - `[Status Menu]` The main Telegram menu status row now shows `compacting` while a Telegram `/compact` run is active. Impact: the phone UI reflects the same compaction state that already blocks dispatch and appears in terminal status.
10
+ - `[Prompt Guidance]` Telegram prompt injection now asks agents to target 37 visible cells for tables, dense list items, and compact text blocks. Impact: replies better fit narrow mobile Telegram screens, especially when emoji or wide glyphs are present.
11
+
12
+ ## 0.8.2: Lock-Safe Delivery
13
+
14
+ - `[Lock Safety]` Active Telegram turns now re-check singleton ownership before preview flushes and final agent-end delivery. Impact: an old π instance stays silent after another instance takes the Telegram bridge lock, even if the old instance finishes a long-running prompt later.
15
+ - `[Inbound Handlers]` The first step of an inbound composition now receives the full configured handler timeout before elapsed-time accounting starts on later steps. Impact: composition timeout behavior is deterministic and avoids one-millisecond test/runtime drift at pipeline start.
16
+ - `[Menu UI]` Model and Thinking submenu headers now include their matching command icons (`🤖` and `🧠`). Impact: submenu headings match the Queue menu's icon-led style.
17
+ - `[Package]` Bumped package metadata to `0.8.2` and kept the lockfile in sync.
18
+
3
19
  ## 0.8.1: Outbound Voice Translation Hotfix
4
20
 
5
21
  - `[Outbound Voice]` Composed voice handlers now pass the original `telegram_voice` text to the first pipeline step through stdin, then continue piping each step's stdout into the next step. Impact: translate-from-stdin voice pipelines can translate hidden voice text before TTS instead of failing with an empty first-step input.
@@ -40,7 +56,7 @@
40
56
  ## 0.7.0: Unified App Menu & Command Template Hardening
41
57
 
42
58
  - `[Commands]` Visible Telegram bot command menu now exposes `/start`, `/compact`, `/next`, `/continue`, `/abort`, and `/stop`; `/help`, `/status`, `/model`, `/thinking`, and `/queue` remain hidden compatibility shortcuts. `/start`, `/help`, and `/status` open one unified app menu containing command help, status rows, and inline controls. Command emoji are centralized as fixed adornments in the commands domain and reused by matching menu buttons (`🤖` model, `🧠` thinking); `/next` uses `⏩` and `/continue` uses `▶️`. `/continue` enqueues a priority Telegram-owned `continue` prompt instead of forcing the next queued item or requiring π to be idle. Impact: the visible command surface is cleaner while existing operator muscle memory still works and skills can react to queued `continue` prompts.
43
- - `[Application Menu]` `/start` opens command help plus status rows and the inline application menu; `/queue` opens the queue section directly, the status menu Queue button shows the current queued-item count, all submenus keep Back/Main menu navigation in the top row, and queued items are listed in dispatch order with numeric labels plus `⚡`/`📎` markers. Queue menu message text uses the same HTML heading style as the other inline menus; empty queue menus render bold message text with only the Main menu navigation button instead of a disabled empty-state button. Item submenus support Back, priority toggle, and Cancel, and stale item clicks refresh the live list. Impact: queued Telegram work is inspectable and mutable from the menu control surface without relying only on reactions.
59
+ - `[Application Menu]` `/start` opens command help plus status rows and the inline application menu; `/queue` opens the queue section directly, the status menu Queue button shows the current queued-item count, all submenus keep Back/Main menu navigation in the top row, and queued items are listed in dispatch order with numeric labels plus `⚡`/`📎` markers. Queue menu message text uses the same HTML heading style as the other inline menus; empty queue menus render bold message text with only the Main menu navigation button instead of a disabled empty-state button. Item submenus support Back, Priority/Normal tabs, and Cancel, and stale item clicks refresh the live list. Impact: queued Telegram work is inspectable and mutable from the menu control surface without relying only on reactions.
44
60
  - `[Prompt Templates]` `/start` now shows a separate block for π prompt-template commands, and the Telegram bot command menu registers Telegram-safe prompt-template aliases such as `fix-tests` → `/fix_tests` when they do not conflict with built-in bridge commands or hidden shortcuts. Sending `/template_name args` from Telegram expands the matching π prompt-template file before queueing the turn. Impact: reusable π workflows are available from Telegram without duplicating prompt text manually.
45
61
  - `[Keyboard]` Shared Telegram inline-keyboard reply-markup structure was extracted to `keyboard`, while `menu` owns application-control button semantics and `outbound-handlers` owns assistant-authored button semantics. Impact: inline UI domains share one Bot API shape without centralizing feature behavior.
46
62
  - `[Domain DAG]` Source-module opening comments now include `Zones:` tags for cross-cutting responsibility areas such as Telegram transport, π agent lifecycle, TUI, and shared utilities. Impact: flat files keep folder-like orientation without adding directory nesting.
package/README.md CHANGED
@@ -13,7 +13,7 @@ This repository is an actively maintained fork of [`badlogic/pi-telegram`](https
13
13
 
14
14
  ## Key Features
15
15
 
16
- - **Telegram Controls**: `/start` opens the inline application menu with command help, available π prompt templates, status rows, model, thinking, and queue sections; `/stop`, `/abort`, `/next`, and `/continue` provide queue-clear, queue-preserve, force-next, and queued-resume semantics respectively; model-switch continuation turns still use the control lane when a restart needs to resume safely.
16
+ - **Telegram Controls**: `/start` opens the inline application menu with command help, available π prompt templates, status rows, model, thinking, and queue sections; the Status row reports `compacting` during Telegram `/compact`; `/stop`, `/abort`, `/next`, and `/continue` provide queue-clear, queue-preserve, force-next, and queued-resume semantics respectively; model-switch continuation turns still use the control lane when a restart needs to resume safely.
17
17
  - **Interactive UI**: Manage your session directly from Telegram. Inline buttons expose an application menu for switching models, choosing model pages from the pagination indicator, adjusting reasoning (thinking) levels, and inspecting or mutating the waiting queue; model scope/pagination controls stay at the top of the model menu, the Queue button shows the current item count, and command emoji are reused on matching controls such as model and thinking.
18
18
  - **In-flight Model Switching**: Change the active model mid-generation. The agent gracefully pauses, applies the new model, and restarts its response without losing context.
19
19
  - **Smart Message Queue**: Messages sent while the agent is busy are queued and previewed in the π status bar, and queued turns can be reprioritized or removed with Telegram reactions or the queue section of the inline application menu.
@@ -92,9 +92,9 @@ Use these inside the Telegram DM with your bot:
92
92
 
93
93
  Prompt-template commands: π prompt templates are mapped to Telegram-safe aliases (`fix-tests.md` becomes `/fix_tests`) and shown as compact command-only rows between the built-in commands and status rows in `/start`. They are not registered in the Telegram bot command menu, keeping the bot menu focused on bridge controls. Sending `/template_name args` from Telegram expands the matching π prompt-template file and queues the expanded prompt like normal Telegram work.
94
94
 
95
- Hidden compatibility shortcuts: `/help` and `/status` open the same main application menu, `/model` opens the model section, `/thinking` opens the thinking section, and `/queue` opens the queue section. They are intentionally not shown in the bot command menu.
95
+ Hidden compatibility shortcuts: `/help` and `/status` open the same main application menu, `/model` opens the model section, `/thinking` opens the thinking section, `/queue` opens the queue section, and `/settings` opens hidden bridge settings. Settings rows open detail submenus with Back plus green/black/yellow option controls such as On and Off for checkboxes. These shortcuts are intentionally not shown in the bot command menu.
96
96
 
97
- Telegram command admission is explicit: `/compact`, `/queue`, `/stop`, `/abort`, `/next`, `/help`, `/start`, `/status`, `/model`, and `/thinking` execute immediately. `/continue` is a command shortcut that enqueues a priority Telegram prompt containing `continue`. Prompt-template commands expand before queueing and then follow normal prompt-queue rules. Synthetic model-switch continuation turns still enter the high-priority control lane so they can resume before normal queued prompts when π becomes safe to dispatch.
97
+ Telegram command admission is explicit: `/compact`, `/queue`, `/settings`, `/stop`, `/abort`, `/next`, `/help`, `/start`, `/status`, `/model`, and `/thinking` execute immediately. `/continue` is a command shortcut that enqueues a priority Telegram prompt containing `continue`. Prompt-template commands expand before queueing and then follow normal prompt-queue rules. Synthetic model-switch continuation turns still enter the high-priority control lane so they can resume before normal queued prompts when π becomes safe to dispatch.
98
98
 
99
99
  ### Pi Commands
100
100
 
@@ -102,6 +102,7 @@ Run these inside π, not Telegram:
102
102
 
103
103
  - **`/telegram-setup`**: Configure or update the Telegram bot token.
104
104
  - **`/telegram-status`**: Check bridge status, connection, polling, execution, queue, and recent redacted runtime/API failure events.
105
+ - **`/telegram-settings`**: Open local bridge settings and toggle proactive push using the same `telegram.json` flag as the Telegram `/settings` menu.
105
106
  - **`/telegram-connect`**: Start polling Telegram updates in the current π session, acquire the singleton lock, or interactively move ownership here from another live instance.
106
107
  - **`/telegram-disconnect`**: Stop polling in the current π session and release the singleton lock.
107
108
 
@@ -119,6 +120,8 @@ Run these inside π, not Telegram:
119
120
 
120
121
  ### Inbound Handlers
121
122
 
123
+ `telegram.json` can set `proactivePush: true` to send successful local non-Telegram final replies to the paired Telegram chat when no Telegram turn is active. Local prompt text is not sent because the bot does not own or mirror terminal user messages. The mode is off by default, can be toggled from the hidden `/settings` menu, persists across contexts until explicitly disabled or removed from config, is gated by the current Telegram lock owner, and skips aborted or failed turns.
124
+
122
125
  `telegram.json` can define ordered `inboundHandlers` for Telegram → π preprocessing such as text translation, voice transcription, OCR, or PDF extraction. Matching handlers run before the Telegram turn enters the π queue. If a matching media/file handler fails, the next matching handler is tried as a fallback. Legacy `attachmentHandlers` still work as a deprecated compatibility alias and are appended after `inboundHandlers`.
123
126
 
124
127
  ```json
@@ -231,7 +234,7 @@ Rich previews are sent through editable messages because Telegram drafts are tex
231
234
 
232
235
  ## Status bar
233
236
 
234
- The π status bar shows the current bridge state plus queued Telegram turns as compact previews. Busy labels distinguish states such as `active`, `dispatching`, `queued`, `tool running`, `model`, and `compacting`.
237
+ The π status bar shows the current bridge state plus queued Telegram turns as compact previews. Busy labels distinguish states such as `active`, `dispatching`, `queued`, `tool running`, `model`, and `compacting`. Telegram prompt guidance asks agents to keep tables, dense list items, and compact text blocks within about 37 visible cells when possible so mobile replies stay readable.
235
238
 
236
239
  ```text
237
240
  telegram queued +3: [⚡ write a shell script…, summarize this image…, 📎 2 attachments]
@@ -33,7 +33,7 @@ Current runtime areas use these ownership boundaries:
33
33
  | `media` / `text-groups` / `turns` / `inbound-handlers` | Text/media extraction, media-group debounce, long-text split coalescing, inbound downloads, inbound text/media handler execution, turn building/editing, image reads, legacy `attachmentHandlers` compatibility |
34
34
  | `queue` | Queue item contracts, lane admission/order, stores, mutations, dispatch readiness/runtime, prompt/control enqueueing, session and agent/tool lifecycle sequencing |
35
35
  | `runtime` | Session-local coordination primitives: counters, lifecycle flags, setup guard, abort handler, typing-loop timers, prompt-dispatch flags, agent-end reset binding |
36
- | `model` / `menu-model` / `menu-thinking` / `menu-status` / `menu` / `menu-queue` / `commands` | Model identity/thinking levels, scoped model resolution, in-flight switching, model-menu UI, thinking-menu UI, status-menu UI, inline application callback composition, queue-menu UI, slash commands, bot command registration |
36
+ | `model` / `menu-model` / `menu-thinking` / `menu-status` / `menu` / `menu-queue` / `menu-settings` / `commands` | Model identity/thinking levels, scoped model resolution, in-flight switching, model-menu UI, thinking-menu UI, status-menu UI, inline application callback composition, queue-menu UI, hidden settings UI, slash commands, bot command registration |
37
37
  | `keyboard` | Shared Telegram inline-keyboard reply-markup structure; feature domains own callback semantics and button construction |
38
38
  | `preview` / `replies` / `rendering` | Preview lifecycle/transports, final reply delivery and reply parameters, Telegram HTML Markdown rendering, chunking, stable-preview snapshots |
39
39
  | `outbound-handlers` | Outbound text transformation, assistant-authored outbound comments, generated reply artifacts, inline-keyboard callbacks, and post-`agent_end` outbound action delivery |
@@ -103,7 +103,7 @@ Admission contract:
103
103
  | Priority prompt queue | A waiting prompt promoted by `👍`, `⚡️`, `❤️`, or `🕊` | `kind: prompt`, `queueLane: priority` | 1 |
104
104
  | Default prompt queue | Normal Telegram text/media turns | `kind: prompt`, `queueLane: default` | 2 |
105
105
 
106
- The command action itself carries its execution mode, and the queue domain exposes lane contracts for admission mode, dispatch rank, and allowed item kinds. Queue append and planning paths validate lane admission so a malformed control/default or other invalid lane pairing fails predictably instead of silently changing priority. This lets synthetic control actions and Telegram prompts share one stable ordering model while still rendering distinctly in status output. In the π status bar, busy labels distinguish `active`, `dispatching`, `queued`, `tool running`, `model`, and `compacting`; priority prompts and priority control items are marked with `⚡`.
106
+ The command action itself carries its execution mode, and the queue domain exposes lane contracts for admission mode, dispatch rank, and allowed item kinds. Queue append and planning paths validate lane admission so a malformed control/default or other invalid lane pairing fails predictably instead of silently changing priority. This lets synthetic control actions and Telegram prompts share one stable ordering model while still rendering distinctly in status output. In the π status bar, busy labels distinguish `active`, `dispatching`, `queued`, `tool running`, `model`, and `compacting`; priority prompts and priority control items are marked with `⚡`. If a queue mutation removes the last waiting item while Telegram-owned work still has running tools, the status remains yellow `active` instead of degrading to green `connected`.
107
107
 
108
108
  A dispatched prompt remains in the queue until `agent_start` consumes it. That keeps the active Telegram turn bound correctly for previews, attachments, abort handling, and final reply delivery.
109
109
 
@@ -115,9 +115,9 @@ Dispatch is gated by:
115
115
  - `ctx.isIdle()` being true
116
116
  - `ctx.hasPendingMessages()` being false
117
117
 
118
- This prevents queue races around rapid follow-ups, `/compact`, and mixed local plus Telegram activity. Post-agent-end dispatch retries are scheduled through a session-bound deferred dispatcher that activates on session start, cancels timers on session shutdown, and skips callbacks from older generations before they touch `ExtensionContext`. Telegram `/start` and hidden compatibility shortcuts `/status`, `/model`, `/thinking`, and `/queue` execute immediately; the dispatch controller still serializes any deferred control items so a queued control action must settle before the next queued action can dispatch.
118
+ This prevents queue races around rapid follow-ups, `/compact`, and mixed local plus Telegram activity. Post-agent-end dispatch retries are scheduled through a session-bound deferred dispatcher that activates on session start, cancels timers on session shutdown, and skips callbacks from older generations before they touch `ExtensionContext`. Telegram `/start` and hidden compatibility shortcuts `/status`, `/model`, `/thinking`, `/queue`, and `/settings` execute immediately; the dispatch controller still serializes any deferred control items so a queued control action must settle before the next queued action can dispatch.
119
119
 
120
- `/start` opens the main application menu: visible command help, compact command-only prompt-template rows when π exposes Telegram-compatible prompt-template names, status rows (`Status`, `Usage`, `Cost`, `Context`), and top-level buttons for model, thinking, and queue sections. The Queue button includes the current queued-item count. Hidden compatibility shortcuts `/help`, `/status`, `/model`, `/thinking`, and `/queue` jump directly to their corresponding menu screens. Command emoji come from the `commands` domain map so visible command descriptions and matching menu buttons share one fixed adornment source. Prompt-template commands use a fixed `🧩` marker, map π template names to Telegram-safe aliases such as `fix-tests` → `/fix_tests`, stay visible only inside the `/start` menu, and expand before queueing because `ExtensionAPI.sendUserMessage()` intentionally bypasses π prompt-template expansion for extension-originated messages. Every submenu starts with a top Back row so navigation stays anchored near the original user message above the inline keyboard; model-menu scope and pagination controls sit directly under that top row before model choices, and tapping the pagination indicator opens a compact page picker headed by `<b>Choose a page:</b>`. `menu-model` owns model-menu state, scoped model pages, model callback planning, page-picker rendering, and model-menu rendering while `model` owns core model identity/switching semantics. `menu-thinking` owns thinking-menu text, reply markup, callback handling, and message rendering. `menu-status` owns status-menu payloads, status callback handling, and status-message rendering. `menu-queue` owns queue-menu UI only: queue items are rendered under a compact `<b>Queue:</b>` heading, top-to-bottom in dispatch order, numbered, and marked with `⚡` for priority prompts or `📎` for prompts with attachments. An empty queue renders bold message text plus the top Main menu button, not a disabled empty-state button. Selecting an item opens a submenu that displays the full queued prompt text with Back, priority toggle, and Cancel. If a callback targets an item that has already left the queue, the menu refreshes the list instead of applying a stale mutation.
120
+ `/start` opens the main application menu: visible command help, compact command-only prompt-template rows when π exposes Telegram-compatible prompt-template names, status rows (`Status`, `Usage`, `Cost`, `Context`), and top-level buttons for model, thinking, and queue sections. The `Status` row reports `compacting` while a Telegram `/compact` run is active. The Queue button includes the current queued-item count. Hidden compatibility shortcuts `/help`, `/status`, `/model`, `/thinking`, and `/queue` jump directly to their corresponding menu screens, while `/settings` opens the hidden settings menu for bridge toggles such as proactive push. Settings options open detail submenus; checkbox-like settings use Back plus green/black/yellow On and Off controls instead of mutating directly from the list. Command emoji come from the `commands` domain map so visible command descriptions and matching menu buttons share one fixed adornment source. Prompt-template commands use a fixed `🧩` marker, map π template names to Telegram-safe aliases such as `fix-tests` → `/fix_tests`, stay visible only inside the `/start` menu, and expand before queueing because `ExtensionAPI.sendUserMessage()` intentionally bypasses π prompt-template expansion for extension-originated messages. Every submenu starts with a top Back row so navigation stays anchored near the original user message above the inline keyboard; model-menu pagination controls sit near the top, tapping the pagination indicator opens a compact page picker headed by `<b>Choose a page:</b>`, and tapping a model opens a detail submenu with Back, ☑️ Activate/🟢 Active selection, and yellow/black-marked Scoped/All membership tabs. `menu-model` owns model-menu state, scoped model pages, model detail rendering, scoped-list persistence planning, and model-menu rendering while `model` owns core model identity/switching semantics. `menu-thinking` owns thinking-menu text, reply markup, callback handling, and message rendering. `menu-status` owns status-menu payloads, status callback handling, and status-message rendering. `menu-queue` owns queue-menu UI only: queue items are rendered under a compact `<b>Queue:</b>` heading, top-to-bottom in dispatch order, numbered, and marked with `⚡` for priority prompts or `📎` for prompts with attachments. An empty queue renders bold message text with the bottom-filled `⌛` hourglass plus the top Main menu button, while non-empty queue states keep the running `⏳` hourglass. Selecting an item opens a submenu that displays the queue item number above the full queued prompt text with Back, side-by-side Priority/Normal tabs, and Cancel. If a callback targets an item that has already left the queue, the menu refreshes the list instead of applying a stale mutation.
121
121
 
122
122
  ### Abort Behavior
123
123
 
@@ -160,7 +160,9 @@ Telegram prompt responses use explicit delivery context to attach outbound text,
160
160
 
161
161
  Outbound files are sent only after the active Telegram turn completes, must be staged through the `telegram_attach` tool, are staged atomically per tool call, are checked against a default 50 MiB limit configurable through `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES` or `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES`, and use file-backed multipart blobs so large sends do not require preloading whole files into memory.
162
162
 
163
- Assistant-authored outbound actions use final-message markup instead of agent tool calls. Preview updates strip closed top-level HTML comments and currently open/partial top-level comment starts before rendering, so users do not see transient metadata even when streaming flushes happen after only `<`, `<!`, or `<!--`. On `agent_end`, the bridge removes top-level comments from the Markdown text reply, but treats column-zero top-level `<!-- telegram_voice ... -->` and `<!-- telegram_button ... -->` blocks specially before delivery; comments inside fenced code, quotes, lists, or indented examples stay literal, including fenced blocks with Markdown-valid indented closing fences. Voice maps to the first matching `outboundHandlers[]` entry with `type: "voice"`, synthesizes body text, `text="..."`, or colon shorthand through command-template execution, and uploads the generated OGG/Opus file via Telegram `sendVoice`; when no outbound voice handler is configured, it silently skips voice delivery. The `template: [...]` form can express TTS plus MP3-to-OGG conversion using configured templates and bridge-provided `{text}`, `{mp3}`, and `{ogg}` placeholders. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values, the default command timeout applies automatically, and each step receives the previous step's stdout on stdin by default, without hard-coded filesystem defaults. Button blocks are built in: each `telegram_button` block becomes one inline-keyboard button on the final text, and callback clicks enqueue the configured prompt text as a normal Telegram prompt turn; the `telegram_button: Label` shorthand uses the same text for label and prompt, `prompt="..."` supports explicit one-line prompts, and body-form buttons use the body as the prompt. Unknown callback data that does not match pi-telegram-owned prefixes (`tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`) is forwarded to π as `[callback] <data>` after built-in handlers decline it, giving layered extensions a simple namespaced button channel without separate polling; layered callback payloads should follow the [Callback Namespace Standard](./callback-namespaces.md). This keeps technical Markdown, code, tables, formulas, and numbered lists in the text channel when appropriate while allowing TTS-friendly voice messages and tappable continuations without invoking `telegram_attach` or extra transport tools.
163
+ Assistant-authored outbound actions use final-message markup instead of agent tool calls. Preview updates strip closed top-level HTML comments and currently open/partial top-level comment starts before rendering, so users do not see transient metadata even when streaming flushes happen after only `<`, `<!`, or `<!--`. On `agent_end`, the bridge removes top-level comments from the Markdown text reply, but treats column-zero top-level `<!-- telegram_voice ... -->` and `<!-- telegram_button ... -->` blocks specially before delivery; comments inside fenced code, quotes, lists, or indented examples stay literal, including fenced blocks with Markdown-valid indented closing fences. Voice maps to the first matching `outboundHandlers[]` entry with `type: "voice"`, synthesizes body text, `text="..."`, or colon shorthand through command-template execution, and uploads the generated OGG/Opus file via Telegram `sendVoice`; when no outbound voice handler is configured, it silently skips voice delivery. The `template: [...]` form can express TTS plus MP3-to-OGG conversion using configured templates and bridge-provided `{text}`, `{mp3}`, and `{ogg}` placeholders. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values, the default command timeout applies automatically, and each step receives the previous step's stdout on stdin by default, without hard-coded filesystem defaults. Button blocks are built in: each `telegram_button` block becomes one inline-keyboard button on the final text, and callback clicks enqueue the configured prompt text as a normal Telegram prompt turn; the `telegram_button: Label` shorthand uses the same text for label and prompt, `prompt="..."` supports explicit one-line prompts, and body-form buttons use the body as the prompt. Unknown callback data that does not match pi-telegram-owned prefixes (`tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`) is forwarded to π as `[callback] <data>` after built-in handlers decline it, giving layered extensions a simple namespaced button channel without separate polling; layered callback payloads should follow the [Callback Namespace Standard](./callback-namespaces.md). When proactive push is enabled, successful local non-Telegram final replies are sent to the paired chat. Local prompt text is not sent because the bot does not own or mirror terminal user messages. This keeps terminal-originated results visible in Telegram without changing Telegram-originated turn delivery.
164
+
165
+ This keeps technical Markdown, code, tables, formulas, and numbered lists in the text channel when appropriate while allowing TTS-friendly voice messages and tappable continuations without invoking `telegram_attach` or extra transport tools. Telegram prompt guidance targets about 37 visible cells for tables, dense list items, and compact text blocks because emoji and other wide glyphs make raw character counts misleading on mobile screens.
164
166
 
165
167
  ## Interactive Controls
166
168
 
@@ -172,12 +174,13 @@ Current operator controls include:
172
174
  - Inline application-menu buttons for model, thinking, and queue controls, applying idle selections immediately while still respecting busy-run restart rules; model-menu inputs are cached briefly and stored inline-menu states are pruned by TTL/LRU so old keyboards expire predictably
173
175
  - Hidden `/model` and `/thinking` shortcuts for opening the model and thinking sections directly while keeping settings out of the visible bot command menu
174
176
  - `/compact` for Telegram-triggered π session compaction when the bridge is idle
175
- - `/queue` for opening the queue section of the inline application menu; the same section is reachable from the status/main menu and supports top-anchored Back navigation, priority toggling, and cancellation
177
+ - `/queue` for opening the queue section of the inline application menu; the same section is reachable from the status/main menu and supports top-anchored Back navigation, Priority/Normal tabs, and cancellation
176
178
  - `/next` for dispatching the next queued turn, aborting the active run first when π is busy
177
179
  - `/continue` for enqueueing a Telegram-owned `continue` prompt, without aborting the current turn or forcing the next queued item
178
180
  - `/abort` for aborting the active Telegram-owned run while preserving queued items for manual continuation
179
181
  - `/stop` for aborting the active Telegram-owned run and clearing waiting Telegram queue items
180
182
  - `/telegram-status` for π-side diagnostics as grouped line-by-line sections separated by blank lines: connection, polling, execution, queue, and the recent redacted runtime/API event ring. These sections include polling state, last update id, active turn source ids, pending dispatch, compaction state, active tool count, pending model-switch state, total queue depth, and queue-lane counts. The event ring records transport/API, polling/update, prompt-dispatch, control-action, typing, compaction, setup, session-lifecycle, and attachment queue/delivery failures; benign unchanged edit responses and unsupported empty draft-clear attempts are filtered out so expected preview transport noise does not obscure real failures
183
+ - `/telegram-settings` for π-side bridge settings; it currently exposes proactive push as a local toggle backed by the same `telegram.json` flag as the hidden Telegram `/settings` menu
181
184
  - Queue reactions apply to waiting text, voice, file, image, and media-group turns by matching the turn's source Telegram message ids: `👍`, `⚡️`, `❤️`, and `🕊` promote waiting prompts, while `👎`, `👻`, `💔`, and `💩` remove waiting turns because ordinary Telegram DM message deletions are not exposed through the Bot API polling path this bridge uses
182
185
 
183
186
  ## In-Flight Model Switching
package/index.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  */
6
6
 
7
7
  import * as Api from "./lib/api.ts";
8
- import * as OutboundAttachments from "./lib/outbound-attachments.ts";
9
8
  import * as CommandTemplates from "./lib/command-templates.ts";
10
9
  import * as Commands from "./lib/commands.ts";
11
10
  import * as Config from "./lib/config.ts";
@@ -15,8 +14,10 @@ import * as Lifecycle from "./lib/lifecycle.ts";
15
14
  import * as Locks from "./lib/locks.ts";
16
15
  import * as Media from "./lib/media.ts";
17
16
  import * as MenuQueue from "./lib/menu-queue.ts";
17
+ import * as MenuSettings from "./lib/menu-settings.ts";
18
18
  import * as Menu from "./lib/menu.ts";
19
19
  import * as Model from "./lib/model.ts";
20
+ import * as OutboundAttachments from "./lib/outbound-attachments.ts";
20
21
  import * as OutboundHandlers from "./lib/outbound-handlers.ts";
21
22
  import * as Pi from "./lib/pi.ts";
22
23
  import * as Polling from "./lib/polling.ts";
@@ -48,8 +49,21 @@ export default function (pi: Pi.ExtensionAPI) {
48
49
  const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
49
50
  const { abort, lifecycle, queue, setup, typing } = bridgeRuntime;
50
51
  const configStore = Config.createTelegramConfigStore();
52
+ const isProactivePushEnabled =
53
+ Config.createTelegramProactivePushChecker(configStore);
54
+ const setProactivePushEnabled =
55
+ Config.createTelegramProactivePushSetter(configStore);
56
+ const proactivePromptTargetStore =
57
+ Config.createTelegramProactivePromptTargetStore();
51
58
  const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
59
+ const lockOwnershipGuard =
60
+ Locks.createTelegramLockOwnershipGuard(lockRuntime);
52
61
  const activeTurnRuntime = Queue.createTelegramActiveTurnStore();
62
+ const proactivePushChatIdGetter =
63
+ Config.createTelegramProactivePushChatIdGetter({
64
+ getActiveTurnChatId: activeTurnRuntime.getChatId,
65
+ getAllowedUserId: configStore.getAllowedUserId,
66
+ });
53
67
  const buttonActionStore = OutboundHandlers.createTelegramButtonActionStore();
54
68
  const pendingModelSwitchStore =
55
69
  Model.createPendingModelSwitchStore<
@@ -198,6 +212,7 @@ export default function (pi: Pi.ExtensionAPI) {
198
212
  sendDraft: sendMessageDraft,
199
213
  sendMessage,
200
214
  editMessageText: editTelegramMessageText,
215
+ canSend: lockOwnershipGuard.ownsCurrentProcess,
201
216
  ...replyTransport,
202
217
  });
203
218
  const { finalizeMarkdownPreview } =
@@ -246,6 +261,7 @@ export default function (pi: Pi.ExtensionAPI) {
246
261
  buildStatusHtml: Commands.createTelegramAppMenuHtmlBuilder({
247
262
  buildStatusHtml: Status.createTelegramStatusHtmlBuilder({
248
263
  getActiveModel: currentModelRuntime.get,
264
+ isCompactionInProgress: lifecycle.isCompactionInProgress,
249
265
  }),
250
266
  getPromptTemplateCommands,
251
267
  }),
@@ -276,6 +292,16 @@ export default function (pi: Pi.ExtensionAPI) {
276
292
  updateStatusMessage: menuActions.updateStatusMessage,
277
293
  updateStatus,
278
294
  });
295
+ const settingsMenuRuntime = MenuSettings.createTelegramSettingsMenuRuntime({
296
+ getModelMenuState: getQueueMenuState,
297
+ getStoredModelMenuState: modelMenuRuntime.getState,
298
+ storeModelMenuState: modelMenuRuntime.storeState,
299
+ editInteractiveMessage,
300
+ sendInteractiveMessage,
301
+ answerCallbackQuery,
302
+ isProactivePushEnabled,
303
+ setProactivePushEnabled,
304
+ });
279
305
 
280
306
  // --- Polling ---
281
307
 
@@ -297,8 +323,11 @@ export default function (pi: Pi.ExtensionAPI) {
297
323
  currentModelRuntime,
298
324
  modelSwitchController,
299
325
  menuActions,
326
+ updateSettingsMenuMessage: settingsMenuRuntime.updateSettingsMenuMessage,
300
327
  openQueueMenu: queueMenuRuntime.openQueueMenu,
301
328
  queueMenuCallbackHandler: queueMenuRuntime.handleCallbackQuery,
329
+ openSettingsMenu: settingsMenuRuntime.openSettingsMenu,
330
+ settingsMenuCallbackHandler: settingsMenuRuntime.handleCallbackQuery,
302
331
  buttonActionStore,
303
332
  inboundHandlerRuntime,
304
333
  updateStatus,
@@ -310,6 +339,10 @@ export default function (pi: Pi.ExtensionAPI) {
310
339
  downloadFile: downloadTelegramBridgeFile,
311
340
  getThinkingLevel,
312
341
  setThinkingLevel,
342
+ persistScopedModelPatterns: Pi.createScopedModelPatternPersister({
343
+ createSettingsManager: Pi.createSettingsManager,
344
+ clearCachedModelMenuInputs: modelMenuRuntime.clearCachedInputs,
345
+ }),
313
346
  setModel,
314
347
  sendUserMessage,
315
348
  isIdle,
@@ -397,6 +430,8 @@ export default function (pi: Pi.ExtensionAPI) {
397
430
  startPolling: lockedPollingRuntime.start,
398
431
  stopPolling: lockedPollingRuntime.stop,
399
432
  updateStatus,
433
+ isProactivePushEnabled,
434
+ setProactivePushEnabled,
400
435
  });
401
436
 
402
437
  // --- Lifecycle Hooks ---
@@ -459,6 +494,11 @@ export default function (pi: Pi.ExtensionAPI) {
459
494
  sendQueuedAttachments: queuedAttachmentSender,
460
495
  planOutboundReply: outboundReplyPlanner,
461
496
  sendOutboundReplyArtifacts: outboundReplyArtifactSender,
497
+ isCurrentOwner: lockOwnershipGuard.ownsContext,
498
+ getDefaultChatId: proactivePushChatIdGetter,
499
+ consumeProactiveReplyToMessageId: proactivePromptTargetStore.consumeForChat,
500
+ isProactivePushEnabled,
501
+ recordRuntimeEvent,
462
502
  getActiveToolExecutions: lifecycle.getActiveToolExecutions,
463
503
  setActiveToolExecutions: lifecycle.setActiveToolExecutions,
464
504
  triggerPendingModelSwitchAbort: modelSwitchController.triggerPendingAbort,
@@ -472,7 +512,10 @@ export default function (pi: Pi.ExtensionAPI) {
472
512
  ...sessionLifecycleRuntime,
473
513
  ...agentLifecycleHooks,
474
514
  onAgentStart: agentStartWithDedupReset,
475
- onBeforeAgentStart: Prompts.createTelegramBeforeAgentStartHook(),
515
+ onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
516
+ isProactivePushEnabled,
517
+ isCurrentOwner: lockOwnershipGuard.ownsContext,
518
+ }),
476
519
  onModelSelect: currentModelRuntime.onModelSelect,
477
520
  onMessageStart: previewRuntime.onMessageStart,
478
521
  onMessageUpdate: previewRuntime.onMessageUpdate,
package/lib/commands.ts CHANGED
@@ -135,6 +135,10 @@ export interface TelegramBridgeCommandStartPollingResult {
135
135
  owner?: string;
136
136
  }
137
137
 
138
+ interface TelegramBridgeSettingsSelectUi {
139
+ select?: (title: string, items: string[]) => Promise<string | undefined>;
140
+ }
141
+
138
142
  export interface TelegramBridgeCommandRegistrationDeps {
139
143
  promptForConfig: (ctx: ExtensionCommandContext) => Promise<void>;
140
144
  getStatusLines: () => string[];
@@ -149,6 +153,8 @@ export interface TelegramBridgeCommandRegistrationDeps {
149
153
  | TelegramBridgeCommandStartPollingResult;
150
154
  stopPolling: () => Promise<void | string>;
151
155
  updateStatus: (ctx: ExtensionCommandContext) => void;
156
+ isProactivePushEnabled?: () => boolean;
157
+ setProactivePushEnabled?: (enabled: boolean) => Promise<void>;
152
158
  }
153
159
 
154
160
  function formatTelegramTakeoverTitle(ctx: ExtensionCommandContext): string {
@@ -183,6 +189,36 @@ export function registerTelegramBridgeCommands(
183
189
  ctx.ui.notify(deps.getStatusLines().join("\n"), "info");
184
190
  },
185
191
  });
192
+ pi.registerCommand("telegram-settings", {
193
+ description: "Open Telegram bridge settings",
194
+ handler: async (_args, ctx) => {
195
+ if (!deps.isProactivePushEnabled || !deps.setProactivePushEnabled) {
196
+ ctx.ui.notify("Telegram settings are unavailable.", "warning");
197
+ return;
198
+ }
199
+ await deps.reloadConfig();
200
+ const enabled = deps.isProactivePushEnabled();
201
+ const nextEnabled = !enabled;
202
+ const label = `${enabled ? "🟢" : "⚫️"} Proactive push`;
203
+ const action = `${nextEnabled ? "Enable" : "Disable"} proactive push`;
204
+ const select = (ctx.ui as TelegramBridgeSettingsSelectUi).select;
205
+ if (!select) {
206
+ ctx.ui.notify(
207
+ `${label}\n${action}: /telegram-settings requires interactive mode.`,
208
+ "info",
209
+ );
210
+ return;
211
+ }
212
+ const selected = await select("Telegram settings", [label, "Cancel"]);
213
+ if (selected !== label) return;
214
+ await deps.setProactivePushEnabled(nextEnabled);
215
+ deps.updateStatus(ctx);
216
+ ctx.ui.notify(
217
+ `Proactive push ${nextEnabled ? "enabled" : "disabled"}.`,
218
+ "info",
219
+ );
220
+ },
221
+ });
186
222
  pi.registerCommand("telegram-connect", {
187
223
  description: "Start the Telegram bridge in this π session",
188
224
  handler: async (_args, ctx) => {
@@ -230,6 +266,7 @@ export const TELEGRAM_RESERVED_COMMAND_NAMES = [
230
266
  "compact",
231
267
  "model",
232
268
  "thinking",
269
+ "settings",
233
270
  "help",
234
271
  "start",
235
272
  ] as const;
@@ -261,6 +298,7 @@ export type TelegramCommandAction =
261
298
  | { kind: "status"; executionMode: "immediate" }
262
299
  | { kind: "model"; executionMode: "immediate" }
263
300
  | { kind: "thinking"; executionMode: "immediate" }
301
+ | { kind: "settings"; executionMode: "immediate" }
264
302
  | {
265
303
  kind: "help";
266
304
  commandName: "help" | "start";
@@ -279,6 +317,7 @@ export interface TelegramCommandActionDeps<TMessage, TContext> {
279
317
  handleStatus: (message: TMessage, ctx: TContext) => Promise<void>;
280
318
  handleModel: (message: TMessage, ctx: TContext) => Promise<void>;
281
319
  handleThinking: (message: TMessage, ctx: TContext) => Promise<void>;
320
+ handleSettings?: (message: TMessage, ctx: TContext) => Promise<void>;
282
321
  handleHelp: (
283
322
  message: TMessage,
284
323
  commandName: "help" | "start",
@@ -353,6 +392,11 @@ export interface TelegramCommandTargetRuntimeDeps<TContext> {
353
392
  replyToMessageId: number,
354
393
  ctx: TContext,
355
394
  ) => Promise<void>;
395
+ openSettingsMenu?: (
396
+ chatId: number,
397
+ replyToMessageId: number,
398
+ ctx: TContext,
399
+ ) => Promise<void>;
356
400
  sendTextReply: (
357
401
  chatId: number,
358
402
  replyToMessageId: number,
@@ -373,6 +417,7 @@ export interface TelegramCommandTargetRuntime<
373
417
  ) => void;
374
418
  showStatus: (message: TMessage, ctx: TContext) => Promise<void>;
375
419
  openModelMenu: (message: TMessage, ctx: TContext) => Promise<void>;
420
+ openSettingsMenu: (message: TMessage, ctx: TContext) => Promise<void>;
376
421
  sendTextReply: (message: TMessage, text: string) => Promise<void>;
377
422
  }
378
423
 
@@ -457,6 +502,7 @@ export function createTelegramCommandTargetQueueRuntime<
457
502
  }),
458
503
  showStatus: deps.showStatus,
459
504
  openModelMenu: deps.openModelMenu,
505
+ openSettingsMenu: deps.openSettingsMenu,
460
506
  sendTextReply: deps.sendTextReply,
461
507
  });
462
508
  }
@@ -485,6 +531,18 @@ export function createTelegramCommandTargetRuntime<
485
531
  const target = getTelegramCommandMessageTarget(message);
486
532
  return deps.openModelMenu(target.chatId, target.replyToMessageId, ctx);
487
533
  },
534
+ openSettingsMenu: async (message, ctx) => {
535
+ const target = getTelegramCommandMessageTarget(message);
536
+ if (!deps.openSettingsMenu) {
537
+ await deps.sendTextReply(
538
+ target.chatId,
539
+ target.replyToMessageId,
540
+ "Settings menu is unavailable.",
541
+ );
542
+ return;
543
+ }
544
+ await deps.openSettingsMenu(target.chatId, target.replyToMessageId, ctx);
545
+ },
488
546
  sendTextReply: async (message, text) => {
489
547
  const target = getTelegramCommandMessageTarget(message);
490
548
  await deps.sendTextReply(target.chatId, target.replyToMessageId, text);
@@ -541,6 +599,7 @@ export interface TelegramCommandRuntimeDeps<
541
599
  openModelMenu: (message: TMessage, ctx: TContext) => Promise<void>;
542
600
  openThinkingMenu: (message: TMessage, ctx: TContext) => Promise<void>;
543
601
  openQueueMenu: (message: TMessage, ctx: TContext) => Promise<void>;
602
+ openSettingsMenu?: (message: TMessage, ctx: TContext) => Promise<void>;
544
603
  getAllowedUserId: () => number | undefined;
545
604
  setAllowedUserId: (userId: number) => void;
546
605
  registerBotCommands: () => Promise<void>;
@@ -624,6 +683,7 @@ export const TELEGRAM_COMMAND_ACTIONS = {
624
683
  compact: { kind: "compact", executionMode: "immediate" },
625
684
  model: { kind: "model", executionMode: "immediate" },
626
685
  thinking: { kind: "thinking", executionMode: "immediate" },
686
+ settings: { kind: "settings", executionMode: "immediate" },
627
687
  help: { kind: "help", commandName: "help", executionMode: "immediate" },
628
688
  start: { kind: "help", commandName: "start", executionMode: "immediate" },
629
689
  } as const satisfies Record<TelegramReservedCommandName, TelegramCommandAction>;
@@ -830,6 +890,10 @@ export async function executeTelegramCommandAction<TMessage, TContext>(
830
890
  case "thinking":
831
891
  await deps.handleThinking(message, ctx);
832
892
  return true;
893
+ case "settings":
894
+ if (!deps.handleSettings) return false;
895
+ await deps.handleSettings(message, ctx);
896
+ return true;
833
897
  case "help":
834
898
  await deps.handleHelp(message, action.commandName, ctx);
835
899
  return true;
@@ -846,6 +910,7 @@ export interface TelegramCommandHandlerTargetRuntimeDeps<
846
910
  | "enqueueControlItem"
847
911
  | "showStatus"
848
912
  | "openModelMenu"
913
+ | "openSettingsMenu"
849
914
  | "sendTextReply"
850
915
  | "registerBotCommands"
851
916
  >,
@@ -877,6 +942,7 @@ export function createTelegramCommandHandlerTargetRuntime<
877
942
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
878
943
  showStatus: deps.showStatus,
879
944
  openModelMenu: deps.openModelMenu,
945
+ openSettingsMenu: deps.openSettingsMenu,
880
946
  sendTextReply: deps.sendTextReply,
881
947
  });
882
948
  return createTelegramCommandHandler({
@@ -901,6 +967,7 @@ export function createTelegramCommandHandlerTargetRuntime<
901
967
  openModelMenu: commandTargetRuntime.openModelMenu,
902
968
  openThinkingMenu: deps.openThinkingMenu,
903
969
  openQueueMenu: deps.openQueueMenu,
970
+ openSettingsMenu: commandTargetRuntime.openSettingsMenu,
904
971
  getAllowedUserId: deps.getAllowedUserId,
905
972
  setAllowedUserId: deps.setAllowedUserId,
906
973
  registerBotCommands: createTelegramBotCommandRegistrar({
@@ -1056,6 +1123,11 @@ async function handleTelegramCommandRuntime<
1056
1123
  handleThinking: async (nextMessage, commandCtx) => {
1057
1124
  await deps.openThinkingMenu(nextMessage, commandCtx);
1058
1125
  },
1126
+ handleSettings: deps.openSettingsMenu
1127
+ ? async (nextMessage, commandCtx) => {
1128
+ await deps.openSettingsMenu?.(nextMessage, commandCtx);
1129
+ }
1130
+ : undefined,
1059
1131
  handleHelp: async (nextMessage, _nextCommandName, commandCtx) => {
1060
1132
  try {
1061
1133
  await deps.registerBotCommands();
package/lib/config.ts CHANGED
@@ -42,6 +42,7 @@ export interface TelegramConfig {
42
42
  inboundHandlers?: TelegramInboundHandlerConfig[];
43
43
  attachmentHandlers?: TelegramInboundHandlerConfig[];
44
44
  outboundHandlers?: TelegramOutboundHandlerConfig[];
45
+ proactivePush?: boolean;
45
46
  }
46
47
 
47
48
  export interface TelegramConfigStore {
@@ -124,6 +125,54 @@ export function createTelegramConfigStore(
124
125
  };
125
126
  }
126
127
 
128
+ export function createTelegramProactivePushChecker(
129
+ configStore: Pick<TelegramConfigStore, "get">,
130
+ ): () => boolean {
131
+ return () => configStore.get().proactivePush ?? false;
132
+ }
133
+
134
+ export function createTelegramProactivePushSetter(
135
+ configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
136
+ ): (enabled: boolean) => Promise<void> {
137
+ return async (enabled) => {
138
+ const config = { ...configStore.get(), proactivePush: enabled };
139
+ configStore.set(config);
140
+ await configStore.persist(config);
141
+ };
142
+ }
143
+
144
+ export function createTelegramProactivePushChatIdGetter(deps: {
145
+ getActiveTurnChatId: () => number | undefined;
146
+ getAllowedUserId: () => number | undefined;
147
+ }): () => number | undefined {
148
+ return () => deps.getActiveTurnChatId() ?? deps.getAllowedUserId();
149
+ }
150
+
151
+ export interface TelegramProactivePromptTarget {
152
+ chatId: number;
153
+ messageId: number;
154
+ }
155
+
156
+ export interface TelegramProactivePromptTargetStore {
157
+ set: (target: TelegramProactivePromptTarget) => void;
158
+ consumeForChat: (chatId: number) => number | undefined;
159
+ }
160
+
161
+ export function createTelegramProactivePromptTargetStore(): TelegramProactivePromptTargetStore {
162
+ let target: TelegramProactivePromptTarget | undefined;
163
+ return {
164
+ set: (nextTarget) => {
165
+ target = nextTarget;
166
+ },
167
+ consumeForChat: (chatId) => {
168
+ if (!target || target.chatId !== chatId) return undefined;
169
+ const messageId = target.messageId;
170
+ target = undefined;
171
+ return messageId;
172
+ },
173
+ };
174
+ }
175
+
127
176
  export type TelegramAuthorizationState =
128
177
  | { kind: "pair"; userId: number }
129
178
  | { kind: "allow" }
@@ -258,6 +258,15 @@ function getRemainingTelegramInboundTimeout(
258
258
  return Math.max(1, timeout - (Date.now() - startedAt));
259
259
  }
260
260
 
261
+ function getTelegramInboundInitialCompositionStepTimeout(
262
+ handler: TelegramInboundHandlerConfig,
263
+ step: TelegramInboundCommandTemplateConfig,
264
+ ): number {
265
+ const timeout = getTelegramInboundHandlerTimeout(handler);
266
+ const stepTimeout = getTelegramInboundHandlerConfiguredTimeout(step);
267
+ return stepTimeout === undefined ? timeout : Math.min(stepTimeout, timeout);
268
+ }
269
+
261
270
  function getTelegramInboundCompositionStepTimeout(
262
271
  handler: TelegramInboundHandlerConfig,
263
272
  step: TelegramInboundCommandTemplateConfig,
@@ -421,7 +430,9 @@ async function executeTelegramTextHandler(
421
430
  output,
422
431
  cwd,
423
432
  deps,
424
- getTelegramInboundCompositionStepTimeout(handler, step, startedAt),
433
+ index === 0
434
+ ? getTelegramInboundInitialCompositionStepTimeout(handler, step)
435
+ : getTelegramInboundCompositionStepTimeout(handler, step, startedAt),
425
436
  );
426
437
  } catch (error) {
427
438
  if (typeof step === "object" && step.critical) throw error;
@@ -498,7 +509,9 @@ async function executeTelegramInboundHandler(
498
509
  cwd,
499
510
  deps,
500
511
  false,
501
- getTelegramInboundCompositionStepTimeout(handler, step, startedAt),
512
+ index === 0
513
+ ? getTelegramInboundInitialCompositionStepTimeout(handler, step)
514
+ : getTelegramInboundCompositionStepTimeout(handler, step, startedAt),
502
515
  index === 0 ? undefined : output,
503
516
  );
504
517
  } catch (error) {
package/lib/locks.ts CHANGED
@@ -61,6 +61,11 @@ export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
61
61
  owns: (ctx?: TelegramLockContext) => boolean;
62
62
  }
63
63
 
64
+ export interface TelegramLockOwnershipGuard<TContext extends TelegramLockContext> {
65
+ ownsCurrentProcess: () => boolean;
66
+ ownsContext: (ctx: TContext) => boolean;
67
+ }
68
+
64
69
  export interface TelegramLockRuntimeOptions {
65
70
  key?: string;
66
71
  locksPath?: string;
@@ -234,6 +239,17 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
234
239
  };
235
240
  }
236
241
 
242
+ export function createTelegramLockOwnershipGuard<
243
+ TContext extends TelegramLockContext,
244
+ >(
245
+ lock: TelegramLockRuntime<TContext>,
246
+ ): TelegramLockOwnershipGuard<TContext> {
247
+ return {
248
+ ownsCurrentProcess: () => lock.owns(),
249
+ ownsContext: (ctx) => lock.owns(ctx),
250
+ };
251
+ }
252
+
237
253
  export function createTelegramLockedPollingRuntime<
238
254
  TContext extends TelegramLockContext,
239
255
  >(