@llblab/pi-telegram 0.20.5 → 0.21.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.
@@ -0,0 +1,294 @@
1
+ # Telegram Activity API
2
+
3
+ ## Purpose
4
+
5
+ The Telegram Activity API lets trusted extension consumers observe normalized Pi work lifecycle without importing pi-telegram internals, correlating raw Pi events with bridge state, or capturing session contexts.
6
+
7
+ It is a higher-level event surface over the [Telegram Delivery API](./delivery.md). Activity owns lifecycle normalization and event routing; delivery owns target authorization, rendering transport, ordering, chunk reconciliation, and stale-generation behavior.
8
+
9
+ The public membrane is:
10
+
11
+ ```ts
12
+ import {
13
+ registerTelegramActivityHandler,
14
+ } from "@llblab/pi-telegram/activity";
15
+ ```
16
+
17
+ An issue #126 consumer can own optional Settings policy for reasoning, intermediate assistant prose, and tool rows. Those visibility choices do not become mandatory bridge-core settings.
18
+
19
+ ## Ownership Boundary
20
+
21
+ Consumer extension code owns:
22
+
23
+ - Which activity event classes are visible.
24
+ - Settings state and presentation policy.
25
+ - Rendering event payloads into operational views.
26
+ - Coalescing or replacing its own visible activity documents.
27
+ - Redacting additional domain-specific data before display.
28
+
29
+ pi-telegram owns:
30
+
31
+ - Mapping Pi lifecycle events into one normalized activity stream.
32
+ - Correlating events with the current Telegram turn or Pi instance.
33
+ - Stable activity/run identity within one runtime generation.
34
+ - Default delivery scope selection.
35
+ - Handler registration, ordering, isolation, disposal, and diagnostics.
36
+ - Avoiding duplicate provider tool-call versus executed-tool events.
37
+ - Session shutdown fencing and fresh delivery contexts.
38
+
39
+ Pi owns the source lifecycle and provider event protocol. The Activity API requires Pi `0.80.6` or newer because `agent_settled` is the terminal boundary that distinguishes a fully settled activity from automatic retry, compaction, and queued continuation. The API does not invent reasoning when a provider does not expose it.
40
+
41
+ ## Registration
42
+
43
+ ```ts
44
+ export interface TelegramActivityHandlerRegistration {
45
+ id: string;
46
+ order?: number;
47
+ handle: (
48
+ event: TelegramActivityEvent,
49
+ ctx: TelegramActivityContext,
50
+ ) => void | Promise<void>;
51
+ }
52
+
53
+ export function registerTelegramActivityHandler(
54
+ registration: TelegramActivityHandlerRegistration,
55
+ ): () => void;
56
+ ```
57
+
58
+ Rules:
59
+
60
+ - `id` is a stable consumer identity, normally derived from package identity plus a local activity suffix.
61
+ - Duplicate active ids are rejected rather than silently stacking duplicate Telegram output.
62
+ - Handlers run by `order`, then `id`.
63
+ - Registration returns a stale-safe disposer that removes only its own registration.
64
+ - Consumers register on `session_start` and dispose on `session_shutdown`.
65
+ - The bridge creates a fresh dispatcher generation on every `session_start`; shutdown stops and clears only the retiring generation, so same-process session replacement resumes delivery instead of leaving Activity permanently stopped.
66
+ - Handler failures are isolated and recorded in `/telegram-status`; they never break Pi lifecycle or other handlers.
67
+
68
+ ## Activity Identity And Source
69
+
70
+ ```ts
71
+ export type TelegramActivitySource =
72
+ | "telegram"
73
+ | "local"
74
+ | "autonomous"
75
+ | "unknown";
76
+
77
+ export type TelegramActivityTarget = Readonly<TelegramDeliveryTarget>;
78
+
79
+ export interface TelegramActivityEnvelope {
80
+ activityId: string;
81
+ sequence: number;
82
+ source: TelegramActivitySource;
83
+ target?: TelegramActivityTarget;
84
+ timestamp: number;
85
+ }
86
+ ```
87
+
88
+ `activityId` identifies one logical settled-work sequence inside the current delivery generation. `sequence` increases monotonically within that activity and lets consumers reject stale asynchronous rendering. Telegram-owned activities carry a frozen `target` captured when the activity starts; it contains only safe chat/thread identity, never a Telegram client or Pi context.
89
+
90
+ Source classification follows evidence, not guesses:
91
+
92
+ - `telegram`: a dispatched active Telegram turn exists.
93
+ - `local`: the initiating Pi input event reports `interactive` or `rpc` and no Telegram turn owns the run.
94
+ - `autonomous`: the initiating input reports `extension` and no Telegram turn owns the run.
95
+ - `unknown`: retries, restored continuations, or runtimes without enough input evidence cannot be classified safely.
96
+
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
+
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.
100
+
101
+ ## Event Contract
102
+
103
+ ```ts
104
+ export type TelegramActivityEvent = TelegramActivityEnvelope & (
105
+ | { type: "agent-start" }
106
+ | {
107
+ type: "assistant-text-delta";
108
+ contentIndex: number;
109
+ delta: string;
110
+ }
111
+ | {
112
+ type: "assistant-segment";
113
+ contentIndex: number;
114
+ text: string;
115
+ placement: "intermediate" | "final" | "terminal-partial";
116
+ }
117
+ | {
118
+ type: "reasoning-delta";
119
+ contentIndex: number;
120
+ delta: string;
121
+ }
122
+ | {
123
+ type: "reasoning-end";
124
+ contentIndex: number;
125
+ text: string;
126
+ }
127
+ | {
128
+ type: "tool-start";
129
+ toolCallId: string;
130
+ toolName: string;
131
+ args: unknown;
132
+ }
133
+ | {
134
+ type: "tool-update";
135
+ toolCallId: string;
136
+ toolName: string;
137
+ update: unknown;
138
+ }
139
+ | {
140
+ type: "tool-end";
141
+ toolCallId: string;
142
+ toolName: string;
143
+ result: unknown;
144
+ isError: boolean;
145
+ }
146
+ | {
147
+ type: "compaction-start";
148
+ reason: "manual" | "threshold" | "overflow" | "unknown";
149
+ }
150
+ | {
151
+ type: "compaction-end";
152
+ reason: "manual" | "threshold" | "overflow" | "unknown";
153
+ }
154
+ | { type: "agent-end" }
155
+ | { type: "agent-settled" }
156
+ );
157
+ ```
158
+
159
+ ### Assistant segment classification
160
+
161
+ Provider stream events expose `text_start`, `text_delta`, and `text_end`, but `text_end` alone does not prove whether prose is intermediate or final. The normalizer therefore holds a completed text segment briefly until the next provider boundary proves placement:
162
+
163
+ - A following `toolcall_start` classifies the pending text as `intermediate`.
164
+ - A following successful `done` classifies it as `final`.
165
+ - A following `error` classifies it as `terminal-partial`.
166
+ - A new text block flushes any older still-pending segment as `intermediate` only when the provider event order proves another block follows.
167
+
168
+ `assistant-text-delta` remains available for extensions that want progressive rendering. Consumers that only want complete intermediate prose should ignore deltas and render `assistant-segment` where `placement === "intermediate"`.
169
+
170
+ ### Reasoning
171
+
172
+ Pi provider events use `thinking_*`; the public product term is `reasoning`. The normalizer maps `thinking_delta` to `reasoning-delta` and `thinking_end` to `reasoning-end`.
173
+
174
+ - Signed/redacted provider metadata is not exposed as display text.
175
+ - Empty reasoning blocks are skipped.
176
+ - Providers that hide reasoning produce no reasoning events.
177
+ - Consumers must treat reasoning as potentially sensitive and disabled by default.
178
+
179
+ ### Tools
180
+
181
+ Tool activity uses Pi's executed-tool lifecycle (`tool_execution_start/update/end`), not provider `toolcall_*` payloads. Provider tool-call boundaries are used only to classify preceding assistant prose. This prevents duplicate tool rows and reports actual execution results.
182
+
183
+ `args`, `update`, and `result` may contain paths, source text, command output, or other sensitive data. They are available to trusted local extension code but must not be rendered wholesale by default. Reference UI should summarize tool name/state and expose bounded details only through explicit policy.
184
+
185
+ ## Delivery Context
186
+
187
+ ```ts
188
+ export interface TelegramActivityContext {
189
+ activityId: string;
190
+ sequence: number;
191
+ source: TelegramActivitySource;
192
+ defaultScope: TelegramDeliveryScope;
193
+ send(
194
+ view: TelegramDeliveryView,
195
+ options?: {
196
+ scope?: TelegramDeliveryScope;
197
+ replyToMessageId?: number;
198
+ },
199
+ ): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
200
+ edit(
201
+ handle: TelegramDeliveryHandle,
202
+ view: TelegramDeliveryView,
203
+ ): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
204
+ delete(
205
+ handle: TelegramDeliveryHandle,
206
+ ): Promise<TelegramDeliveryResult<void>>;
207
+ chatAction(
208
+ action: TelegramDeliveryChatAction,
209
+ options?: { scope?: TelegramDeliveryScope },
210
+ ): Promise<TelegramDeliveryResult<void>>;
211
+ }
212
+ ```
213
+
214
+ Default scope:
215
+
216
+ - `telegram` uses `{ kind: "target", target: event.target }`, binding delayed handlers to the immutable originating thread rather than whichever turn happens to be active later.
217
+ - `local`, `autonomous`, and `unknown` use `{ kind: "instance" }`.
218
+
219
+ Handlers may explicitly choose aggregate or another authorized scope. Context methods delegate to the public delivery runtime on every call; they do not retain Telegram clients, Pi contexts, or transport ownership objects. The Delivery API rechecks authorization when an operation runs, so a captured target that is no longer owned fails closed instead of rerouting.
220
+
221
+ A handler may still receive events while no target is currently deliverable. Delivery then returns the normal structured `target-unavailable`, `target-unauthorized`, or `runtime-unavailable` result.
222
+
223
+ ## Dispatch And Backpressure
224
+
225
+ Pi lifecycle must not wait for extension rendering or Telegram transport.
226
+
227
+ - Lifecycle hooks normalize and enqueue activity events synchronously, then return.
228
+ - Each handler owns a serialized asynchronous queue so its event order is stable without blocking other handlers.
229
+ - Handler queues and contexts are generation-bound. `session_shutdown` stops queued events immediately, and an already-running handler receives `runtime-unavailable` instead of delivering through a replacement session after it resumes.
230
+ - Disposing a handler also fences contexts captured from that registration; they cannot outlive their ownership and adopt another registration with the same id.
231
+ - High-frequency `assistant-text-delta`, `reasoning-delta`, and `tool-update` events may coalesce only with the immediately adjacent event of the same type, activity id, content/tool id, and handler queue.
232
+ - Boundary events (`assistant-segment`, `reasoning-end`, tool start/end, compaction, agent end/settled) are never dropped or reordered.
233
+ - The 0.21 dispatcher does not impose an event-count drop policy or emit queue-length diagnostics. Adjacent delta/update coalescing reduces common streaming pressure, but boundary events accumulate behind a slow handler; consumers must keep handler work bounded and delegate long-lived work outside the callback when appropriate.
234
+
235
+ The Delivery API independently serializes concrete Telegram operations per target. Activity serialization preserves semantic event order; delivery serialization preserves transport order.
236
+
237
+ ## Lifecycle Mapping
238
+
239
+ The bridge maps Pi hooks as follows:
240
+
241
+ - `input`: capture source evidence for the next logical activity.
242
+ - `agent_start`: allocate or reuse activity identity and emit `agent-start` after Telegram queue consumption establishes active-turn ownership.
243
+ - `message_update.assistantMessageEvent`: normalize text/reasoning/provider boundaries.
244
+ - `tool_execution_start/update/end`: emit executed tool events.
245
+ - `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.
246
+ - `agent_end`: emit low-level run completion but keep identity alive for retry/follow-up work.
247
+ - `agent_settled`: emit terminal settlement, flush pending terminal segments, and release activity identity.
248
+ - `session_shutdown`: stop dispatch, clear pending normalization state, and invalidate delivery generation through the existing delivery lifecycle.
249
+
250
+ ## Diagnostics
251
+
252
+ Duplicate registration fails synchronously. When a handler throws or rejects, the dispatcher forwards only the handler id, event type, activity id, and error to pi-telegram's existing bounded/redacted runtime event recorder. The bridge does not copy reasoning, assistant prose, tool arguments/results, Telegram payloads, or queue contents into diagnostic metadata. It does not currently record queue phase, queue length, coalescing counts, or handler latency.
253
+
254
+ The first implementation has no public Activity diagnostics getter because handler failures already flow through bridge runtime diagnostics and `/telegram-status`. Add queue telemetry or a dedicated getter only when an observed consumer needs inspectable backpressure state.
255
+
256
+ ## Security And Non-Goals
257
+
258
+ The Activity API does not:
259
+
260
+ - Enable reasoning visibility by default.
261
+ - Guarantee reasoning availability across providers.
262
+ - Expose signed/redacted reasoning metadata.
263
+ - Render raw tool arguments or results automatically.
264
+ - Replace assistant final replies or Rich Draft previews.
265
+ - Mutate queues, models, thinking levels, sessions, or process state.
266
+ - Expose Telegram clients, bot tokens, Pi contexts, or private runtime objects.
267
+ - Block Pi lifecycle on extension handlers or Telegram delivery.
268
+
269
+ ## Validation Contract
270
+
271
+ The implementation must cover:
272
+
273
+ - Stable id registration, ordering, duplicate rejection, and stale-safe disposal.
274
+ - Telegram, local, autonomous, and unknown source evidence.
275
+ - Identity reuse across retry/compaction and reset at settlement.
276
+ - Text delta flow and intermediate/final/terminal segment classification.
277
+ - Reasoning mapping and empty/hidden reasoning behavior.
278
+ - Executed tool start/update/end without provider tool-call duplication.
279
+ - Default active-turn versus instance delivery scopes.
280
+ - Adjacent delta/update coalescing and boundary preservation.
281
+ - Handler error isolation, non-blocking lifecycle, shutdown fencing, and redacted diagnostics.
282
+ - Public package import without `/lib` reach-through.
283
+
284
+ ## Consumer Policy Pattern
285
+
286
+ The registration and delivery examples above provide the complete public building blocks for issue #126-style visibility policy:
287
+
288
+ - Reasoning can default off and send completed `reasoning-end` blocks only when enabled.
289
+ - Intermediate prose can default off and send only `assistant-segment` events with `placement: "intermediate"`, never the final assistant segment.
290
+ - Tool rows can default on, show only tool name/state, and edit generation-bound logical handles from running to done/failed without exposing arguments or results.
291
+ - Interactive toggles belong in a registered Section and Settings row; activity messages can remain non-interactive.
292
+ - `session_shutdown` should dispose stable registrations and drop retained delivery handles, so reload/session replacement cannot reuse old contexts or handles.
293
+
294
+ The separate [`pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) project remains the maintained companion-extension and managed-UI reference. This document owns the Activity-specific usage pattern; pi-telegram does not ship a redundant `examples/` package directory.
@@ -14,6 +14,8 @@ The bridge is a mobile companion for a live Pi session, not a remote terminal. I
14
14
  This document is the architectural map. Focused behavior standards live in sibling docs:
