@llblab/pi-kit 0.1.5 → 0.1.7

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 (60) hide show
  1. package/CHANGELOG.md +9 -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 +17 -0
  49. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  50. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
  51. package/node_modules/@llblab/pi-telegram/index.ts +25 -0
  52. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +11 -0
  53. package/node_modules/@llblab/pi-telegram/lib/journal.ts +192 -9
  54. package/node_modules/@llblab/pi-telegram/lib/queue.ts +14 -0
  55. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +52 -2
  56. package/node_modules/@llblab/pi-telegram/lib/status.ts +3 -3
  57. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +5 -1
  58. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  59. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +14 -6
  60. 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
 
@@ -198,16 +198,16 @@ All inbound updates are gated by the configured authorized user id.
198
198
  5. Coalesce media groups, likely split long text, and one adjacent forward-plus-comment pair in either order when needed.
199
199
  6. Download files with size limits and partial-download cleanup, then run configured/programmatic inbound handlers.
200
200
  7. Build a prompt or control queue item carrying an exact durable receipt for every contributing update id.
201
- 8. Remove journal authority only after prompt handoff, control settlement, or durable follower acknowledgement; execution failures remain durable until automatic replay succeeds.
201
+ 8. Remove local prompt journal authority synchronously immediately before `sendUserMessage`, so session/process replacement can lose an unstarted prompt at that narrow crash boundary but can never replay a prompt already admitted to Pi; controls and foreign forwarding retain their explicit settlement boundaries.
202
202
  9. Handle `edited_message` updates separately while the original turn is still queued and dispatch only when all safety gates are clear.
203
203
 
204
204
  #### Durable Admission And Recovery
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
 
@@ -286,7 +286,7 @@ Dispatch requires:
286
286
 
287
287
  A dispatched prompt remains queued until `agent_start` consumes it. This keeps the active Telegram turn bound for previews, attachments, aborts, and final replies. A low-level `agent_end` error also retains that active turn because Pi may retry automatically; a later successful `agent_end` delivers through the original target and metadata, while `agent_settled` proves that an unrecovered error can be finalized once before queue dispatch resumes.
288
288
 
289
- Post-agent-end queue dispatch uses a session-bound deferred dispatcher. It is activated on session start, clears timers on shutdown, and skips callbacks from older generations before touching `ExtensionContext`. Dispatch stays session-bound after polling ownership moves elsewhere. When a queued Telegram prompt is forwarded into Pi, it uses a normal `sendUserMessage(content)` turn after the bridge's idle/dispatch guards pass; it does not use Pi's `followUp` delivery option or inject terminal input.
289
+ Post-agent-end queue dispatch uses a session-bound deferred dispatcher. It is activated on session start, clears timers on shutdown, and skips callbacks from older generations before touching `ExtensionContext`. Dispatch stays session-bound after polling ownership moves elsewhere. When a queued Telegram prompt is forwarded into Pi, the bridge synchronously commits its exact durable receipt before calling normal `sendUserMessage(content)`; failed receipt commitment blocks dispatch, while the unavoidable crash window between commitment and Pi admission favors at-most-once execution over replay. It does not use Pi's `followUp` delivery option or inject terminal input.
290
290
 
291
291
  One monotonic session generation also fences agent/tool/message events, compaction callbacks, preview state, scheduled final delivery, controls, and shutdown. Distinct Pi context objects observed within one session adopt that generation; contexts already observed under an older generation remain stale after replacement. Session start invalidates pending preview work, delayed finals check their captured context before delivery, and shutdown rechecks after asynchronous polling/preview boundaries with a bounded preview-clear wait.
292
292
 
@@ -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({
@@ -68,6 +68,10 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
68
68
  ctx: TContext,
69
69
  ) => void;
70
70
  isItemReady: (item: Queue.TelegramQueueItem<TContext>) => boolean;
71
+ onPromptHandedOff?: (
72
+ item: Queue.PendingTelegramTurn,
73
+ ctx: TContext,
74
+ ) => void;
71
75
  onControlSettled: (
72
76
  item: Queue.PendingTelegramControlItem<TContext>,
73
77
  ctx: TContext,
@@ -122,6 +126,13 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
122
126
  (item.admissionReceipts?.length ?? 0) === 0
123
127
  );
124
128
  },
