@llblab/pi-kit 0.1.5 → 0.1.6

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-actors/BACKLOG.md +1 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +14 -1
  5. package/node_modules/@llblab/pi-actors/README.md +19 -3
  6. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +29 -10
  7. package/node_modules/@llblab/pi-actors/dist/lib/inspector-overlay.d.ts +3 -0
  8. package/node_modules/@llblab/pi-actors/dist/lib/inspector-overlay.js +120 -38
  9. package/node_modules/@llblab/pi-actors/dist/lib/limits.d.ts +1 -0
  10. package/node_modules/@llblab/pi-actors/dist/lib/limits.js +1 -0
  11. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +6 -1
  12. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.d.ts +10 -3
  13. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +109 -52
  14. package/node_modules/@llblab/pi-actors/dist/lib/runs-artifacts.js +24 -4
  15. package/node_modules/@llblab/pi-actors/dist/lib/runtime.d.ts +2 -0
  16. package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +24 -4
  17. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.d.ts +1 -0
  18. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.js +3 -2
  19. package/node_modules/@llblab/pi-actors/dist/lib/state-readers.d.ts +5 -1
  20. package/node_modules/@llblab/pi-actors/dist/lib/state-readers.js +30 -3
  21. package/node_modules/@llblab/pi-actors/dist/lib/tools-access.d.ts +1 -0
  22. package/node_modules/@llblab/pi-actors/dist/lib/tools-access.js +13 -11
  23. package/node_modules/@llblab/pi-actors/dist/lib/tools-inspect.js +5 -5
  24. package/node_modules/@llblab/pi-actors/dist/lib/tools-message.d.ts +1 -0
  25. package/node_modules/@llblab/pi-actors/dist/lib/tools-message.js +3 -1
  26. package/node_modules/@llblab/pi-actors/docs/README.md +1 -0
  27. package/node_modules/@llblab/pi-actors/docs/actor-inspector.md +21 -5
  28. package/node_modules/@llblab/pi-actors/docs/async-runs.md +16 -4
  29. package/node_modules/@llblab/pi-actors/docs/command-templates.md +5 -4
  30. package/node_modules/@llblab/pi-actors/docs/inspection.md +83 -0
  31. package/node_modules/@llblab/pi-actors/docs/recipe-library.md +1 -1
  32. package/node_modules/@llblab/pi-actors/docs/template-recipes.md +225 -66
  33. package/node_modules/@llblab/pi-actors/docs/tool-registry.md +24 -3
  34. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +26 -10
  35. package/node_modules/@llblab/pi-actors/lib/inspector-overlay.ts +161 -33
  36. package/node_modules/@llblab/pi-actors/lib/limits.ts +1 -0
  37. package/node_modules/@llblab/pi-actors/lib/observability.ts +5 -1
  38. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +128 -50
  39. package/node_modules/@llblab/pi-actors/lib/runs-artifacts.ts +34 -4
  40. package/node_modules/@llblab/pi-actors/lib/runtime.ts +26 -7
  41. package/node_modules/@llblab/pi-actors/lib/session-evidence.ts +7 -2
  42. package/node_modules/@llblab/pi-actors/lib/state-readers.ts +45 -3
  43. package/node_modules/@llblab/pi-actors/lib/tools-access.ts +26 -12
  44. package/node_modules/@llblab/pi-actors/lib/tools-inspect.ts +9 -5
  45. package/node_modules/@llblab/pi-actors/lib/tools-message.ts +8 -1
  46. package/node_modules/@llblab/pi-actors/package.json +1 -1
  47. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  48. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
  49. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  50. package/node_modules/@llblab/pi-telegram/docs/architecture.md +3 -3
  51. package/node_modules/@llblab/pi-telegram/index.ts +25 -0
  52. package/node_modules/@llblab/pi-telegram/lib/journal.ts +192 -9
  53. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +52 -2
  54. package/node_modules/@llblab/pi-telegram/lib/status.ts +3 -3
  55. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +5 -1
  56. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  57. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +14 -6
  58. package/package.json +4 -4
@@ -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
 