15
15
 
16
16
  - [Public API](./public-api.md) — stable commands, config, package entrypoints, assistant markup, extension APIs, and compatibility boundaries.
17
+ - [Telegram Delivery API](./delivery.md) — target-aware operational views, logical message handles, lifecycle fencing, and leader/follower transport.
18
+ - [Telegram Activity API](./activity.md) — normalized Pi lifecycle events, activity/source identity, non-blocking extension dispatch, and delivery contexts.
17
19
  - [UI Style](./ui-style.md) — inline UI labels, navigation, state markers, cards, and dialogs.
18
20
  - [Callback Namespaces](./callback-namespaces.md) — callback prefix ownership and fallback rules.
19
21
  - [Sections](./sections.md) — structured Telegram menu sections.
@@ -64,6 +66,8 @@ The repository uses a **Flat Domain DAG**:
64
66
  - `sections`: Telegram menu-section registry, opaque section callback tokens, render/callback dispatch, safe section ports, and diagnostics.
65
67
  - `keyboard`: shared inline-keyboard reply-markup shape only; feature domains own labels, callback data, and behavior.
66
68
  - `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.
69
+ - `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.
70
+ - `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. It does not own visibility policy or Telegram rendering.
67
71
  - `outbound-markup`: top-level assistant action comment parsing, attribute parsing, voice reply planning, and preview/delivery stripping.
68
72
  - `outbound`: outbound text transformations, voice/button artifact delivery, and generated callback actions.
69
73
  - `outbound-attachments`: `telegram_attach`, queued outbound files, stat/limit checks, and photo/document delivery classification.
@@ -233,7 +237,7 @@ Assistant delivery guarantees:
233
237
 
234
238
  - Model-authored Markdown is the source of truth; the bridge does not pre-render assistant Markdown to HTML unless the operator selects `assistant.rendering: "html"` for compatibility.
235
239
  - Before native Rich Markdown delivery, the bridge normalizes known Bot-API-fragile source forms without changing visible meaning, including space-after-marker blockquotes and dollar-prefixed ticker atoms that Telegram may otherwise treat as unterminated math.
236
- - Prompt context blocks use compact metadata (`[tag|key:value]`) as the stable inbound contract. `[telegram...]` names the current surface only: owner/current turns use `[telegram]` or `[telegram|thread:<name>]`; guest-mode turns use `[telegram|guest:<group-title-or-peer-username-or-id>]`. Source authors for quoted/forwarded material and their files are carried by `[reply|from:<username-or-id>]`, `[forward|from:<username-or-id>]`, and `[attachments|from:<username-or-id>]`, while plain `[attachments]` remains current-turn attachments and is ordered before reply/forward/source context.
240
+ - Prompt context blocks use compact metadata (`[tag|key:value]`) as the stable inbound contract. `[telegram...]` names the current surface only: owner/current turns use `[telegram]` or `[telegram|thread:<name>]`; guest-mode turns use `[telegram|guest:<group-title-or-peer-username-or-id>]`. In a private Guest Mode turn the paired owner's `from` identity is never the guest: the remote private-chat identity wins, then non-owner caller metadata, with a non-bot replied peer available only as a final identity fallback when stronger conversation evidence is absent; username falls back to the remote display name and numeric id. Reply attribution still belongs independently in `[reply|from:...]`, and a replied bot can never define or replace the current `[telegram|guest:...]` location identity. Source authors for quoted/forwarded material and their files are carried by `[reply|from:<username-or-id>]`, `[forward|from:<username-or-id>]`, and `[attachments|from:<username-or-id>]`, while plain `[attachments]` remains current-turn attachments and is ordered before reply/forward/source context.
237
241
  - Quoted rich replies use Telegram `rich_message` blocks as the prompt-context source when available, so `[reply]` context receives rendered plain text instead of raw `InputRichMessage.markdown` fallback text.
238
242
  - Long native Markdown replies are split only at Telegram Rich Message transport limits; oversized fenced code, display-math, and fully wrapped inline-formatting blocks are rewrapped per chunk so persisted Rich Markdown chunks remain structurally valid.
239
243
  - When Draft previews are enabled, streaming previews pass structurally closed assistant Markdown prefixes through to `sendRichMessageDraft` with ownership checks, voice suppression, and serialized flushes. Unclosed inline spans, links, fenced code, comments, and display-math blocks are held back until a safe boundary exists. Draft failures are recorded and the failing frame is skipped instead of degrading to raw plain-message previews, because partial Markdown can be invalid while the final message remains valid.
@@ -265,6 +269,8 @@ Unknown callback data outside owned prefixes is forwarded as `[callback] <data>`
265
269
 
266
270
  - Raw update observation/consumption: [Updates](./updates.md).
267
271
  - Telegram-native slash commands: `registerTelegramCommand()` from [Public API](./public-api.md#commands).
272
+ - Target-aware operational views and chat actions: [Telegram Delivery API](./delivery.md).
273
+ - Normalized non-blocking Pi lifecycle events: [Telegram Activity API](./activity.md); the separate [`pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) project remains the companion-extension reference.
268
274
  - Structured inline UI sections: [Sections](./sections.md).
