@llblab/pi-telegram 0.36.6 → 0.36.8

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
@@ -81,7 +81,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
81
81
  - A forwarding delivery id is stable across registration replacement and derives from envelope kind, source `update_id`, and stable recipient binding. Runtime instance and registration generation remain separate attempt fences. Persisted message ownership carries the stable binding so replay can rebind only to its current authenticated registration.
82
82
  - A queued receipt persists its acquiring runtime instance, OS pid/process-birth identity, session generation, acquisition id, and acquisition time. Only exact authority may settle or discard it. Same-process session replacement may reconstruct the claim and the original process may settle after transport ownership moves; a foreign process may neither replay nor settle it through generic removal or a copied acquisition id.
83
83
  - Startup and elapsed time are not owner-death proof; queued authority has no time lease. Dead-owner recovery groups the complete receipt and transactionally rechecks pid liveness plus process-birth identity: only an absent PID or mismatched stable Linux/macOS birth proof returns all sources to `pending`; a matching proof is `alive`, while Windows or inaccessible birth metadata is `unverifiable`, and both non-dead outcomes keep authority queued. The live-transfer contract is authenticated offer → exact-generation bounded payload staging → recipient CAS acceptance → exact receipt-and-owner ACK → donor removal → recipient readiness. The offer freezes donor settlement/recovery; controls rebuild local closures; negative/mismatched pre-acceptance ACK cancels only an unaccepted offer and retains donor work; a lost post-acceptance ACK cannot cancel recipient authority and leaves donor memory frozen for explicit reconciliation.
84
- - Execution failures persist bounded diagnostics and attempt state as `retry-wait`. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity.
84
+ - Execution failures persist bounded diagnostics and attempt state as `retry-wait`. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity. Missing snapshots left by the retired broad temp cleanup rebuild only from a complete provably empty segment chain, while revisionless snapshots may recover from a validated later segment predecessor; otherwise the snapshot and segments move atomically under `tmp/telegram/recovery/` before a fresh journal is published and startup continues with informational recovery evidence.
85
85
  - An unresolved reaction delays only the exact governed queue item identified by chat/message sources, not unrelated queue work. Queue receipt publication follows in-memory append and precedes dispatch request; receipt-bearing turns remain queued until every exact source commits.
86
86
  - The detailed implementation and release gates live in [`docs/architecture.md`](./docs/architecture.md), [`docs/multi-instance-bus.md`](./docs/multi-instance-bus.md), and [`BACKLOG.md`](./BACKLOG.md).
87
87
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.36.8: Durable Journal Recovery
6
+
7
+ - `Durable Journal Recovery`: Restricts age cleanup to UUID-prefixed downloads, repairs missing or revisionless snapshots from validated segment evidence, and otherwise quarantines the snapshot plus segments before publishing a fresh journal, keeping `/telegram-connect` operational with informational recovery evidence instead of manual JSON repair.
8
+
9
+ ## 0.36.7: Context-Aware Proactive Controls
10
+
11
+ - `Proactive Prompt Buttons`: Plans valid top-level `telegram_button` comments before local/autonomous assistant segments enter Rich or HTML proactive delivery, preserving the ordinary callback store and renderer path instead of silently hiding correct controls.
12
+ - `Connection-Aware Agent Context`: Successful setup/connect and disconnect commands queue a hidden connection-state note for delivery with the next agent turn without triggering one. Connected system guidance and Telegram Skills now distinguish transport capability from user intent, reserving proactive Telegram controls for Telegram-originated turns or explicit requests.
13
+
5
14
  ## 0.36.6: Orphan Journal Guard
6
15
 
7
16
  - `Orphan Journal Guard`: Rejects a missing journal snapshot when immutable revision segments still exist, preventing later admission from replacing uncertain durable authority with a new revisionless snapshot while preserving snapshot-first compaction and fail-closed recovery.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  It is a **runtime adapter**, not a remote terminal. Start or supervise work in the Pi TUI, then continue from Telegram while away from the keyboard. Each Telegram destination follows a running Pi instance and sends prompts into that instance's currently active session; it is not permanently bound to one session file or session identity. The bridge preserves Pi session semantics instead of pretending Telegram is a PTY, shell, process launcher, or session browser. That boundary is the product: Telegram gets safe runtime handles, not raw terminal power.
10
10
 