@@ -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,
@@ -449,6 +465,15 @@ export default function (pi: Pi.ExtensionAPI) {
449
465
  BusApi.createTelegramAggregateTypingActionSender(telegramApiRuntime),
450
466
  updateStatus,
451
467
  isContextActive: telegramSessionContextStore.isCurrent,
468
+ getTransportAuthority() {
469
+ if (ownsTelegramDirectDelivery()) {
470
+ const epoch = getCurrentLeaderEpoch();
471
+ return epoch === undefined ? undefined : `direct:${epoch}`;
472
+ }
473
+ if (!telegramBusFollowerRegistrationState.isRegistered()) return undefined;
474
+ const generation = telegramBusFollowerRegistrationState.getGeneration();
475
+ return generation ? `follower:${generation}` : undefined;
476
+ },
452
477
  recordRuntimeEvent,
453
478
  });
454
479
  const currentModelRuntime = Model.createCurrentModelRuntime({
@@ -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);
@@ -320,6 +320,8 @@ export interface TelegramTypingLoopDeps {
320
320
  options?: { message_thread_id?: number },
321
321
  ) => Promise<unknown>;
322
322
  sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
323
+ shouldContinue?: () => boolean;
324
+ onStopped?: () => void;
323
325
  }
324
326
 
325
327
  export interface TelegramRuntimeEventRecorderPort {
@@ -365,6 +367,8 @@ export interface TelegramTypingLoopStarterDeps<
365
367
  sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
366
368
  updateStatus: (ctx: TContext, error?: string) => void;
367
369
  isContextActive?: (ctx: TContext) => boolean;
370
+ isTransportAvailable?: () => boolean;
371
+ getTransportAuthority?: () => string | number | undefined;
368
372
  intervalMs?: number;
369
373
  }
370
374
 