269
275
  - Callback namespace discipline: [Callback Namespaces](./callback-namespaces.md).
270
276
  - Voice/STT/TTS providers: [Voice Integration](./voice.md).
@@ -0,0 +1,224 @@
1
+ # Telegram Delivery API
2
+
3
+ ## Purpose
4
+
5
+ The Telegram Delivery API gives trusted extension consumers a safe programmatic way to render operational Telegram UI without importing bridge internals or owning Telegram transport.
6
+
7
+ It fills the boundary between callback-scoped `TelegramSectionContext.open()` and agent-callable `telegram_message` / `telegram_attach`. It does not replace normal active-turn final replies, Sections, outbound handlers, or raw update handlers.
8
+
9
+ The public package membrane is:
10
+
11
+ ```ts
12
+ import {
13
+ deleteTelegramView,
14
+ editTelegramView,
15
+ sendTelegramChatAction,
16
+ sendTelegramView,
17
+ } from "@llblab/pi-telegram/delivery";
18
+ ```
19
+
20
+ ## Ownership Boundary
21
+
22
+ Consumer extension code owns:
23
+
24
+ - When an operational view should appear.
25
+ - View text, parse mode, and optional inline keyboard.
26
+ - Its own settings and callback policy.
27
+ - Retaining a returned handle only for the current live extension generation.
28
+
29
+ pi-telegram owns:
30
+
31
+ - Active-turn and current-instance target resolution.
32
+ - Pairing and target authorization.
33
+ - Classic direct transport versus follower-to-leader routing.
34
+ - Named-profile isolation.
35
+ - Per-target operation ordering.
36
+ - Telegram text limits, rendering, and chunk reconciliation.
37
+ - Runtime generation checks, shutdown behavior, and diagnostics.
38
+ - Bot credentials, polling, offsets, retries, and raw API clients.
39
+
40
+ ## Public Contract
41
+
42
+ ### Views
43
+
44
+ ```ts
45
+ export type TelegramDeliveryParseMode = "plain" | "html" | "markdown";
46
+
47
+ export interface TelegramDeliveryView {
48
+ text: string;
49
+ parseMode?: TelegramDeliveryParseMode;
50
+ replyMarkup?: TelegramInlineKeyboardMarkup;
51
+ }
52
+ ```
53
+
54
+ `plain` is the default. Operational activity should prefer `plain` or explicit `html`. `markdown` exists for extension-authored content that naturally owns Markdown; the bridge converts it through the existing UI/compat Markdown-to-HTML renderer rather than entering the native assistant final-reply pipeline.
55
+
56
+ `replyMarkup` accepts only structural keyboard data. Callback ownership stays with Sections or a registered raw update handler. The documented issue #126 consumer shape uses Sections for interactive Settings toggles and keeps delivered activity rows non-interactive, so a second managed callback registry would duplicate token, answer, edit, navigation, and cleanup ownership without a proven use case. Revisit only when a public-import-only consumer must generate managed callbacks independently of a registered Section context for arbitrary delivered messages.
57
+
58
+ ### Target scopes
59
+
60
+ ```ts
61
+ export type TelegramDeliveryScope =
62
+ | { kind: "active-turn" }
63
+ | { kind: "instance" }
64
+ | { kind: "aggregate" }
65
+ | { kind: "target"; target: TelegramDeliveryTarget };
66
+
67
+ export interface TelegramDeliveryTarget {
68
+ chatId: number;
69
+ threadId?: number;
70
+ }
71
+ ```
72
+
73
+ Resolution rules:
74
+
75
+ - `active-turn` requires a current Telegram-owned turn and resolves its exact `{ chatId, threadId? }`.
76
+ - `instance` resolves the current process's assigned follower/leader thread, or the paired private chat in classic mode. It does not silently fall back to an unrelated active thread.
77
+ - `aggregate` resolves the paired private chat without `threadId`; it is the Threaded Mode `All` surface and the ordinary classic chat. Follower aggregate messages carry an internal authenticated-bus marker that the leader validates for the assigned chat and strips before Bot API transport; unmarked or cross-chat threadless follower writes remain denied.
78
+ - `target` validates an explicit destination against the active profile and current runtime authority. A classic owner may target its paired private chat. A follower may target only its assigned thread or aggregate surface. A leader may target its own thread, aggregate surface, or a currently live bound thread for that profile. Unknown, stale, cross-profile, and unpaired targets are rejected.
79
+
80
+ No scope selects another named profile. Profile activation remains session-local bridge state.
81
+
82
+ ### Handles
83
+
84
+ ```ts
85
+ export interface TelegramDeliveryHandle {
86
+ readonly target: TelegramDeliveryTarget;
87
+ readonly messageIds: readonly number[];
88
+ readonly generation: string;
89
+ }
90
+ ```
91
+
92
+ A handle represents one logical view, which may span multiple Telegram messages after chunking. `generation` is an opaque runtime identity, not an authorization secret. Callers must not construct handles or persist them across reload/session replacement.
93
+
94
+ Edit reconciles the logical view as one operation:
95
+
96
+ - Existing chunks are edited in order.
97
+ - Additional chunks are sent when the new view grows.
98
+ - Surplus old chunks are deleted when the new view shrinks.
99
+ - The returned handle replaces the previous handle.
100
+
101
+ Delete removes every message still represented by the handle. Partial transport failure returns a structured failure and records diagnostics; it never pretends the whole logical view succeeded.
102
+
103
+ ### Results
104
+
105
+ ```ts
106
+ export type TelegramDeliveryFailureReason =
107
+ | "runtime-unavailable"
108
+ | "target-unavailable"
109
+ | "target-unauthorized"
110
+ | "stale-handle"
111
+ | "invalid-view"
112
+ | "transport-failed";
113
+
114
+ export type TelegramDeliveryResult<T> =
115
+ | { ok: true; value: T }
116
+ | {
117
+ ok: false;
118
+ reason: TelegramDeliveryFailureReason;
119
+ message: string;
120
+ partial?: T;
121
+ };
122
+ ```
123
+
124
+ Expected availability, authorization, and lifecycle outcomes return failures rather than throwing. Programmer errors may still throw for malformed objects that cannot satisfy the TypeScript contract. Transport failures are redacted before reaching callers and are also recorded in bridge runtime diagnostics.
125
+
126
+ When a multi-chunk `send` or growing `edit` fails after materializing part of the logical view, `partial` contains a valid handle for every message still visible from that operation. Callers may pass it to `editTelegramView` to reconcile the view or to `deleteTelegramView` for cleanup. A failure before any message exists omits `partial`; callers must never infer message ids or construct a handle.
127
+
128
+ ### Operations
129
+
130
+ ```ts
131
+ export interface SendTelegramViewOptions {
132
+ scope: TelegramDeliveryScope;
133
+ replyToMessageId?: number;
134
+ }
135
+
136
+ export function sendTelegramView(
137
+ view: TelegramDeliveryView,
138
+ options: SendTelegramViewOptions,
139
+ ): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
140
+
141
+ export function editTelegramView(
142
+ handle: TelegramDeliveryHandle,
143
+ view: TelegramDeliveryView,
144
+ ): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
145
+
146
+ export function deleteTelegramView(
147
+ handle: TelegramDeliveryHandle,
148
+ ): Promise<TelegramDeliveryResult<void>>;
149
+
150
+ export function sendTelegramChatAction(
151
+ action: "typing" | "upload_document" | "upload_photo" | "record_voice",
152
+ options: { scope: TelegramDeliveryScope },
153
+ ): Promise<TelegramDeliveryResult<void>>;
154
+ ```
155
+
156
+ `replyToMessageId` applies only to the first chunk and must belong to the resolved chat. The initial contract exposes only actions already used by bridge-owned activity and delivery paths; it is not a generic Bot API action string.
157
+
158
+ ## Runtime Binding
159
+
160
+ The public functions resolve a process-local runtime binding on every call. They never capture a Pi `ExtensionContext` or command context.
161
+
162
+ The bridge constructs and binds a genuinely fresh delivery runtime during every `session_start`. It unbinds and shuts down the current runtime during `session_shutdown` before session-bound transport state is discarded; binding an unexpected replacement also shuts down the displaced runtime. Reload and session replacement therefore produce these outcomes:
163
+
164
+ - A new call resolves the newly bound runtime after startup.
165
+ - An old handle returns `stale-handle` for edit and delete.
166
+ - A call while no generation is bound returns `runtime-unavailable`.
167
+ - Queued operations from the old generation stop before transport begins.
168
+ - An already-issued Telegram request may resolve during shutdown, but the old operation returns `runtime-unavailable` and cannot issue another edit, delete, chunk, or chat action afterward.
169
+ - Old operations never adopt the replacement generation implicitly.
170
+
171
+ The binding uses the same `globalThis` membrane pattern as other extension registries so package load order does not expose bridge internals. Only pi-telegram may bind or replace the runtime port.
172
+
173
+ ## Ordering And Delivery Semantics
174
+
175
+ - Operations serialize per profile and concrete target. Different targets may progress independently.
176
+ - `send`, logical `edit`, and logical `delete` preserve caller order for the same target.
177
+ - Delivery uses the existing bridge API runtime so followers route allowlisted calls through the leader rather than contacting Telegram directly.
178
+ - Text is chunked through the existing parse-mode-appropriate renderer/splitter. HTML chunks remain balanced, extension-authored Markdown becomes balanced UI/compat HTML, and plain chunks preserve text.
179
+ - Inline keyboard markup attaches only to the final chunk of a logical view. Reply parameters attach only to the first chunk.
180
+ - Edit growth sends additional chunks; edit shrink deletes surplus chunks; delete removes every chunk in handle order.
181
+ - A partial send failure returns the ids already sent. A partial edit failure returns the original surviving ids plus every newly sent id, minus any surplus ids already deleted during shrink. The returned handle therefore remains sufficient for deterministic retry or cleanup.
182
+ - The API does not participate in assistant preview/final deduplication and does not mutate the Telegram turn queue.
183
+ - A successful operational send does not imply agent work, create a Pi prompt, or alter terminal status.
184
+
185
+ ## Diagnostics
186
+
187
+ Failures record redacted runtime events under a delivery-specific category with operation, scope kind, profile, and failure reason. Diagnostics must not include bot tokens, unrestricted message bodies, callback payload secrets, or raw transport responses.
188
+
189
+ A future `getTelegramDeliveryDiagnostics()` is unnecessary for the first slice because callers receive structured results and `/telegram-status` already owns bridge diagnostics. Add a dedicated diagnostics getter only if a real consumer needs registry-level introspection.
190
+
191
+ ## Security And Non-Goals
192
+
193
+ The API does not expose:
194
+
195
+ - Bot tokens or raw Telegram clients.
196
+ - Arbitrary Bot API methods.
197
+ - A second polling loop.
198
+ - Cross-profile delivery.
199
+ - Unrestricted cross-instance targeting.
200
+ - Session replacement, reload, process launch, or Pi slash-command dispatch.
201
+ - File/media uploads in the first 0.21 slice.
202
+ - Captured Pi contexts or mutable queue/session state.
203
+
204
+ Extension consumers remain trusted local code, but the contract still preserves product ownership boundaries so accidental misuse cannot silently bypass Threaded Mode routing or target identity.
205
+
206
+ ## Validation Contract
207
+
208
+ The implementation must cover:
209
+
210
+ - Classic `instance` and `aggregate` resolution.
211
+ - Leader own-thread, aggregate, and live-bound explicit target resolution.
212
+ - Follower assigned-thread and aggregate routing through the leader.
213
+ - Missing active turns and disconnected runtimes.
214
+ - Cross-profile, unknown, stale, and unauthorized explicit targets.
215
+ - Plain, HTML, and Markdown chunking.
216
+ - Reply-first and keyboard-last chunk placement.
217
+ - Send/edit growth/edit shrink/delete ordering.
218
+ - Reload/session-replacement generation invalidation.
219
+ - In-flight shutdown fencing and redacted diagnostics.
220
+ - Package-boundary imports with no `/lib` access.
221
+
222
+ ## Relationship To Activity
223
+
224
+ The Activity API builds on this contract rather than duplicating transport. Activity handlers receive a fresh target-aware context whose `send`, `edit`, `delete`, and chat-action methods delegate to the same delivery runtime. The Activity API owns lifecycle normalization; this API owns delivery only.