129
+ commitPromptDispatch(item, ctx) {
130
+ if ((item.admissionReceipts?.length ?? 0) === 0) return true;
131
+ const settlement = deps.admission.getSettlement();
132
+ if (!settlement?.onPromptHandedOff) return false;
133
+ settlement.onPromptHandedOff(item, ctx);
134
+ return !settlement.isItemReady(item);
135
+ },
125
136
  onControlSettled(item, ctx) {
126
137
  deps.admission.getSettlement()?.onControlSettled(item, ctx);
127
138
  },
@@ -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);
@@ -2836,6 +2836,9 @@ export interface TelegramDispatchRuntimeDeps<TContext = unknown> {
2836
2836
  >["item"],
2837
2837
  ) => void;
2838
2838
  onPromptDispatchStart: (chatId: number) => void;
2839
+ commitPromptDispatch?: (
2840
+ item: Extract<TelegramQueueDispatchAction<TContext>, { kind: "prompt" }>["item"],
2841
+ ) => boolean;
2839
2842
  sendUserMessage: (
2840
2843
  content: Extract<
2841
2844
  TelegramQueueDispatchAction,
@@ -2859,6 +2862,10 @@ export interface TelegramQueueDispatchControllerDeps<
2859
2862
  updateStatus: (ctx: TContext, error?: string) => void;
2860
2863
  sendTextReply: TelegramControlRuntimeDeps<TContext>["sendTextReply"];
2861
2864
  onPromptDispatchStart: (ctx: TContext, chatId: number) => void;
2865
+ commitPromptDispatch?: (
2866
+ item: Extract<TelegramQueueDispatchAction<TContext>, { kind: "prompt" }>["item"],
2867
+ ctx: TContext,
2868
+ ) => boolean;
2862
2869
  sendUserMessage: TelegramDispatchRuntimeDeps<TContext>["sendUserMessage"];
2863
2870
  onPromptDispatchFailure: (ctx: TContext, message: string) => void;
2864
2871
  isQueueItemTransportActive?: (item: TelegramQueueItem<TContext>) => boolean;
@@ -2890,6 +2897,9 @@ export function executeTelegramQueueDispatchPlan<TContext = unknown>(
2890
2897
  }
2891
2898
  deps.onPromptDispatchStart(plan.item.chatId);
2892
2899
  try {
2900
+ if (deps.commitPromptDispatch && !deps.commitPromptDispatch(plan.item)) {
2901
+ throw new Error("Telegram prompt dispatch could not be committed durably.");
2902
+ }
2893
2903
  deps.sendUserMessage(plan.item.content);
2894
2904
  } catch (error) {
2895
2905
  const message = getTelegramQueueErrorMessage(error);
@@ -2922,6 +2932,7 @@ export function createTelegramQueueDispatchRuntime<TContext = unknown>(
2922
2932
  updateStatus: deps.updateStatus,
2923
2933
  sendTextReply: deps.sendTextReply,
2924
2934
  onPromptDispatchStart: deps.onPromptDispatchStart,
2935
+ commitPromptDispatch: deps.commitPromptDispatch,
2925
2936
  sendUserMessage: deps.sendUserMessage,
2926
2937
  onPromptDispatchFailure: deps.onPromptDispatchFailure,
2927
2938
  isQueueItemTransportActive: deps.isQueueItemTransportActive,
@@ -3061,6 +3072,9 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3061
3072
  onPromptDispatchStart: (chatId) => {
3062
3073
  deps.onPromptDispatchStart(ctx, chatId);
3063
3074
  },
3075
+ commitPromptDispatch: deps.commitPromptDispatch
3076
+ ? (item) => deps.commitPromptDispatch!(item, ctx)
3077
+ : undefined,
3064
3078
  sendUserMessage: deps.sendUserMessage,
3065
3079
  onPromptDispatchFailure: (message) => {
3066
3080
  deps.onPromptDispatchFailure(ctx, message);
@@ -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);