@@ -376,15 +380,33 @@ export function createTelegramTypingLoopStarter<TContext>(
376
380
  options?: { target?: TelegramTypingLoopTarget },
377
381
  ) => void {
378
382
  return (ctx, chatId, options) => {
383
+ const transportAuthority = deps.getTransportAuthority?.();
384
+ const hasTransport = (): boolean =>
385
+ deps.getTransportAuthority
386
+ ? transportAuthority !== undefined &&
387
+ Object.is(deps.getTransportAuthority(), transportAuthority)
388
+ : deps.isTransportAvailable?.() !== false;
389
+ if (!hasTransport()) return;
390
+ let active = true;
379
391
  deps.typing.start({
380
392
  chatId: chatId ?? deps.getDefaultChatId(),
381
393
  target: options?.target,
382
394
  intervalMs: deps.intervalMs ?? TELEGRAM_TYPING_ACTION_INTERVAL_MS,
383
395
  sendTypingAction: async (targetChatId, actionOptions) => {
396
+ if (!active) return;
397
+ if (!hasTransport()) {
398
+ deps.typing.stop();
399
+ return;
400
+ }
384
401
  try {
385
402
  await deps.sendTypingAction(targetChatId, actionOptions);
386
403
  } catch (error) {
387
404
  if (deps.isContextActive?.(ctx) === false) return;
405
+ if (!active) return;
406
+ if (!hasTransport()) {
407
+ deps.typing.stop();
408
+ return;
409
+ }
388
410
  const message =
389
411
  error instanceof Error ? error.message : String(error);
390
412
  updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
@@ -404,10 +426,20 @@ export function createTelegramTypingLoopStarter<TContext>(
404
426
  },
405
427
  sendAggregateTypingAction: deps.sendAggregateTypingAction
406
428
  ? async (targetChatId) => {
429
+ if (!active) return;
430
+ if (!hasTransport()) {
431
+ deps.typing.stop();
432
+ return;
433
+ }
407
434
  try {
408
435
  await deps.sendAggregateTypingAction?.(targetChatId);
409
436
  } catch (error) {
410
437
  if (deps.isContextActive?.(ctx) === false) return;
438
+ if (!active) return;
439
+ if (!hasTransport()) {
440
+ deps.typing.stop();
441
+ return;
442
+ }
411
443
  const message =
412
444
  error instanceof Error ? error.message : String(error);
413
445
  updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
@@ -427,6 +459,10 @@ export function createTelegramTypingLoopStarter<TContext>(
427
459
  }
428
460
  }
429
461
  : undefined,
462
+ shouldContinue: hasTransport,
463
+ onStopped: () => {
464
+ active = false;
465
+ },
430
466
  });
431
467
  };
432
468
  }
@@ -442,7 +478,12 @@ export function startTelegramTypingLoop(
442
478
  ): boolean {
443
479
  if (deps.chatId === undefined || deps.chatId === 0) return false;
444
480
  const previousKey = state.typingLoopKey;
481
+ const previousDeps = state.typingLoopDeps;
445
482
  const nextKey = getTelegramTypingLoopKey(deps);
483
+ if (previousDeps && previousDeps !== deps) {
484
+ previousDeps.onStopped?.();
485
+ state.typingInFlight = undefined;
486
+ }
446
487
  state.typingLoopDeps = deps;
447
488
  state.typingLoopKey = nextKey;
448
489
  const sendTyping = (): void => {
@@ -450,10 +491,14 @@ export function startTelegramTypingLoop(
450
491
  if (
451
492
  !activeDeps ||
452
493
  activeDeps.chatId === undefined ||
453
- activeDeps.chatId === 0 ||
454
- state.typingInFlight
494
+ activeDeps.chatId === 0
455
495
  )
456
496
  return;
497
+ if (activeDeps.shouldContinue?.() === false) {
498
+ stopTelegramTypingLoop(state);
499
+ return;
500
+ }
501
+ if (state.typingInFlight) return;
457
502
  const targetChatId = activeDeps.chatId;
458
503
  const threadParams = getTelegramTypingLoopThreadParams(activeDeps.target);
459
504
  const typing = Promise.resolve()
@@ -486,9 +531,12 @@ export function stopTelegramTypingLoop(
486
531
  ): boolean {
487
532
  if (!state.typingInterval) return false;
488
533
  clearInterval(state.typingInterval);
534
+ const activeDeps = state.typingLoopDeps;
489
535
  state.typingInterval = undefined;
490
536
  state.typingLoopDeps = undefined;
491
537
  state.typingLoopKey = undefined;
538
+ state.typingInFlight = undefined;
539
+ activeDeps?.onStopped?.();
492
540
  return true;
493
541
  }
494
542
 
@@ -571,6 +619,8 @@ export interface TelegramPromptDispatchRuntimeDeps<
571
619
  sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
572
620
  updateStatus: (ctx: TContext, error?: string) => void;
573
621
  isContextActive?: (ctx: TContext) => boolean;
622
+ isTransportAvailable?: () => boolean;
623
+ getTransportAuthority?: () => string | number | undefined;
574
624
  intervalMs?: number;
575
625
  }
576
626
 
@@ -871,9 +871,6 @@ export function buildTelegramStatusBarText(
871
871
  state: TelegramStatusBarState,
872
872
  ): string {
873
873
  const label = theme.fg("accent", getTelegramStatusBarLabel(state));
874
- if (state.error) {
875
- return `${label} ${theme.fg("error", "error")}`;
876
- }
877
874
  const queued = state.queuedStatus
878
875
  ? theme.fg("success", state.queuedStatus)
879
876
  : "";
@@ -885,6 +882,9 @@ export function buildTelegramStatusBarText(
885
882
  return `${label} ${theme.fg("warning", "electing")}${queued}`;
886
883
  if (!state.pollingActive && state.busRole !== "follower")
887
884
  return `${theme.fg("accent", "telegram")} ${theme.fg("dim", "disconnected")}${queued}`;
885
+ if (state.error) {
886
+ return `${label} ${theme.fg("error", "error")}`;
887
+ }
888
888
  if (state.processing) {
889
889
  const processingStatus = state.queuedStatus
890
890
  ? "active"
@@ -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);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.36.7",
3
+ "version": "0.36.10",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -54,9 +54,15 @@ Console programs are one capability source, not the defining boundary. Use the r
54
54
 
55
55
  ## Control Admission
56
56
 
57
- Buttons are optional, but bias toward offering them when they materially shorten a likely feedback loop. If the user can approve, reject, refine, prioritize, redirect, inspect, or choose a concrete next step faster by tapping than by composing a reply, proactively expose the smallest useful control set without waiting to be asked for buttons.
57
+ Buttons are optional only when no candidate passes the admission test. Bias toward offering them whenever they materially shorten a likely feedback loop. If the user can approve, reject, refine, prioritize, redirect, inspect, or choose a concrete next step faster by tapping than by composing a reply, proactively expose the smallest useful control set without waiting to be asked for buttons.
58
58
 
59
- Zero buttons remains preferable when controls would only decorate the answer, restate visible prose, solicit generic “What next?” input, expose an unclear consequence, or save negligible effort. A button earns its place by reducing response effort, ambiguity, turnaround time, or supervision cost while preserving an ordinary typed reply as a first-class option.
59
+ On a Telegram-originated turn, emitting controls is required when the response asks the user for one or more bounded confirmations or choices, the likely answers can be represented truthfully in 2–6 safe controls, and no secret-bearing or high-impact ambiguity blocks their formulation. This includes blocking questions such as confirming scope, version, workflow classification, approval, or the next release step. Ask in prose when explanation is necessary, but attach the controls in the same reply; the availability of free-form typing is not a reason to omit them. A high-impact operation still uses the required confirmation flow rather than a one-tap execution shortcut.
60
+
61
+ Apply the same requirement at a workflow handoff even when the current user message itself was a complete correction or implementation command. Before concluding a Telegram reply, project the user's likely next intent from the active goal, recent trajectory, newly completed act, and available capabilities—not only from an explicitly pending question. Typical phase transitions include “prepare → release”, “inspect → approve”, “fix → rerun”, and “draft → send”. If one next action or a small alternative set is high-confidence, newly unblocked, truthfully expressible, and safe to request, emit 2–6 controls now instead of waiting for the user to restate the obvious next step. A narrowly completed subtask does not erase the parent intent or make the predictive handoff decorative.
62
+
63
+ This is anticipatory interaction, not generic suggestion generation. Prefer controls that advance the user's demonstrated workflow over broad capability menus, speculative side quests, or “What next?” buttons. A predicted high-impact action is offered as an explicit self-contained request or confirmation, never silently executed. When confidence is low or materially different next intents compete, omit controls or expose only the smallest clarifying choice.
64
+
65
+ Zero buttons remains preferable when controls would only decorate the answer, restate visible prose, solicit generic “What next?” input, expose an unclear consequence, save negligible effort, or when the user already issued a complete command and neither that command nor the inferred active workflow leaves a high-confidence immediate decision. A button earns its place by reducing response effort, ambiguity, turnaround time, or supervision cost while preserving an ordinary typed reply as a first-class option.
60
66
 
61
67
  For status requests, show a compact `Refresh` control and bounded inspect/drill-down controls only when work is active, blocked, stale-sensitive, or otherwise actionable. A completed static status needs no buttons. Do not add destructive shortcuts or actions whose target and consequence are not yet clear.
62
68
 
@@ -130,11 +136,11 @@ Use the transport's canonical prompt-button syntax. For pi-telegram, one top-lev
130
136
 
131
137
  Model the control surface as an ordered ragged sequence of independently sized rows, not as a rectangular matrix to fill. Rectangular grids are one specialization for genuinely spatial or coordinate-bearing state; most interfaces should vary row width according to hierarchy, grouping, label pressure, and action priority.
132
138
 
133
- - Put controls in one compact row only when they are genuine peers that answer the same local question or form one coherent toolbar/navigation group.
134
- - Use a singleton full-width row for a structurally independent, pinned, primary, summary, or high-consequence action when separation improves comprehension.
139
+ - Default to one full-width button per row for non-spatial controls. Put controls in one compact row only when they are genuine peers that answer the same local question or form one coherent toolbar/navigation group **and** their rendered labels comfortably fit a narrow phone-width chat.
140
+ - Use a singleton full-width row for a structurally independent, pinned, primary, summary, or high-consequence action, and whenever label length makes horizontal grouping cramped or ambiguous.
135
141
  - Vary row widths intentionally—for example `1 → 2 → 4 → 1 → 2`—and never pad a row with empty, duplicate, or no-op controls merely to produce uniform dimensions.
136
142
  - Preserve reading order across rows: orientation and structural navigation first, primary content or choices next, secondary controls afterward, and destructive actions visibly separated when present.
137
- - Use at most two columns when buttons carry words, phrases, icon-plus-text labels, or other text that must be read; move additional peer choices into more semantic rows rather than compressing four or more textual buttons across a phone-width line. Three through five columns are for short symbols, glyphs, coordinates, or compact codes whose position carries meaning. Six through eight may be used only for single-glyph or similarly minimal position-bearing labels whose grouping materially improves the interaction. Eight is the phone-width UX maximum: never generate a row of nine or more controls even though the parser has no artificial width cap. Never shorten necessary wording merely to increase row density; regroup or use full-width rows when labels need explanation, wrap ambiguously, or lose meaning without prose.
143
+ - Treat two columns as an earned compact mode, not the default: a pair normally fits when each label is no more than one emoji plus roughly two average-length words. If either label has more words, unusually long words, qualifiers, or likely wrapping, place each button on its own row. Use at most two columns for readable text labels; move additional peer choices into more semantic rows rather than compressing textual buttons across a phone-width line. Three through five columns are for short symbols, glyphs, coordinates, or compact codes whose position carries meaning. Six through eight may be used only for single-glyph or similarly minimal position-bearing labels whose grouping materially improves the interaction; a row of emoji-only controls may therefore legitimately use up to eight columns. Eight is the phone-width UX maximum: never generate a row of nine or more controls even though the parser has no artificial width cap. Never shorten necessary wording merely to increase row density; regroup or use full-width rows when labels need explanation, wrap ambiguously, or lose meaning without prose.
138
144
 
139
145
  Treat vertical extent independently from horizontal density. A genuinely spatial surface may retain many rows—such as an `8×16` field—when vertical continuity, coordinates, and one-glance topology matter; do not paginate merely to make its height match its width. For non-spatial collections, however, a tall button wall should yield to semantic grouping, progressive disclosure, or pagination. Keep compact state and instructions above a tall surface, preserve stable coordinates across regeneration, and avoid repeating prose between rows.
140
146
 
@@ -181,7 +187,7 @@ Button prompts must:
181
187
  - Request fresh inspection when state may have changed.
182
188
  - Avoid embedding volatile output that should be rediscovered.
183
189
 
184
- Labels stay short, distinct, and scannable. Emoji are optional semantic markers; do not rely on color alone. If buttons are unavailable, render the same control surface as a numbered choice list.
190
+ Labels stay short, distinct, and scannable. Prefer an explicit `label` over exposing a long prompt as button text. Emoji are explicitly allowed and encouraged when one consistent semantic marker improves scanning or expressiveness; keep their meaning consistent across sibling controls, avoid decorative noise, and do not rely on emoji or color alone. If buttons are unavailable, render the same control surface as a numbered choice list.
185
191
 
186
192
  ## Capability Adapters
187
193
 
@@ -230,6 +236,8 @@ Buttons may represent explicit alternatives without live system inspection. Stat
230
236
 
231
237
  Before sending a surface, verify:
232
238
 
239
+ - If the reply asks a Telegram user for bounded confirmation or selection, qualifying controls are present; do not ship a prose-only blocking question merely because the answer is short.
240
+ - If the completed act unblocks a high-confidence next intent inferred from the parent goal and workflow trajectory, qualifying handoff controls are present even when no explicit pending question exists and the latest user message was itself a complete command.
233
241
  - State and controls share one clear owner and target.
234
242
  - Live claims come from current evidence.
235
243
  - Complete versus filtered or adapted output is labeled honestly.