11
- Every completed intermediate commentary block from a Telegram-originated turn is delivered once as its own message before the existing final reply. Proactive push is enabled by default for local, autonomous, and unclassified extension follow-up work: `assistant.proactivePush` projects visible checkpoints and the final answer to the authorized Telegram target once and in order; set it explicitly to `false` to disable only that local/autonomous projection. Neither path mirrors local prompts, thinking, tool traffic, token deltas, or stale-generation work. The separate `Activity` setting defaults to `verbose` so new installations discover collapsed provider-exposed thinking and tool evidence immediately; operators can narrow it to one class or choose `quiet`. See [Outbound](docs/outbound.md#public-assistant-output) and the [configuration reference](docs/public-api.md#configuration-api).
11
+ Every completed intermediate commentary block from a Telegram-originated turn is delivered once as its own message before the existing final reply. Proactive push is enabled by default for local, autonomous, and unclassified extension follow-up work: `assistant.proactivePush` projects visible checkpoints and the final answer to the authorized Telegram target once and in order, preserving assistant-authored `telegram_button` comments as interactive prompt buttons; set it explicitly to `false` to disable only that local/autonomous projection. Neither path mirrors local prompts, thinking, tool traffic, token deltas, or stale-generation work. The separate `Activity` setting defaults to `verbose` so new installations discover collapsed provider-exposed thinking and tool evidence immediately; operators can narrow it to one class or choose `quiet`. See [Outbound](docs/outbound.md#public-assistant-output) and the [configuration reference](docs/public-api.md#configuration-api).
12
12
 
13
13
  This repository is an actively maintained fork of [`badlogic/pi-telegram`](https://github.com/badlogic/pi-telegram). It started from upstream commit [`cb34008`](https://github.com/badlogic/pi-telegram/commit/cb34008460b6c1ca036d92322f69d87f626be0fc) and has since diverged substantially.
14
14
 
@@ -57,7 +57,7 @@ Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved tok
57
57
 
58
58
  The connected Pi instance owns Telegram polling. Use `/telegram-connect <name>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the `default` profile keeps unsuffixed runtime paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers.
59
59
 
60
- After an unclean computer shutdown, `/telegram-connect` detects truncated or structurally invalid temporary ownership/routing files, quarantines only the damaged files under `tmp/telegram/recovery/`, and retries once. Saved `telegram.json` configuration and runtime diagnostics remain intact. Recovery never replaces a verifiable live owner; if safe automatic recovery cannot complete, the command gives one explicit Pi-restart instruction instead of requiring deletion of the whole `tmp/` directory.
60
+ After an unclean computer shutdown, `/telegram-connect` detects truncated or structurally invalid temporary ownership/routing files, quarantines only the damaged files under `tmp/telegram/recovery/`, and retries once. A journal snapshot removed by older broad temp cleanup is rebuilt when its complete segment history proves an empty result, while a revisionless snapshot is repaired from the first surviving segment's exact predecessor when the reconstructed tail validates. Otherwise the snapshot and segments are quarantined as recovery evidence, a fresh journal is published, and startup continues with an informational diagnostic instead of requiring manual JSON repair. Saved `telegram.json` configuration and runtime diagnostics remain intact. Recovery never replaces a verifiable live owner; if safe automatic recovery cannot complete, the command gives one explicit Pi-restart instruction instead of requiring deletion of the whole `tmp/` directory.
61
61
 
62
62
  ### 4. Pair your Telegram account
63
63
 
@@ -163,7 +163,7 @@ Telegram private-chat Threaded Mode is the public switch for multi-instance Tele
163
163
 
164
164
  Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes the `owners.json` slot, diagnostics logs, state files, thread/bus ownership, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the `default` slot. A different selected profile is a parallel bot runtime: its owner slot, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and other named profiles while shared bridge settings remain top-level/global.
165
165
 
166
- Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup is limited to stale scratch files and cannot redirect live traffic.
166
+ Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup recognizes only those UUID-prefixed scratch files. It never age-deletes journals, ownership, state, logs, or other top-level runtime files and therefore cannot redirect live traffic or orphan immutable journal segments.
167
167
 
168
168
  When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Pruning alone preserves the binding; when Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, and that cleanup serializes ahead of replacement registration. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
169
169
 
@@ -205,9 +205,9 @@ All inbound updates are gated by the configured authorized user id.
205
205
 
206
206
  Here, **durable** means recovery across ordinary process exit, crash, kill, and replacement after a successful atomic rename is visible to the filesystem. It does not promise survival across host, kernel, filesystem, storage-device, or power failure: journal and offset publication do not call `fsync`/`fdatasync`, and parent directories are not flushed. A host-level failure may therefore lose a recently acknowledged rename despite correct process-level ordering. Operators requiring that stronger boundary must place the agent directory on storage with an independently managed durability/backup policy; `0.28.0` must not be described as power-loss durable.
207
207
 
208
- The profile-scoped journal separates transport progress from semantic progress. Leader/classic snapshots live at `tmp/telegram/inbox[.<profile>].json`; follower paths add a stable target-binding hash. The selected post-v1 storage design is one revisioned compacted snapshot plus immutable atomic transaction segments beside it. Existing v1 files load as implicit revision `0`, while positive snapshot revisions are explicit. Immutable revision segments publish privately and atomically under the existing journal transaction lock; exact repeats are idempotent, while gaps and conflicting duplicate revisions fail closed. Each segment carries one complete mutation, and readers reconstruct ordered upserts, removals, and operator-disposition state only from revisions newer than the snapshot. Malformed or gapped segments, filename/revision disagreement, and foreign journal identity fail closed. After the initial snapshot, append, batch completion, queue receipt/owner/handoff, retry/terminal, recovery, and operator dispositions publish only changed upserts/removals and disposition replacement in one segment. This avoids rewriting retained raw updates during completion-heavy drains without splitting exact queue, failure, recovery, or disposition transactions.
208
+ The profile-scoped journal separates transport progress from semantic progress. Leader/classic snapshots live at `tmp/telegram/inbox[.<profile>].json`; follower paths add a stable target-binding hash. The selected post-v1 storage design is one revisioned compacted snapshot plus immutable atomic transaction segments beside it. Existing v1 files load as implicit revision `0`, while positive snapshot revisions are explicit. Immutable revision segments publish privately and atomically under the existing journal transaction lock; exact repeats are idempotent, while gaps and conflicting duplicate revisions fail closed. Each segment carries one complete mutation, and readers reconstruct ordered upserts, removals, and operator-disposition state only from revisions newer than the snapshot. Malformed or gapped segments, filename/revision disagreement, and foreign journal identity fail closed. Compatibility recovery for the former broad temp-cleanup bug rebuilds a missing snapshot when its complete revision-1 segment chain removes known base authority before any upsert and reconstructs to an empty journal, and repairs a revisionless snapshot when the first surviving segment supplies its exact positive predecessor revision and the reconstructed tail validates. If repair fails, the transaction-locked loader atomically moves the snapshot when present plus its segment directory under `tmp/telegram/recovery/`, publishes a fresh empty private journal, records an informational recovery event with the quarantine path, and continues startup; no uncertain files are silently deleted. After the initial snapshot, append, batch completion, queue receipt/owner/handoff, retry/terminal, recovery, and operator dispositions publish only changed upserts/removals and disposition replacement in one segment. This avoids rewriting retained raw updates during completion-heavy drains without splitting exact queue, failure, recovery, or disposition transactions.
209
209
 
210
- Compaction runs under the journal transaction lock when either 256 unapplied segments or 4 MiB of segment bytes is reached. It publishes the complete private (`0600`) snapshot at revision `R` before best-effort deletion of segments `<= R`; failed cleanup leaves redundant segments that readers ignore. Interrupted cleanup therefore leaves either an older snapshot plus newer authoritative segments or a newer snapshot plus harmless redundant older segments. Revision gaps, conflicting duplicates, malformed segments, and identity mismatches fail closed. The logical reconstructed journal and aggregate unapplied segment bytes are independently bounded at 10,000 entries and 32 MiB as applicable; rejected growth publishes neither snapshot nor segment bytes. Compaction may temporarily require exactly one private complete snapshot of at most 32 MiB. Capacity pauses polling and authority files are never automatically deleted, reset, or quarantined.
210
+ Compaction runs under the journal transaction lock when either 256 unapplied segments or 4 MiB of segment bytes is reached. It publishes the complete private (`0600`) snapshot at revision `R` before best-effort deletion of segments `<= R`; failed cleanup leaves redundant segments that readers ignore. Interrupted cleanup therefore leaves either an older snapshot plus newer authoritative segments or a newer snapshot plus harmless redundant older segments. Revision gaps, conflicting duplicates, malformed segments, and identity mismatches fail closed. The logical reconstructed journal and aggregate unapplied segment bytes are independently bounded at 10,000 entries and 32 MiB as applicable; rejected growth publishes neither snapshot nor segment bytes. Compaction may temporarily require exactly one private complete snapshot of at most 32 MiB. Capacity pauses polling and valid authority files are never automatically deleted, reset, or quarantined. Only a missing-snapshot history that cannot be reconstructed safely uses the explicit evidence-preserving quarantine-and-reset compatibility fallback above.
211
211
 
212
212
  `pending` entries remain immediately executable while raw interception, routing, or grouping is incomplete. Every execution failure becomes `retry-wait` with durable attempt count, next eligible time, failure class, bounded summary, and latest failure time. The `failed` state remains schema-compatible only for legacy candidate journals and is converted to automatic retry during lifecycle startup. `queued` entries carry exact prompt/control receipts plus the acquiring Pi runtime instance, OS pid/birth identity, session generation, acquisition id, and acquisition time. Queueing alone is never completion.
213
213
 
package/docs/outbound.md CHANGED
@@ -12,7 +12,7 @@ Every completed `assistant-segment` with `placement: "intermediate"` from a Tele
12
12
 
13
13
  Proactive projection defaults on for local, autonomous, and unclassified extension follow-up Pi work. With `assistant.proactivePush` omitted or set to `true`, every completed public block—including visible commentary/checkpoints and the final answer—is projected to the instance's authorized target in source order; set it explicitly to `false` to opt out of only this local/autonomous projection. Both paths consume normalized complete Activity segments rather than raw token deltas, reasoning, or tool traffic.
14
14
 
15
- Projected blocks use `assistant.rendering` independently of voice policy. Rich mode sends native Rich Markdown and HTML mode keeps the established HTML renderer; projection does not synthesize voice or attach queued files merely because Rich rendering is active. Ordered admission revalidates the exact target, profile/token transport generation, leader epoch or follower registration generation, and session generation before each send. Active-turn final delivery waits for admitted commentary inside its existing background delivery task, preserving commentary-before-final order without blocking Pi lifecycle completion. A `commit-unknown` outcome never permits replay.
15
+ Projected blocks use `assistant.rendering` independently of voice policy. Rich mode sends native Rich Markdown and HTML mode keeps the established HTML renderer; assistant-authored `telegram_button` comments are planned into prompt buttons before either renderer runs, while projection does not synthesize voice or attach queued files merely because Rich rendering is active. Ordered admission revalidates the exact target, profile/token transport generation, leader epoch or follower registration generation, and session generation before each send. Active-turn final delivery waits for admitted commentary inside its existing background delivery task, preserving commentary-before-final order without blocking Pi lifecycle completion. A `commit-unknown` outcome never permits replay.
16
16
 
17
17
  ## Technical Activity
18
18
 
@@ -40,8 +40,8 @@ import {
40
40
  Stable commands inside Pi:
41
41
 
42
42
  - `/telegram-setup` — configure/update the bot token.
43
- - `/telegram-connect` — start polling here and acquire external Telegram control ownership. Accepted queue/reply state stays local if ownership later moves elsewhere.
44
- - `/telegram-disconnect` — after destructive confirmation, stop polling and release ownership without deleting or silencing accepted local queue state. In Threaded Mode it deletes this instance's current Telegram thread; a follower waits for its active leader to confirm generation-fenced cleanup before stopping. Graceful Pi `quit` performs the same teardown without prompting, while `reload`, `new`, `resume`, and `fork` preserve same-process handoff.
43
+ - `/telegram-connect` — start polling here and acquire external Telegram control ownership. Accepted queue/reply state stays local if ownership later moves elsewhere. A successful command queues a hidden connection-state note for delivery with the agent's next turn without triggering one; it says Telegram is connected and that connectivity alone is not user intent.
44
+ - `/telegram-disconnect` — after destructive confirmation, stop polling and release ownership without deleting or silencing accepted local queue state. A successful command queues the corresponding hidden, non-triggering disconnected context note; cancelled or failed disconnects do not publish a false state transition. In Threaded Mode it deletes this instance's current Telegram thread; a follower waits for its active leader to confirm generation-fenced cleanup before stopping. Graceful Pi `quit` performs the same teardown without prompting, while `reload`, `new`, `resume`, and `fork` preserve same-process handoff.
45
45
  - `/telegram-status` — show connection, polling, execution, queue, and recent event diagnostics; debug output separates poller and worker progress, durable automatic-retry state, exact foreign queued-owner identity, and negotiated protocol/build/capabilities.
46
46
 
47
47
  ### Telegram commands
package/index.ts CHANGED
@@ -132,6 +132,22 @@ export default function (pi: Pi.ExtensionAPI) {
132
132
  getProfileName: configStore.getActiveProfileName,
133
133
  getBotToken: configStore.getBotToken,
134
134
  getBotId: getTelegramBotId,
135
+ onRecovery(event) {
136
+ recordRuntimeEvent(
137
+ "recovery",
138
+ event.kind === "repaired"
139
+ ? "Telegram update journal was repaired automatically."
140
+ : "Telegram update journal was reset after its damaged files were quarantined.",
141
+ {
142
+ phase: "journal-auto-recovery",
143
+ recoveryKind: event.kind,
144
+ journalPath: event.path,
145
+ revision: event.revision,
146
+ quarantinePath: event.quarantinePath,
147
+ reason: event.reason,
148
+ },
149
+ );
150
+ },
135
151
  getQueueRuntimeIdentity() {
136
152
  return {
137
153
  instanceId: telegramInstanceId,
@@ -539,6 +555,8 @@ export default function (pi: Pi.ExtensionAPI) {
539
555
  sendRichMessage,
540
556
  editMessage: editTelegramMessageText,
541
557
  getAssistantRenderingMode: configControls.getAssistantRenderingMode,
558
+ planButtonReply:
559
+ Outbound.createTelegramButtonReplyPlanner(buttonActionStore),
542
560
  execCommand: CommandTemplates.execCommandTemplate,
543
561
  getHandlers: configStore.getOutboundHandlers,
544
562
  recordRuntimeEvent,
package/lib/bindings.ts CHANGED
@@ -514,6 +514,18 @@ export function registerTelegramCommandsAndTools({
514
514
  sendMarkdownReply(chatId, undefined, markdown, options),
515
515
  recordRuntimeEvent,
516
516
  });
517
+ const queueAgentConnectionContext = (connected: boolean): void => {
518
+ pi.sendMessage(
519
+ {
520
+ customType: "telegram-connection-state",
521
+ content: connected
522
+ ? Prompts.TELEGRAM_CONNECTED_CONTEXT_MESSAGE
523
+ : Prompts.TELEGRAM_DISCONNECTED_CONTEXT_MESSAGE,
524
+ display: false,
525
+ },
526
+ { deliverAs: "nextTurn" },
527
+ );
528
+ };
517
529
  Commands.registerTelegramBridgeCommands(pi, {
518
530
  promptForConfig: async (ctx, profileName) => {
519
531
  const nextProfileName = profileName ?? undefined;
@@ -581,8 +593,11 @@ export function registerTelegramCommandsAndTools({
581
593
  recordRuntimeEvent,
582
594
  });
583
595
  const completion = await runSetup(ctx);
584
- if (profileName && completion.status === "success") {
585
- ctx.ui.notify(`Profile "${profileName}" saved and connected.`, "info");
596
+ if (completion.status === "success") {
597
+ queueAgentConnectionContext(true);
598
+ if (profileName) {
599
+ ctx.ui.notify(`Profile "${profileName}" saved and connected.`, "info");
600
+ }
586
601
  }
587
602
  },
588
603
  getStatusLines,
@@ -599,6 +614,7 @@ export function registerTelegramCommandsAndTools({
599
614
  stopPolling: stopPolling ?? lockedPollingRuntime.stop,
600
615
  recoverPollingStart,
601
616
  getDisconnectThreadName,
617
+ queueAgentConnectionContext,
602
618
  updateStatus,
603
619
  getProfileNames: () =>
604
620
  Config.getTelegramProfileNames(configStore.getStoredConfig()),
package/lib/commands.ts CHANGED
@@ -334,6 +334,7 @@ export interface TelegramBridgeCommandRegistrationDeps {
334
334
  error: unknown,
335
335
  ) => Promise<TelegramPollingStartRecoveryResult>;
336
336
  getDisconnectThreadName?: () => string | undefined;
337
+ queueAgentConnectionContext?: (connected: boolean) => void;
337
338
  updateStatus: (ctx: ExtensionCommandContext) => void;
338
339
  getProfileNames?: () => string[];
339
340
  activateDefaultProfileConfig?: (ctx: ExtensionCommandContext) => Promise<void>;
@@ -468,6 +469,9 @@ export function registerTelegramBridgeCommands(
468
469
  if (result?.message) {
469
470
  ctx.ui.notify(result.message, result.ok ? "info" : "warning");
470
471
  }
472
+ if (!result || result.ok) {
473
+ deps.queueAgentConnectionContext?.(true);
474
+ }
471
475
  deps.updateStatus(ctx);
472
476
  },
473
477
  });
@@ -490,6 +494,7 @@ export function registerTelegramBridgeCommands(
490
494
  try {
491
495
  const message = await deps.stopPolling();
492
496
  if (message) ctx.ui.notify(message, "info");
497
+ deps.queueAgentConnectionContext?.(false);
493
498
  } catch (error) {
494
499
  const detail = error instanceof Error ? error.message : String(error);
495
500
  ctx.ui.notify(
package/lib/journal.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  unlinkSync,
18
18
  writeFileSync,
19
19
  } from "node:fs";
20
- import { dirname, join } from "node:path";
20
+ import { basename, dirname, join } from "node:path";
21
21
  import { isDeepStrictEqual } from "node:util";
22
22
 
23
23
  import {
@@ -349,6 +349,14 @@ export type TelegramUpdateJournalPublicationBoundary =
349
349
  | "before-write"
350
350
  | "after-write-before-rename";
351
351
 
352
+ export interface TelegramUpdateJournalRecoveryEvent {
353
+ kind: "repaired" | "reset";
354
+ path: string;
355
+ revision?: number;
356
+ quarantinePath?: string;
357
+ reason: string;
358
+ }
359
+
352
360
  export interface TelegramUpdateJournalStoreOptions {
353
361
  path: string;
354
362
  profileName?: string;
@@ -356,6 +364,7 @@ export interface TelegramUpdateJournalStoreOptions {
356
364
  maxEntries?: number;
357
365
  maxBytes?: number;
358
366
  getNowMs?: () => number;
367
+ onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
359
368
  queueRuntimeIdentity?: TelegramUpdateJournalQueueRuntimeIdentity;
360
369
  getQueueProcessLiveness?: (
361
370
  owner: TelegramUpdateJournalQueueProcessIdentity,
@@ -1472,6 +1481,7 @@ export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
1472
1481
  getBotId: () => number | undefined;
1473
1482
  getJournalPath: (profileName?: string) => string;
1474
1483
  getQueueRuntimeIdentity?: () => TelegramUpdateJournalQueueRuntimeIdentity;
1484
+ onRecovery?: (event: TelegramUpdateJournalRecoveryEvent) => void;
1475
1485
  }
1476
1486
 
1477
1487
  export function createTelegramUpdateJournalRuntimeBindingResolver(
@@ -1506,6 +1516,7 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
1506
1516
  ...(deps.getQueueRuntimeIdentity
1507
1517
  ? { queueRuntimeIdentity: deps.getQueueRuntimeIdentity() }
1508
1518
  : {}),
1519
+ ...(deps.onRecovery ? { onRecovery: deps.onRecovery } : {}),
1509
1520
  }),
1510
1521
  };
1511
1522
  };
@@ -1546,6 +1557,7 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
1546
1557
  ...(includeQueueRuntimeIdentity && deps.base.getQueueRuntimeIdentity
1547
1558
  ? { getQueueRuntimeIdentity: deps.base.getQueueRuntimeIdentity }
1548
1559
  : {}),
1560
+ ...(deps.base.onRecovery ? { onRecovery: deps.base.onRecovery } : {}),
1549
1561
  getJournalPath(profileName) {
1550
1562
  return deps.getFollowerJournalPath(bindingKey, profileName);
1551
1563
  },
@@ -1601,6 +1613,13 @@ export function createTelegramUpdateJournalStore(
1601
1613
  );
1602
1614
  const getNowMs = options.getNowMs ?? Date.now;
1603
1615
  const onPublicationBoundary = options.onPublicationBoundary;
1616
+ const notifyRecovery = (event: TelegramUpdateJournalRecoveryEvent): void => {
1617
+ try {
1618
+ options.onRecovery?.(event);
1619
+ } catch {
1620
+ // Recovery diagnostics must not break recovered journal authority.
1621
+ }
1622
+ };
1604
1623
  const getQueueProcessLiveness =
1605
1624
  options.getQueueProcessLiveness ?? getTelegramProcessLiveness;
1606
1625
 
@@ -1742,8 +1761,9 @@ export function createTelegramUpdateJournalStore(
1742
1761
  entries: [],
1743
1762
  });
1744
1763
 
1745
- const readCurrent = (): ReadTelegramUpdateJournalResult => {
1764
+ const readCurrentStrict = (): ReadTelegramUpdateJournalResult => {
1746
1765
  let source: string;
1766
+ let recoveringMissingSnapshot = false;
1747
1767
  try {
1748
1768
  const size = statSync(path).size;
1749
1769
  if (size > maxBytes) {
@@ -1775,15 +1795,14 @@ export function createTelegramUpdateJournalStore(
1775
1795
  );
1776
1796
  }
1777
1797
  if (orphanedSegmentNames.length > 0) {
1778
- throw createJournalError(
1779
- "invalid",
1780
- path,
1781
- `is missing while ${segmentDirectory} retains revision segments`,
1782
- );
1798
+ recoveringMissingSnapshot = true;
1799
+ source = serializeJournalFile(emptyFile());
1800
+ } else {
1801
+ return { file: emptyFile(), exists: false, serializedBytes: 0 };
1783
1802
  }
1784
- return { file: emptyFile(), exists: false, serializedBytes: 0 };
1803
+ } else {
1804
+ throw createJournalError("io", path, "could not be read", error);
1785
1805
  }
1786
- throw createJournalError("io", path, "could not be read", error);
1787
1806
  }
1788
1807
  let parsed: unknown;
1789
1808
  try {
@@ -1808,6 +1827,9 @@ export function createTelegramUpdateJournalStore(
1808
1827
  segmentNames.sort();
1809
1828
  let revision = file.revision ?? 0;
1810
1829
  let unappliedSegmentBytes = 0;
1830
+ let orphanRecoverySawUpsert = false;
1831
+ let orphanRecoverySawBaseRemoval = false;
1832
+ let orphanRecoveryUnsafe = false;
1811
1833
  for (const name of segmentNames) {
1812
1834
  const nameRevision = Number(name.slice(0, 16));
1813
1835
  if (nameRevision <= revision) continue;
@@ -1871,11 +1893,16 @@ export function createTelegramUpdateJournalStore(
1871
1893
  file.entries.map((entry) => [entry.updateId, entry]),
1872
1894
  );
1873
1895
  for (const updateId of segment.removedUpdateIds) {
1896
+ if (recoveringMissingSnapshot && !entriesById.has(updateId)) {
1897
+ orphanRecoverySawBaseRemoval = true;
1898
+ if (orphanRecoverySawUpsert) orphanRecoveryUnsafe = true;
1899
+ }
1874
1900
  entriesById.delete(updateId);
1875
1901
  }
1876
1902
  for (const entry of segment.upsertedEntries) {
1877
1903
  entriesById.set(entry.updateId, entry);
1878
1904
  }
1905
+ if (segment.upsertedEntries.length > 0) orphanRecoverySawUpsert = true;
1879
1906
  file = parseJournalFile(
1880
1907
  {
1881
1908
  version: TELEGRAM_UPDATE_JOURNAL_VERSION,
@@ -1935,10 +1962,166 @@ export function createTelegramUpdateJournalStore(
1935
1962
  }
1936
1963
  return { file, exists: true, serializedBytes: reboundBytes };
1937
1964
  }
1965
+ if (recoveringMissingSnapshot) {
1966
+ if (
1967
+ orphanRecoveryUnsafe ||
1968
+ !orphanRecoverySawBaseRemoval ||
1969
+ file.entries.length > 0
1970
+ ) {
1971
+ throw createJournalError(
1972
+ "invalid",
1973
+ path,
1974
+ `is missing while ${segmentDirectory} retains revision segments`,
1975
+ );
1976
+ }
1977
+ const recovered = serializeJournalFile(file);
1978
+ const recoveredBytes = assertCapacity(file, recovered);
1979
+ writeJournalFile(path, recovered, onPublicationBoundary);
1980
+ notifyRecovery({
1981
+ kind: "repaired",
1982
+ path,
1983
+ revision: file.revision,
1984
+ reason: "Recovered a missing snapshot from a complete empty segment history.",
1985
+ });
1986
+ return { file, exists: true, serializedBytes: recoveredBytes };
1987
+ }
1938
1988
  const serializedBytes = assertCapacity(file);
1939
1989
  return { file, exists: true, serializedBytes };
1940
1990
  };
1941
1991
 
1992
+ const readCurrent = (): ReadTelegramUpdateJournalResult => {
1993
+ try {
1994
+ return readCurrentStrict();
1995
+ } catch (error) {
1996
+ if (
1997
+ !(error instanceof TelegramUpdateJournalError) ||
1998
+ error.code !== "invalid"
1999
+ ) {
2000
+ throw error;
2001
+ }
2002
+ let snapshotExists = false;
2003
+ try {
2004
+ statSync(path);
2005
+ snapshotExists = true;
2006
+ } catch (snapshotError) {
2007
+ if ((snapshotError as { code?: unknown })?.code !== "ENOENT") throw error;
2008
+ }
2009
+ const segmentDirectory = getTelegramUpdateJournalSegmentDirectory(path);
2010
+ let segmentNames: string[];
2011
+ try {
2012
+ segmentNames = readdirSync(segmentDirectory)
2013
+ .filter((name) => /^\d{16}\.json$/u.test(name))
2014
+ .sort();
2015
+ } catch {
2016
+ throw error;
2017
+ }
2018
+ if (segmentNames.length === 0) throw error;
2019
+
2020
+ if (snapshotExists) {
2021
+ try {
2022
+ const snapshot = parseJournalFile(
2023
+ JSON.parse(readFileSync(path, "utf8")) as unknown,
2024
+ path,
2025
+ );
2026
+ const firstSegmentPath = join(segmentDirectory, segmentNames[0]);
2027
+ const firstSegment = parseJournalSegment(
2028
+ JSON.parse(readFileSync(firstSegmentPath, "utf8")) as unknown,
2029
+ firstSegmentPath,
2030
+ );
2031
+ if (
2032
+ snapshot.revision === undefined &&
2033
+ firstSegment.previousRevision > 0 &&
2034
+ snapshot.profile === firstSegment.profile &&
2035
+ identitiesMatch(snapshot.botIdentity, firstSegment.botIdentity)
2036
+ ) {
2037
+ writeJournalFile(
2038
+ path,
2039
+ serializeJournalFile({
2040
+ ...snapshot,
2041
+ revision: firstSegment.previousRevision,
2042
+ }),
2043
+ onPublicationBoundary,
2044
+ );
2045
+ const repaired = readCurrentStrict();
2046
+ notifyRecovery({
2047
+ kind: "repaired",
2048
+ path,
2049
+ revision: repaired.file.revision,
2050
+ reason: `Recovered a revisionless snapshot from segment revision ${firstSegment.revision}.`,
2051
+ });
2052
+ return repaired;
2053
+ }
2054
+ } catch {
2055
+ // Fall through to evidence-preserving quarantine and reset.
2056
+ }
2057
+ }
2058
+
2059
+ const recoveryDirectory = join(
2060
+ dirname(path),
2061
+ "recovery",
2062
+ `${getNowMs()}-${process.pid}-${randomUUID()}`,
2063
+ );
2064
+ mkdirSync(recoveryDirectory, { recursive: true, mode: 0o700 });
2065
+ const snapshotQuarantinePath = join(recoveryDirectory, basename(path));
2066
+ const segmentQuarantinePath = join(
2067
+ recoveryDirectory,
2068
+ basename(segmentDirectory),
2069
+ );
2070
+ if (
2071
+ snapshotExists &&
2072
+ !renameTelegramPathWithRetry(path, snapshotQuarantinePath)
2073
+ ) {
2074
+ throw createJournalError(
2075
+ "io",
2076
+ path,
2077
+ "could not quarantine an unrecoverable journal snapshot",
2078
+ error,
2079
+ );
2080
+ }
2081
+ if (!renameTelegramPathWithRetry(segmentDirectory, segmentQuarantinePath)) {
2082
+ if (snapshotExists) {
2083
+ renameTelegramPathWithRetry(snapshotQuarantinePath, path);
2084
+ }
2085
+ throw createJournalError(
2086
+ "io",
2087
+ segmentDirectory,
2088
+ "could not quarantine an unrecoverable journal segment history",
2089
+ error,
2090
+ );
2091
+ }
2092
+ const reset = emptyFile();
2093
+ const serialized = serializeJournalFile(reset);
2094
+ const serializedBytes = assertCapacity(reset, serialized);
2095
+ try {
2096
+ writeJournalFile(path, serialized, onPublicationBoundary);
2097
+ } catch (publicationError) {
2098
+ const segmentsRestored = renameTelegramPathWithRetry(
2099
+ segmentQuarantinePath,
2100
+ segmentDirectory,
2101
+ );
2102
+ const snapshotRestored =
2103
+ !snapshotExists ||
2104
+ renameTelegramPathWithRetry(snapshotQuarantinePath, path);
2105
+ if (!segmentsRestored || !snapshotRestored) {
2106
+ throw createJournalError(
2107
+ "io",
2108
+ path,
2109
+ "reset publication failed after journal evidence was quarantined",
2110
+ publicationError,
2111
+ );
2112
+ }
2113
+ throw publicationError;
2114
+ }
2115
+ notifyRecovery({
2116
+ kind: "reset",
2117
+ path,
2118
+ quarantinePath: recoveryDirectory,
2119
+ reason: error.message,
2120
+ });
2121
+ return { file: reset, exists: true, serializedBytes };
2122
+ }
2123
+ };
2124
+
1942
2125
  const runMutation = <T>(operation: () => T): T => {
1943
2126
  try {
1944
2127
  return withTelegramFileTransaction(`${path}.transaction`, operation);
package/lib/outbound.ts CHANGED
@@ -958,6 +958,10 @@ export function createTelegramAssistantOutputSender<
958
958
  ) => Promise<TelegramSentMessage>;
959
959
  editMessage: (body: TelegramEditMessageTextBody) => Promise<unknown>;
960
960
  getAssistantRenderingMode: () => "rich" | "html";
961
+ planButtonReply?: (markdown: string) => {
962
+ markdown: string;
963
+ replyMarkup?: TReplyMarkup;
964
+ };
961
965
  execCommand: TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>["execCommand"];
962
966
  getHandlers?: TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>["getHandlers"];
963
967
  recordRuntimeEvent?: TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>["recordRuntimeEvent"];
@@ -997,11 +1001,19 @@ export function createTelegramAssistantOutputSender<
997
1001
  getHandlers: deps.getHandlers,
998
1002
  recordRuntimeEvent: deps.recordRuntimeEvent,
999
1003
  });
1004
+ const buttonReply = deps.planButtonReply?.(event.text) ?? {
1005
+ markdown: event.text,
1006
+ };
1000
1007
  await outboundRuntime.sendMarkdownReply(
1001
1008
  target.chatId,
1002
1009
  undefined,
1003
- event.text,
1004
- { target },
1010
+ buttonReply.markdown,
1011
+ {
1012
+ target,
1013
+ ...(buttonReply.replyMarkup
1014
+ ? { replyMarkup: buttonReply.replyMarkup }
1015
+ : {}),
1016
+ },
1005
1017
  );
1006
1018
  };
1007
1019
  }
package/lib/prompts.ts CHANGED
@@ -7,9 +7,14 @@
7
7
  import type { BeforeAgentStartEvent } from "./pi.ts";
8
8
  import { TELEGRAM_PREFIX } from "./turns.ts";
9
9
 
10
+ export const TELEGRAM_CONNECTED_CONTEXT_MESSAGE =
11
+ "Telegram session connected. Use Telegram features for Telegram-originated turns or explicit Telegram requests; connectivity alone is not user intent.";
12
+ export const TELEGRAM_DISCONNECTED_CONTEXT_MESSAGE =
13
+ "Telegram session disconnected. Do not use Telegram delivery, actions, or Telegram-specific reply features unless the user reconnects it.";
14
+
10
15
  const LOCAL_SYSTEM_PROMPT_SUFFIX = `
11
16
 
12
- Telegram bridge available. Load the \`telegram-bridge\` Skill for Telegram delivery, actions, Threaded Mode, or diagnosis. Do not use Telegram from local/TUI prompts unless explicitly asked.`;
17
+ ${TELEGRAM_CONNECTED_CONTEXT_MESSAGE} Load the \`telegram-bridge\` Skill for Telegram-originated turns or explicit requests involving Telegram delivery, actions, Threaded Mode, or diagnosis. Do not use Telegram-specific features from unrelated local/TUI prompts.`;
13
18
 
14
19
  const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
15
20
 
@@ -37,6 +37,8 @@ function getTelegramApiTempDir(): string {
37
37
  return resolveTelegramTempDir();
38
38
  }
39
39
  const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
40
+ const TELEGRAM_TEMP_SCRATCH_FILE_PATTERN =
41
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/u;
40
42
  const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
41
43
  process.env,
42
44
  ["PI_TELEGRAM_INBOUND_FILE_MAX_BYTES", "TELEGRAM_MAX_FILE_SIZE_BYTES"],
@@ -1116,7 +1118,9 @@ export async function cleanupTelegramTempFiles(
1116
1118
  return 0;
1117
1119
  }
1118
1120
  for (const entry of entries) {
1119
- if (!entry.isFile()) continue;
1121
+ if (!entry.isFile() || !TELEGRAM_TEMP_SCRATCH_FILE_PATTERN.test(entry.name)) {
1122
+ continue;
1123
+ }
1120
1124
  const path = join(tempDir, entry.name);
1121
1125
  try {
1122
1126
  const stats = await stat(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.36.6",
3
+ "version": "0.36.8",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: generated-control-surface
3
- description: Proactively compiles current state, available capabilities, and user intent into contextual, evidence-backed, ephemeral prompt-button interfaces. Use on Telegram turns and other prompt-button transports whenever controls materially shorten likely feedback, without requiring an explicit user request; route reusable deterministic loops toward Generative Apps, omit decorative UI, and preserve domain ownership while fixed transport menus and callbacks remain with their runtime owners.
3
+ description: Proactively compiles current state, available capabilities, and user intent into contextual, evidence-backed, ephemeral prompt-button interfaces. Use without an explicit button request on Telegram-originated turns when controls materially shorten likely feedback, or when a user explicitly requests controls on another prompt-button transport; connection or proactive projection alone is not activation intent. Route reusable deterministic loops toward Generative Apps, omit decorative UI, and preserve domain ownership while fixed transport menus and callbacks remain with their runtime owners.
4
4
  ---
5
5
 
6
6
  # Generated Control Surface
@@ -29,7 +29,7 @@ This Skill and `generative-apps` share one logical button-matrix and `label + pr
29
29
 
30
30
  When a generated surface reveals repeated stable interaction with bounded state and deterministic transitions, load the complementary `generative-apps` Skill and consider compiling that loop. A Generative App may retain ordinary model-mediated prompt buttons beside deterministic bound methods, so only the stable actions need bypass inference. Conversely, keep one-off, interpretive, changing, and context-heavy interaction here when a reusable script would add no concrete latency, token, cost, reliability, or UX value.
31
31
 
32
- On Telegram turns, evaluate this Skill proactively rather than waiting for the user to ask for buttons. Load and apply it when a likely next decision, approval, navigation step, inspection, or bounded action can be made materially easier through controls; its correct output may still contain zero buttons when the admission test fails.
32
+ On Telegram-originated turns, evaluate this Skill proactively rather than waiting for the user to ask for buttons. A connected Telegram session, available button syntax, or proactive projection of local output is capability evidence, not activation intent: do not load this Skill for an unrelated local/TUI prompt merely because its answer may also appear in Telegram. Load and apply it when the current turn is Telegram-originated or the user explicitly requests a prompt-button surface and a likely next decision, approval, navigation step, inspection, or bounded action can be made materially easier through controls; its correct output may still contain zero buttons when the admission test fails.
33
33
 
34
34
  A control surface may expose:
35
35
 
@@ -7,6 +7,12 @@ description: Operates pi-telegram turns, replies, attachments, direct delivery,
7
7
 
8
8
  Use pi-telegram as a mobile companion surface for the current Pi session. Preserve the current Telegram target, ordinary reply ownership, durable queue semantics, and the boundary between agent intent and bridge transport.
9
9
 
10
+ ## Connection Awareness
11
+
12
+ `Telegram session connected.` means this Pi instance currently has authorized Telegram transport, so Telegram tools and proactive projection may be available. It does not mean the current prompt came from Telegram and does not grant intent to add Telegram actions to unrelated local/TUI work. Use Telegram-specific reply features proactively only on a turn carrying structured Telegram origin; from local/TUI prompts, use them only when the user explicitly requests Telegram delivery or the established workflow is explicitly Telegram-mediated.
13
+
14
+ `Telegram session disconnected.` revokes that availability. Do not attempt Telegram delivery, actions, buttons, or voice until a later connected context is present. Treat the newest connection-state context as authoritative.
15
+
10
16
  ## Turn Recognition
11
17
 
12
18
  Telegram-originated prompts carry structured context:
@@ -48,7 +54,7 @@ Use `telegram_attach` outside Telegram turns only when the user explicitly reque
48
54
 
49
55
  Before compiling assistant-authored controls, inspect already-loaded capability guidance for an advertised maintained Generative App or view/controller adapter. When that owner-provided view exists and the current intent concerns repeated interaction, load and follow the bundled `generative-apps` Skill and prefer binding or invoking the existing app over synthesizing one-shot prompt buttons. This routing guidance is not permission for the bridge to discover capability-specific apps, own their state, or hard-code their identities.
50
56
 
51
- Otherwise, on Telegram turns, proactively load `generated-control-surface` when a likely next decision or action may benefit from prompt buttons; do not wait for an explicit button request, and accept zero controls when its admission rules reject decorative or low-value UI.
57
+ Otherwise, only on Telegram-originated turns, proactively load `generated-control-surface` when a likely next decision or action may benefit from prompt buttons; do not activate it merely because Telegram is connected or local output may be projected by proactive push. Do not wait for an explicit button request on a Telegram-originated turn, and accept zero controls when its admission rules reject decorative or low-value UI.
52
58
 
53
59
  `telegram_button` and `telegram_voice` are hidden top-level HTML comments, not tools. Emit them at column zero, outside lists, quotes, code blocks, and indentation.
54
60