@llblab/pi-kit 0.36.1 → 0.36.2

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 (70) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +4 -0
  4. package/node_modules/@llblab/pi-claude-usage/README.md +2 -2
  5. package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +2 -3
  6. package/node_modules/@llblab/pi-claude-usage/package.json +1 -1
  7. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +5 -0
  8. package/node_modules/@llblab/pi-codex-usage/README.md +1 -1
  9. package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +2 -3
  10. package/node_modules/@llblab/pi-codex-usage/package.json +1 -1
  11. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  12. package/node_modules/@llblab/pi-telegram/BACKLOG.md +17 -5
  13. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +23 -0
  14. package/node_modules/@llblab/pi-telegram/README.md +4 -2
  15. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +17 -1
  16. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +12 -2
  17. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +17 -6
  18. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +1 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +1 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +42 -10
  21. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +1 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.js +9 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +3 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -2
  26. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +6 -3
  27. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +50 -9
  28. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +119 -14
  29. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -0
  30. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +9 -0
  31. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +39 -3
  32. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +144 -16
  33. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +1 -1
  34. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +15 -8
  35. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +14 -1
  36. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +122 -8
  37. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +15 -1
  38. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +22 -6
  39. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +4 -3
  40. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +3 -3
  41. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +1 -0
  42. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +52 -2
  43. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +17 -0
  44. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +25 -0
  45. package/node_modules/@llblab/pi-telegram/dist/package.json +2 -1
  46. package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
  47. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +9 -3
  48. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -0
  49. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  50. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +24 -1
  51. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +16 -0
  52. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -5
  53. package/node_modules/@llblab/pi-telegram/lib/bus.ts +3 -0
  54. package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -11
  55. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +12 -0
  56. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +2 -0
  57. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +3 -2
  58. package/node_modules/@llblab/pi-telegram/lib/pi.ts +2 -0
  59. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +79 -16
  60. package/node_modules/@llblab/pi-telegram/lib/routing.ts +137 -24
  61. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +14 -0
  62. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +173 -17
  63. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +14 -8
  64. package/node_modules/@llblab/pi-telegram/lib/threads.ts +166 -10
  65. package/node_modules/@llblab/pi-telegram/lib/updates.ts +33 -6
  66. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +8 -7
  67. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +56 -2
  68. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +44 -0
  69. package/node_modules/@llblab/pi-telegram/package.json +2 -1
  70. package/package.json +4 -4
@@ -222,10 +222,10 @@ Production retirement requires a durable profile-scoped reader/writer ledger own
222
222
  - The unreachable legacy `directories` projector remains only for decoding old internal snapshots; effective configuration falls back to Letters. The `directory-snake` and `directory-title` modes derive suffix visibility exclusively from the leader's current authenticated live-owner snapshot: one live binding for a normalized exact cwd has no suffix; two or more live bindings for that cwd all expose their globally assigned slots; dormant retained bindings never affect the count. `showSlotSuffix` remains readable for legacy presentation but is ignored by both new projectors and is never written merely because live concurrency changed. The leader captures exact profile, epoch, binding target, and follower registration generation before projection; a provisioning candidate counts only after its authenticated exact owner is admitted to the same serialized Workspace mutation, while an owner losing authority is excluded before reconciliation. Registration/provision completion, authenticated disconnect or confirmed-dead prune, target replacement, and promotion schedule one serialized reconciliation of every affected live same-cwd binding. A failed or partial Telegram rename retains acknowledged per-binding progress and retries only under a fresh live snapshot; dormant tabs are not renamed until they regain authenticated ownership. Leader startup treats its authenticated follower roster as unsettled for one follower-staleness window: automatic display reconciliations requested by election or incremental re-registration are deferred and coalesced until that boundary. A same-cwd follower returning within the window therefore preserves the already acknowledged suffix without an `unsuffixed → suffixed` Telegram rename; if it stays absent, the settled reconcile removes the suffix once. Explicit mode changes remain immediate, and leader stop/restart invalidates the pending timer. Equal basenames from different paths still require deterministic parent-path qualification independently from same-cwd suffixing. Preserve the existing `threadName` as generated/recovery identity while automatic modes are selected. New manual names live only in `manualThreadName`; do not guess manual provenance from a legacy name or palette membership. Telegram `/name Name` changes the manual override and displayed title only for its exact originating target. Leader and follower requests carry that target through final generation/binding checks, so replacement cannot redirect a stale dialog mutation. Reset uses the same negotiated `workspace-thread-rename-v1` capability and exact follower generation; the leader computes the current automatic projection from the latest live snapshot, edits the exact target, clears only `manualThreadName`, and persists before acknowledging. Follower metadata refresh preserves an acknowledged display title only while target and registration generation stay unchanged. Named-profile setup preserves the latest saved automatic preference even if another instance changes it while the token form is open.
223
223
  - Fresh provisioning projects the candidate together with retained bindings and sends the active mode's title in `createForumTopic`. The exact targeted provision retains creation-title evidence until the Workspace commit publishes the binding and consumes that evidence together. Recovery preserves it even when a starting record already exists; an untargeted or unknown creation never authorizes a title commit. Proven deletion removes exact-target pending creation evidence, including when no current record was committed. Older contradictory pending/deleted snapshots settle that evidence durably before replacement; closed targets and pending cleanup block recovery until reconciliation, rather than becoming active again. The same exact-target check protects follower reconnect/carried-target shortcuts and the final Workspace commit, before creation evidence can be consumed. A matching carried pending target resumes through the provisioner that owns its reserved slot and acknowledged title instead of allocating that slot again. The shared provision-commit helper first commits the claim, then applies the acknowledged title with exact-binding comparison, preserving generic stale-title rejection on target replacement. Switching display mode changes projection only: preserve Thread ID, binding identity, slot, queue ownership, and routing. `displayTitle` records a successful Telegram edit independently of `threadName`, survives same-target registration updates, and is cleared on target replacement. The title reconciler captures profile, mode, leader epoch, and exact live-binding authority before each edit, rechecks after ACK and persistence, and skips dormant bindings. Failed persistence retains acknowledged dirty metadata for a later persist without repeating that API edit; a late or unknown ACK never commits a title to a replacement binding. Keep the stable palette/manual name separate from the current display title so switching back does not generate a different name. The leader owns Telegram title edits and acknowledged follower/TUI convergence, with generation/profile fencing and truthful partial-failure recovery. Successful registration ACKs optionally carry the acknowledged `displayTitle` with the exact target and registration generation, making it available before the initial status refresh. Heartbeats carry later title changes; stale generations cannot update display state. Connected notices use acknowledged titles while runtime `threadName` remains the stable restoration identity. Live bot chooser/notice labels, prompt attribution, and cross-instance agent-target name selection use the same acknowledged projection, but candidate liveness and the captured numeric `{chatId, threadId}` remain authoritative. Ambiguous projected names fail closed. Older peers can ignore the optional field, and no separate polling connection or follower snapshot-read loop is needed; do not expose a setting control that merely stores a preference without updating its promised surfaces.
224
224
  - Reopening a retained inactive binding restores its slot and name without taking leadership. Explicit connection from the same directory may allocate a second binding. Startup restore remains restore-only: it must not evict another Workspace or allocate a fresh binding merely because all remembered bindings are owned. Explicit connection already skips a live peer's migrated binding instead of attempting to adopt it; that admission rule is independent of the display redesign.
225
- - Prefer free letters in deterministic order. Only under full slot exhaustion may retirement choose the eligible binding with the oldest proven inactivity start, not necessarily slot `a` after `z`. Time alone never retires a binding. If every slot is protected or its ownership is unverifiable, reject new allocation with a truthful capacity explanation; never evict a live owner or silently expand into `aa`.
225
+ - Prefer free letters in deterministic order. Only under full slot exhaustion may retirement choose the eligible binding with the oldest proven inactivity start, not necessarily slot `a` after `z`. Time alone never retires a binding. If every slot is protected or its ownership is unverifiable, retirement is impossible and allocation falls back to the metadata-only letter reuse described under Demand-Driven Slot Rotation instead of refusing the connection; never delete a live owner's Thread or silently expand into `aa`.
226
226
  - Inactivity begins at a verified transition to no live owner; an idle but connected process is active. Exact confirmed Thread deletion also proves that the binding has no active Telegram target: the common Thread store records first inactivity with invalidation, even if an earlier stale observation already removed the live record. Fenced invalidation publishes this metadata only at its durable commit; closure or an unknown API outcome alone supplies no inactivity. With automatic cleanup disabled, confirmed-dead follower pruning instead acquires profile admission and commits non-destructive owner detachment. It requires one exact owner record and one matching Workspace binding with the same letter; final publication rechecks the registered positive PID's absence, prune generation, leader epoch, profile and replacement registrations. It retains the tab, Workspace identity, slot and journals without claiming that Telegram deleted the Thread. For graceful leader quit with cleanup disabled, the [lifecycle boundary](./architecture.md#runtime-ownership) supplies completed quiescence and exact session/epoch/profile authority for the same atomic detachment; reload/new/resume/fork never enter it. A failed pre-commit publication retains the owner record; a lost post-rename acknowledgement leaves the committed fact intact. No-op snapshot equality cannot bypass the fenced transition into memory. Missing/ambiguous legacy identity remains protected. Live-owner, accepted-work and delivery protection still apply independently before retirement. Do not derive inactivity from last user message, heartbeat silence alone, snapshot-write time, file age, or any elapsed-time threshold. Legacy records without sufficient evidence have no `inactiveSinceMs` and remain ineligible rather than inheriting an invented old timestamp. Malformed inactivity values are ignored without dropping the binding. Repeated inactive observations retain the first timestamp for pressure ordering; stale same-target upserts cannot postpone it, while target replacement or successful active provisioning clears it.
227
227
  - Eligibility excludes live or unverifiable owners, exact transient claims, accepted pending/active work, unresolved delivery authority, and pending provisioning, handoff, or cleanup operations. The store can capture one candidate snapshot from bindings, live records, process-local exact claims, reservations, and persisted provision/cleanup intents, but requires the caller to supply separate tri-state evidence for external live ownership, accepted work, and delivery authority. Only three explicit `clear` results plus valid continuous inactivity produce `eligible`; any protected or unknown dimension fails closed. Before future deletion, `workspace-retirement` selects the oldest eligible pressure victim only after all slots are occupied or reserved. It captures the profile and leader epoch, rechecks protection, then persists one durable Workspace retirement intent containing the complete expected binding, pressure reason, leader epoch, and request time. The intent itself protects that binding from competing selection; only the exact captured intent may be excluded during a fenced recheck. Duplicate intent ids, bindings, targets, or letters fail closed. Each new Workspace binding durably accumulates the registration/profile keys used to route its historical follower journals and marks that set complete. An existing legacy binding may learn current keys but cannot claim its unknown historical set is complete without separate discovery proof. Accepted-work policy treats an exact local queued/active target or any unsettled entry in a binding-specific follower journal as protected. Shared leader-journal entries protect only a decoded exact target; undecodable relevant entries, unreadable sources, and incomplete source enumeration remain unknown. The read-only evidence capture resolves the shared leader journal and every recorded follower key; missing resolvers, read errors, or an incomplete legacy key set make coverage unknown. A complete readable capture may prune a known empty follower-journal key only when a separate process/writer check is explicitly clear and the exact binding, profile, and leader epoch remain current; nonempty, unreadable, writable, or unknown keys remain. For incomplete legacy metadata, `journal` can discover canonical profile-exact follower snapshot and segment roots. Matching paths with unexpected/unreadable shape make discovery incomplete; successfully read discovered journals are target-scoped evidence because their one-way filename hash cannot recover the historical routing key. Legacy completeness remains unset, so later retirement repeats discovery rather than forgetting that historical domain. Live composition includes decoded process-birth proof, registry liveness, queue/journal state, and delivery-authority discovery.
228
- - Reclamation deletes the exact inactive Telegram Thread and therefore removes its history; this is distinct from dropping a local label. The leader owns a durable, exact-binding-and-epoch-fenced retirement intent. The store validates and persists this contract, and preparation resumes an exact current-epoch intent after persistence failure or reload. The isolated executor requires both the caller-owned exclusive gate and durable admission ledger. It acquires or exactly adopts the matching fence, rechecks protection after admissions close, persists `deletion-issued`, and invokes an executor-only `deleteForumTopic` port with the sole non-retried permit. Unknown results keep the binding, intent, slot, and issued fence; retries and successors cannot issue again and require exact already-absence proof. Exact parsed Telegram rejection instead follows the cancellation protocol below, retaining the binding and letter. Success or confirmed absence advances to `commit-ready`, then a store-owned commit hides the free slot while persistence is in flight, restores in-memory authority on known failure, and removes binding+intent before exact fence completion. One failure-resilient Workspace operation runtime owns the shared gate for topic lifecycle, reroute restore/reclaim, leader/follower provisioning, delayed post-provision reconciliation, disconnect and confirmed-dead cleanup, rename, and display reconciliation; registration cannot enter the live registry until its gated provisioning completes. Timer-owned mutation never inherits expired registration authority: it reacquires profile admission before cleanup and remains blocked without store/API effects behind either retained fence phase. The executor consumes that runtime's exposed gate rather than nesting another lock. A successor leader cannot execute the old epoch directly. It may durably adopt exactly one intent into its current epoch only when the profile, complete binding snapshot, and fresh protection recheck still match; known persistence failure restores the old intent and request time. Leader composition now exposes external protection from exact live registry targets, active/queued local targets, shared and historical follower journals, and profile-exact discovery for incomplete legacy history. An accepted item without an exact target for the candidate chat remains unknown. Exact JSON and multipart Bot API operations are counted at the shared direct-client boundary through final settlement; a message-scoped edit/delete without thread identity conservatively protects all bindings in its chat. Historical follower owner keys are decoded before process-birth liveness checks. Exact durable intents block matching claims, activation, title/journal commits, and binding upserts, preventing normal mutation from making their snapshots stale. Closed-but-existing topics do not count as confirmed absence. The former process-local TOCTOU is closed in the isolated ledger/executor and production journal/API/mutation/allocation composition, including delayed post-provision cleanup. Fresh leader/follower allocation now retries once after a confirmed pressure retirement. Allocation requests serialize before taking ordinary leases; failure releases those leases before retirement enters the shared mutation gate and acquires its destructive fence. Restore-only follower startup never enters this path. Protected or unverifiable capacity still fails closed. Reuse changes only the letter assignment: routing remains exact numeric target authority, so traffic for the retired target cannot reach the replacement. Recheck eligibility before Telegram mutation and serialize against re-registration. Release the letter only after confirmed deletion or exact already-absent evidence and a successful durable retirement commit. The current confirmed cleanup path records inactivity only after its exact deletion/already-absent result; an unconfirmed API outcome records no inactivity. Unknown API outcomes, failed persistence, owner replacement, and interrupted cleanup retain the reservation and recovery evidence, preventing reassignment or deletion of a replacement target.
228
+ - Reclamation deletes the exact inactive Telegram Thread and therefore removes its history; this is distinct from dropping a local label. The leader owns a durable, exact-binding-and-epoch-fenced retirement intent. The store validates and persists this contract, and preparation resumes an exact current-epoch intent after persistence failure or reload. The isolated executor requires both the caller-owned exclusive gate and durable admission ledger. It acquires or exactly adopts the matching fence, rechecks protection after admissions close, persists `deletion-issued`, and invokes an executor-only `deleteForumTopic` port with the sole non-retried permit. Unknown results keep the binding, intent, slot, and issued fence; retries and successors cannot issue again and require exact already-absence proof. Exact parsed Telegram rejection instead follows the cancellation protocol below, retaining the binding and letter. Success or confirmed absence advances to `commit-ready`, then a store-owned commit hides the free slot while persistence is in flight, restores in-memory authority on known failure, and removes binding+intent before exact fence completion. One failure-resilient Workspace operation runtime owns the shared gate for topic lifecycle, reroute restore/reclaim, leader/follower provisioning, delayed post-provision reconciliation, disconnect and confirmed-dead cleanup, rename, and display reconciliation; registration cannot enter the live registry until its gated provisioning completes. Timer-owned mutation never inherits expired registration authority: it reacquires profile admission before cleanup and remains blocked without store/API effects behind either retained fence phase. The executor consumes that runtime's exposed gate rather than nesting another lock. A successor leader cannot execute the old epoch directly. It may durably adopt exactly one intent into its current epoch only when the profile, complete binding snapshot, and fresh protection recheck still match; known persistence failure restores the old intent and request time. Leader composition now exposes external protection from exact live registry targets, active/queued local targets, shared and historical follower journals, and profile-exact discovery for incomplete legacy history. An accepted item without an exact target for the candidate chat remains unknown. Exact JSON and multipart Bot API operations are counted at the shared direct-client boundary through final settlement; a message-scoped edit/delete without thread identity conservatively protects all bindings in its chat. Historical follower owner keys are decoded before process-birth liveness checks. Exact durable intents block matching claims, activation, title/journal commits, and binding upserts, preventing normal mutation from making their snapshots stale. Closed-but-existing topics do not count as confirmed absence. The former process-local TOCTOU is closed in the isolated ledger/executor and production journal/API/mutation/allocation composition, including delayed post-provision cleanup. Fresh leader/follower allocation now retries once after a confirmed pressure retirement. Allocation requests serialize before taking ordinary leases; failure releases those leases before retirement enters the shared mutation gate and acquires its destructive fence. Restore-only follower startup never enters this path. Protected or unverifiable capacity deletes nothing and falls back to metadata-only letter reuse. Reuse changes only the letter assignment: routing remains exact numeric target authority, so traffic for the retired target cannot reach the replacement. Recheck eligibility before Telegram mutation and serialize against re-registration. Release the letter only after confirmed deletion or exact already-absent evidence and a successful durable retirement commit. The current confirmed cleanup path records inactivity only after its exact deletion/already-absent result; an unconfirmed API outcome records no inactivity. Unknown API outcomes, failed persistence, owner replacement, and interrupted cleanup retain the reservation and recovery evidence, preventing reassignment or deletion of a replacement target.
229
229
  - A retired binding no longer promises its former name/letter on reopen; the Workspace may be provisioned anew. Retained message ownership and journal evidence must never route old work through a recycled letter: full binding generation and target remain mandatory independently of display labels.
230
230
  - Implement and validate using isolated stores and mocked Telegram APIs. Do not delete live Threads, migrate live journals/locks manually, publish, or restart operator instances as part of local preparation. Operator smoke on disposable Threads is a separate gate.
231
231
 
@@ -235,6 +235,12 @@ A fresh leader allocation or authenticated explicit follower registration first
235
235
 
236
236
  The executor validates exact bot/profile, leader epoch, fence owner, binding, target, slot, operation and issuance time immediately before `deleteForumTopic`. This executor-only transport uses the existing client without ordinary target admission, which its own fence intentionally blocks; both API retries and network-family fallback are disabled. Only `true` or a definite HTTP 400 absent-Thread response confirms deletion. A parsed Telegram `ok: false` response with matching HTTP/error codes in `400 | 401 | 403 | 404 | 429`, the exact `deleteForumTopic` method and target, and no confirmed-absence evidence instead proves rejection. The executor durably records `deletion-rejected`, withdraws only the matching retirement intent, persists that withdrawal while retaining the binding, then releases the exact fence. Reconstruction finishes cancellation even after intent removal or lost publication acknowledgements; it does not require deletion eligibility or issue another delete. Every fresh attempt uses a new operation identity, even within the same clock tick. A runtime that does not understand the retained rejection phase cannot recover it; use a supporting build, not manual ledger edits. Transport failures, HTTP 5xx, malformed/mismatched responses and forged status fields remain unknown: they retain the binding and fence without reissuing or assigning the letter. No background timer or elapsed-age threshold performs rotation.
237
237
 
238
+ #### Metadata-Only Letter Reuse
239
+
240
+ When pressure retirement finds no eligible binding (every letter protected or unverifiable), the same wrapper reassigns the least recently used letter rather than refusing the connection. Under the shared gate it reloads the store and, if still no letter is free, removes the `slot` from one binding and from its current Thread record, persists once, and retries the claim, which then takes the freed letter. The victim is the binding with the oldest last use: detached bindings by their first-inactivity time, live owners counted as most recent and chosen only when nothing detached remains, ties by letter. The metadata reassignment keeps the Thread, binding, journals, accepted work and generated name; it performs no deletion. Before retrying allocation, the leader awaits one display-only reconciliation of the displaced binding under fresh profile admission. A slotless binding displays its retained generated name in every automatic mode, or keeps its manual override. Routing remains the exact numeric target plus binding key, so old work can never reach the replacement through the recycled letter.
241
+
242
+ A binding or letter held by a claim, reservation, pending provision, cleanup, retirement intent or an external admission reservation is skipped, as is a letter that a different current Thread record also holds. An unreadable external reservation set, lost leader authority or refused publication restores the previous assignment and reports capacity unavailable. The displaced binding is slotless like a legacy binding: it holds no letter, is not a capacity-retirement candidate, and regains one only through a later allocation-authorized connection. Displaced bindings and their Threads therefore accumulate; nothing deletes them automatically. On the leader, Settings → Thread cleanup → Review inactive tabs can prepare exact inactive slotless bindings with complete clear protection. Back leaves them untouched; the separate Clean action confirms deletion of the reviewed Telegram tabs and their messages. The shared gate reloads and revalidates each exact candidate before and under its manual cleanup fence; changed/protected candidates are skipped, and an unknown issued deletion never repeats. The manual-cleanup path retains explicit `slot: null` through its work-set, fence, permit and exact binding commit; it never uses the letter now held by another binding. Older readers reject these new null-bearing records rather than inferring cleanup permission. Pressure retirement still requires a real letter. Restore-only startup and background heartbeat recovery never reuse a letter, which prevents eviction churn. A rejected or unknown title edit retains the previous acknowledged title and records a diagnostic; it cannot undo or repeat the published reassignment, block the fresh connection, or authorize deletion. Ordinary display reconciliation also includes slotless bindings without live owners, so startup or a later display trigger can repair the title after failure or a crash between reassignment and editing. Title edits retain exact binding/profile/mode/epoch fences and require a positive acknowledgement; this display authority never creates live routing ownership.
243
+
238
244
  Protection reads use the binding's strict non-repairing journal inspector. Corruption, unsupported platforms/schemas, incomplete historical coverage and unresolved named-profile paths remain unknown, never empty-work evidence. Legacy inactivity timestamps are not fabricated. If full pressure is blocked only by queued work, rotation may inspect candidates oldest-first and invoke the journal's existing exact dead-owner recovery—never raw file or queue clearing. The current binding, leader/profile authority, local queue, live owner and delivery authority must all remain clear; source enumeration must be complete; each relevant entry must form a whole unoffered receipt group for the same target and owner; and every grouped PID plus process birth must be freshly proven dead before the first mutation. The journal rechecks the exact receipt, owner/acquisition, source IDs, handoff absence and liveness transactionally. Any live/unverifiable owner, partial/mixed group, mutation refusal, replacement or unknown evidence stops that candidate. A committed prefix may remain discarded after interruption, but it grants no deletion authority: rotation performs a fresh complete protection capture and ordinary retirement eligibility check before preparing the destructive fence. Unrelated journal work is untouched, no payload is replayed, and restore-only startup never enters this path. Deletion removes **the Telegram Thread and all its messages** as documented by [`deleteForumTopic`](../.agents/skills/telegram-bot/api.md#deleteforumtopic); retirement removes that exact local Workspace binding, not Pi session files, project files, State Flow memory, or durable update journals. Reopening a retired session may create a fresh Thread rather than restoring its deleted history.
239
245
 
240
246
  ### Ambiguous Retirement Recovery Evidence
@@ -667,7 +673,7 @@ This inspection authorizes no reload/restart, live-state mutation, fault injecti
667
673
  ## Security Boundaries
668
674
 
669
675
  - Messages, edits, callbacks, and reactions check user authorization, not only chat/thread membership.
670
- - Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary.
676
+ - Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary. A reply refused because it targets a Thread other than the follower's own surfaces as the typed `TelegramApiFollowerNotAllowedError` carrying only the requested target. A journaled recipient input whose Thread was since re-created re-runs exactly once against the follower's current Thread (reply anchor cleared, diagnostic `recipient-target-recovery`); a second refusal or any other failure keeps the ordinary durable `retry-wait`.
671
677
  - Follower Bot API proxying is allowlisted and target-scoped where applicable. Ordinary sends remain confined to the follower's assigned thread; trusted runtime-marked `telegram_message` cross-target sends may address only a different thread inside the same paired chat, and the leader strips the internal marker before calling Telegram. This preserves requested inter-thread delivery without granting arbitrary bot or cross-chat control.
672
678
  - Button and section callbacks verify authorized `from.id` and owning target/instance.
673
679
  - Generated artifacts stay scoped to the owning thread after leader failover.
@@ -125,6 +125,8 @@ The file is global across Pi instances and contains configuration only. The per-
125
125
 
126
126
  Threaded Mode Settings exposes **Thread display** as Letters (default), Names, `directory-snake`, or `directory-title`; retained `directories` is unsupported, resolves to Letters, and is never rewritten automatically. `threads.displayMode` is shared by every profile; absent and invalid values resolve to `letters`. The retired `profiles.<name>.threadDisplayMode` key is ignored, not migrated. Another profile's already-running leader applies a shared change at its next startup or display reconciliation. Settings previews the current binding set through the same projector used for fresh titles and reconciliation. A manual `/name` override appears as `custom`; choosing any automatic Thread display option clears that override for the current Thread and immediately reapplies the selected projection. Names projects the generated dictionary name for the slot; the two directory modes tokenize Unicode path segments deterministically and render snake case or humanized title case, with `_a` or ` A` slot suffixes when required. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1`, the additional `directory-display-format-v1` capability for either new directory mode, and its exact registration generation. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
127
127
 
128
+ Manual cleanup is independent of the graceful-quit toggle. On the leader instance, Settings → Thread cleanup → Review inactive tabs prepares only exact inactive bindings with complete clear protection, including bindings without a letter. Review and Back delete nothing. The separate Clean action confirms deletion of those eligible Telegram tabs and all their messages, not Pi session history or project files. Confirmation reloads and revalidates the exact review under a one-shot manual fence. Changed or protected bindings are skipped; an issued unknown deletion is never retried automatically, and unfinished local commit retains recovery protection. Follower instances do not receive a remote cleanup grant from these controls.
129
+
128
130
  Hidden/default semantics are represented by absence:
129
131
 
130
132
  - `threads.automaticCleanup` defaults to `true`; graceful Pi quit deletes the instance's bound Threaded Mode tab without prompting but preserves the owner slot as independent restart intent. Set it to `false`, or use `🧹 Thread cleanup` in Telegram Settings, to preserve the tab too. A confirmed `/telegram-disconnect`, unlike quit, clears restart ownership. Settings views and cleanup reload shared config before evaluating this switch, so another live Pi instance's update takes effect without restarting. Confirmed leader/follower teardown persists an exact target/runtime-generation cleanup intent before Telegram deletion; an interrupted attempt remains retryable by the current or successor leader under current authority and clears only after confirmed deletion. A same-profile replacement leader first adopts any still-active binding and cancels its superseded cleanup, so startup never deletes and recreates a reusable thread. If a follower's graceful envelope is missed, the leader may create the same fenced cleanup only after its heartbeat is stale, the OS confirms the exact registered PID no longer exists, cleanup remains enabled, and no replacement registration can overtake deletion. Heartbeat loss alone, live/unknown process liveness, IPC failure, and auth failure remain non-destructive. Invalid-config recovery makes the setting unresolved and therefore skips destructive cleanup. Manual `/telegram-disconnect` keeps its confirmation and teardown behavior regardless of this setting.
@@ -240,7 +240,7 @@ A message (or All-tab command) held in an unbound or temporary tab gets one rout
240
240
  - Cancel appears only at the root.
241
241
  - An input sent from `All` moves into the routing tab opened for it: the tab starts with a silent forward of the input (Telegram shows it as forwarded from the operator), the chooser replies to it, and the original leaves `All` once the copy is confirmed. A restored Thread therefore keeps the prompt it answers.
242
242
  - A chooser is one-shot: every final outcome deletes its message. A confirmed Cancel, a Forward or Reroute, and a successful Restore remove the menu; Restore keeps the tab as the Pi's Thread, while a temporary tab with no remaining unresolved input is removed. If Telegram refuses the delete, Cancel and Restore leave an inert bold notice (`⛔️ Routing cancelled.` / `✅ Message routed.`) with an empty keyboard, and Forward asks for one more tap to clear the menu.
243
- - Old or expired controls answer `⌛ Routing choice expired.`
243
+ - Old or expired controls answer `⌛ Routing choice expired.` A newly committed expiry instead displays the fully bold `⌛ Routing expired; input was not delivered.` only for proven unissued work. Selected/issued or uncertain work displays fully bold `⌛ Routing expired; delivery is unconfirmed.` followed by `Previously accepted work may continue.` If the chooser is gone, or its temporary tab will be removed, send that notice without an old reply anchor to the leader's current owned Thread in the same private chat; never fall back to All. No owned Thread means diagnostics only.
244
244
  - Tab name: every temporary routing tab is named `🚦 Routing`, whether the bot created it (menu command or threadless prompt) or adopted the `New Chat` tab Telegram opened for a typed input. It is never renamed after its first input.
245
245
  - Status → `❌ Pending cancellations` lists one row per protected original: its text, or `📎` plus the caption, file name or the message field carrying Telegram's `file_id`.
246
246
 
@@ -453,7 +453,30 @@ export function createTelegramGenerativeAppBoundButtonActionInvoker<
453
453
  | GenerativeApps.GenerativeAppLiveSurface<GenerativeApps.TelegramBindLiveHandle>
454
454
  | undefined;
455
455
  try {
456
- boundAction = GenerativeApps.parseGenerativeAppBoundAction(action.prompt);
456
+ // Only buttons emitted by a Generative App carry a binding. An unbound button is ordinary model text
457
+ // (`std::map`, `a::b(c)`): malformed or uninstalled-app prompts fall through to the normal prompt plane.
458
+ if (!action.binding) {
459
+ try {
460
+ boundAction = GenerativeApps.parseGenerativeAppBoundAction(
461
+ action.prompt,
462
+ );
463
+ } catch {
464
+ return false;
465
+ }
466
+ if (
467
+ boundAction &&
468
+ !(await GenerativeApps.isGenerativeAppInstalled(
469
+ deps.agentDir,
470
+ boundAction.app,
471
+ ))
472
+ ) {
473
+ boundAction = undefined;
474
+ }
475
+ } else {
476
+ boundAction = GenerativeApps.parseGenerativeAppBoundAction(
477
+ action.prompt,
478
+ );
479
+ }
457
480
  if (!boundAction) return false;
458
481
  deps.assertExecutionCurrent(query);
459
482
  const chatId = query.message?.chat?.id;
@@ -70,6 +70,7 @@ import {
70
70
  type TelegramBusSelectedMenuTextEffect,
71
71
  type TelegramBusSocketPathSource,
72
72
  type TelegramBusWorkspaceRestoreObservation,
73
+ TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
73
74
  } from "./bus.ts";
74
75
  import type {
75
76
  TelegramConfigStore,
@@ -96,6 +97,7 @@ import {
96
97
  assertTelegramApiCallAuthority,
97
98
  TelegramApiAuthorityError,
98
99
  TelegramApiCommitUnknownError,
100
+ TelegramApiFollowerNotAllowedError,
99
101
  TelegramApiStaleTargetError,
100
102
  type TelegramApiCallOptions,
101
103
  } from "./telegram-api.ts";
@@ -1986,6 +1988,20 @@ export function createTelegramBusFollowerApiCaller(
1986
1988
  new Error(message ?? "Telegram bus API call result is ambiguous."),
1987
1989
  );
1988
1990
  }
1991
+ if (message === TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE) {
1992
+ const body =
1993
+ (method === "call" || method === "callMultipart") && isRecord(args[1])
1994
+ ? args[1]
1995
+ : undefined;
1996
+ const chatId = Number(body?.chat_id);
1997
+ const threadId = Number(body?.message_thread_id);
1998
+ throw new TelegramApiFollowerNotAllowedError(
1999
+ message,
2000
+ Number.isSafeInteger(chatId) && Number.isSafeInteger(threadId)
2001
+ ? { chatId, threadId }
2002
+ : undefined,
2003
+ );
2004
+ }
1989
2005
  throw new Error(message ?? "Telegram bus API call failed.");
1990
2006
  };
1991
2007
  return async (method, args, localOptions) => {
@@ -40,6 +40,7 @@ import {
40
40
  type TelegramBusProtocolIdentity,
41
41
  type TelegramBusSelectedMenuDelivery,
42
42
  type TelegramBusSocketPathSource,
43
+ TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
43
44
  } from "./bus.ts";
44
45
  import type { TelegramThreadDisplayMode } from "./config.ts";
45
46
  import type { TelegramAttachmentSource } from "./media.ts";
@@ -464,6 +465,9 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
464
465
  store: deps.topicTargetStore,
465
466
  getLeaderEpoch: deps.getCurrentLeaderEpoch,
466
467
  getExternalProtection: deps.captureWorkspaceExternalProtection,
468
+ onSlotReused: (bindingKey, isCurrent) => display
469
+ ? reconcileThreadDisplay(bindingKey, isCurrent).then(() => undefined)
470
+ : Promise.resolve(),
467
471
  recordEvent(message, details) {
468
472
  deps.recordRuntimeEvent("bus", message, details);
469
473
  },
@@ -513,6 +517,9 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
513
517
  getLeaderEpoch: () => deps.getCurrentLeaderEpoch?.(),
514
518
  captureLiveBindingKeys,
515
519
  captureBindingAuthority(binding) {
520
+ // Slotless retained bindings need display repair even without a live owner.
521
+ // The reconciler still fences the exact binding snapshot, profile, mode and epoch.
522
+ if (binding.slot === undefined) return () => true;
516
523
  const matches = (target: TelegramTarget | undefined) =>
517
524
  target?.chatId === binding.target.chatId &&
518
525
  target?.threadId === binding.target.threadId;
@@ -544,19 +551,22 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
544
551
  callApi: deps.callApi,
545
552
  })
546
553
  : undefined;
547
- const reconcileThreadDisplayOperation = async () => {
548
- const result = await display!.reconcile();
554
+ const reconcileThreadDisplayOperation = async (bindingKey?: string) => {
555
+ const result = await display!.reconcile(bindingKey);
549
556
  deps.onThreadDisplayChanged?.();
550
557
  return result;
551
558
  };
552
- const reconcileThreadDisplay = () =>
559
+ const reconcileThreadDisplay = (bindingKey?: string, isCurrent: () => boolean = () => true) =>
553
560
  runWorkspaceOperation(
554
561
  {
555
562
  operationId: createTelegramWorkspaceAdmissionOperationId(),
556
563
  operationKind: "workspace.reconcile-display",
557
564
  scopes: [{ kind: "profile" }],
558
565
  },
559
- reconcileThreadDisplayOperation,
566
+ () => {
567
+ if (!isCurrent()) throw new Error("Telegram Thread display update lost allocation authority.");
568
+ return reconcileThreadDisplayOperation(bindingKey);
569
+ },
560
570
  );
561
571
  let startupDisplayTimer: ReturnType<typeof setTimeout> | undefined;
562
572
  let startupDisplayGeneration = 0;
@@ -4181,7 +4191,7 @@ async function handleFollowerApiCall(
4181
4191
  ) {
4182
4192
  return rejectTelegramBusRequest(
4183
4193
  envelope.requestId,
4184
- "Telegram bus API call is not allowed for this follower.",
4194
+ TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
4185
4195
  );
4186
4196
  }
4187
4197
  if (!deps.callApi) {
@@ -2873,6 +2873,9 @@ export function isTelegramBusEnvelopeAuthorized(
2873
2873
  }
2874
2874
 
2875
2875
  /** Negative acknowledgement carrying only a diagnostic message. */
2876
+ export const TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE =
2877
+ "Telegram bus API call is not allowed for this follower.";
2878
+
2876
2879
  export function rejectTelegramBusRequest(
2877
2880
  requestId: string,
2878
2881
  message: string,
@@ -590,18 +590,32 @@ export default function (pi: Pi.ExtensionAPI) {
590
590
  );
591
591
  },
592
592
  });
593
- const inactiveThreadCleanupReviewRuntime =
594
- ThreadCleanupManager.createTelegramInactiveThreadCleanupReviewRuntime({
595
- getProfileName() {
596
- return configStore.getActiveProfileName() ?? "default";
593
+ const inactiveThreadCleanupRuntime =
594
+ ThreadCleanupManager.createTelegramInactiveThreadCleanupRuntime({
595
+ store: threadStore,
596
+ captureAuthority() {
597
+ const ledger = workspaceAdmissionRuntime.resolve();
598
+ const leaderEpoch = getCurrentLeaderEpoch();
599
+ const operator = configStore.getAllowedUserId();
600
+ if (!ledger || leaderEpoch === undefined || operator === undefined) return undefined;
601
+ const stamp = telegramTransportStampRuntime.getStamp();
602
+ const generation = telegramSessionContextStore.getGeneration();
603
+ return {
604
+ profileName: configStore.getActiveProfileName() ?? "default",
605
+ leaderEpoch, ledger,
606
+ isCurrent() {
607
+ return getCurrentLeaderEpoch() === leaderEpoch &&
608
+ telegramTransportStampRuntime.isActive(stamp) &&
609
+ configStore.getAllowedUserId() === operator &&
610
+ telegramSessionContextStore.getGeneration() === generation;
611
+ },
612
+ };
597
613
  },
598
- listBindings: threadStore.listWorkspaceBindings,
599
614
  getProtection: captureWorkspaceExternalProtection,
600
- listReservations: threadStore.listReservations,
601
- listPendingProvisions: threadStore.listPendingProvisions,
602
- listPendingCleanups: threadStore.listPendingCleanups,
603
- getWorkStore() {
604
- const profileName = configStore.getActiveProfileName() ?? "default";
615
+ getProcessLiveness: ProcessIdentity.getTelegramProcessLiveness,
616
+ runExclusive: telegramWorkspaceOperationRuntime.runExclusive,
617
+ getWorkStore(authority) {
618
+ const profileName = authority.profileName;
605
619
  const botToken = configStore.getBotToken();
606
620
  if (!botToken)
607
621
  throw new Error(
@@ -617,9 +631,16 @@ export default function (pi: Pi.ExtensionAPI) {
617
631
  tokenSha256: Journal.createTelegramUpdateJournalBotIdentity({
618
632
  botToken,
619
633
  }).tokenSha256,
634
+ admissionProfileKey: authority.ledger.getProfileKey(),
620
635
  });
621
636
  },
622
637
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
638
+ deleteThread(authorize) {
639
+ return directTelegramApiRuntime.deleteWorkspaceThread(authorize);
640
+ },
641
+ onError(error) {
642
+ recordRuntimeEvent("telegram", error, { phase: "manual-inactive-thread-cleanup" });
643
+ },
623
644
  });
624
645
  const updateAdmissionRuntimeBinding =
625
646
  Updates.createTelegramUpdateAdmissionRuntimeBinding<Pi.ExtensionContext>({
@@ -1187,7 +1208,8 @@ export default function (pi: Pi.ExtensionAPI) {
1187
1208
  sendInteractiveMessage,
1188
1209
  answerCallbackQuery,
1189
1210
  ...configControls,
1190
- reviewInactiveThreads: inactiveThreadCleanupReviewRuntime.review,
1211
+ reviewInactiveThreads: inactiveThreadCleanupRuntime.review,
1212
+ cleanInactiveThreads: inactiveThreadCleanupRuntime.clean,
1191
1213
  getThreadDisplayMode() {
1192
1214
  return threadStore.getBotState().threadMode === "enabled"
1193
1215
  ? Config.resolveTelegramThreadDisplayMode(configStore.get())
@@ -1741,6 +1763,16 @@ export default function (pi: Pi.ExtensionAPI) {
1741
1763
  },
1742
1764
  );
1743
1765
  },
1766
+ recoverRejectedRecipientTarget(error) {
1767
+ const rejected =
1768
+ error instanceof TelegramApi.TelegramApiFollowerNotAllowedError
1769
+ ? error.requestTarget
1770
+ : undefined;
1771
+ const current = telegramBusFollowerRegistrationState.getTarget();
1772
+ return rejected && current?.threadId !== undefined
1773
+ ? { rejected, current: { chatId: current.chatId, threadId: current.threadId } }
1774
+ : undefined;
1775
+ },
1744
1776
  prepareUpdateForExecution(update) {
1745
1777
  return BusFollower.prepareTelegramBusFollowerJournaledUpdateForExecution(
1746
1778
  update,
@@ -288,6 +288,18 @@ export function resolveGenerativeAppDir(agentDir: string, app: string): string {
288
288
  return join(getAppsRoot(agentDir), app);
289
289
  }
290
290
 
291
+ export async function isGenerativeAppInstalled(
292
+ agentDir: string,
293
+ app: string,
294
+ ): Promise<boolean> {
295
+ try {
296
+ await stat(resolveGenerativeAppModulePath(agentDir, app));
297
+ return true;
298
+ } catch {
299
+ return false;
300
+ }
301
+ }
302
+
291
303
  export function resolveGenerativeAppModulePath(
292
304
  agentDir: string,
293
305
  app: string,
@@ -14,6 +14,7 @@ import type {
14
14
  AgentStartEvent,
15
15
  AssistantMessageEvent,
16
16
  BeforeAgentStartEvent,
17
+ BeforeAgentStartEventResult,
17
18
  ExtensionAPI,
18
19
  ExtensionContext,
19
20
  InputEvent,
@@ -61,6 +62,7 @@ type TelegramBeforeAgentStartEvent = Omit<
61
62
 
62
63
  export interface TelegramBeforeAgentStartResult {
63
64
  systemPrompt?: string | string[];
65
+ message?: BeforeAgentStartEventResult["message"];
64
66
  }
65
67
 
66
68
  type TelegramBeforeAgentStartReturn =
@@ -207,7 +207,7 @@ export function buildAutomaticThreadCleanupSettingsText(
207
207
  "<code>-</code> <code>on</code> (default): delete the bound thread and release Telegram authority on graceful quit.",
208
208
  "<code>-</code> <code>off</code>: preserve the tab as a restart hint; manual <code>/telegram-disconnect</code> still confirms and deletes it.",
209
209
  "",
210
- "Review inactive tabs checks current owner and work evidence. Review never deletes tabs.",
210
+ "On the leader instance, Review inactive tabs checks current owner and work evidence, including tabs without a letter. Review never deletes tabs.",
211
211
  ].join("\n");
212
212
  }
213
213
 
@@ -217,7 +217,8 @@ function buildInactiveThreadReviewText(count: number): string {
217
217
  "",
218
218
  `${count} proven inactive tab${count === 1 ? "" : "s"}.`,
219
219
  "No tabs were deleted.",
220
- "Deletion requires a separate confirmed Clean action.",
220
+ "Clean inactive tabs deletes these Telegram tabs and all their messages. Pi session history and project files are not removed.",
221
+ "Clean rechecks owner and work protection; a changed or protected tab is skipped. Choose Back to leave without deleting anything.",
221
222
  ].join("\n");
222
223
  }
223
224
 
@@ -10,6 +10,7 @@ import {
10
10
  type AgentSettledEvent,
11
11
  type AgentStartEvent,
12
12
  type BeforeAgentStartEvent,
13
+ type BeforeAgentStartEventResult,
13
14
  type ExtensionAPI,
14
15
  type ExtensionCommandContext,
15
16
  type ExtensionContext,
@@ -33,6 +34,7 @@ export type {
33
34
  AgentStartEvent,
34
35
  AssistantMessageEvent,
35
36
  BeforeAgentStartEvent,
37
+ BeforeAgentStartEventResult,
36
38
  ExtensionAPI,
37
39
  ExtensionCommandContext,
38
40
  ExtensionContext,
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Telegram prompt injection helpers
3
3
  * Zones: pi agent prompts, telegram guidance
4
- * Owns Telegram-specific system prompt suffixes injected into pi agent turns
4
+ * Owns stable Telegram guidance, turn-scoped context, and compatibility fallback hooks
5
5
  */
6
6
 
7
- import type { BeforeAgentStartEvent } from "./pi.ts";
7
+ import type { BeforeAgentStartEvent, BeforeAgentStartEventResult } from "./pi.ts";
8
8
  import { TELEGRAM_PREFIX } from "./turns.ts";
9
9
 
10
10
  export const TELEGRAM_CONNECTED_CONTEXT_MESSAGE =
@@ -164,18 +164,34 @@ type TelegramBeforeAgentStartResult = {
164
164
  systemPrompt: TelegramSystemPrompt;
165
165
  };
166
166
 
167
+ type TelegramProactiveBeforeAgentStartResult = Omit<BeforeAgentStartEventResult, "systemPrompt"> & {
168
+ systemPrompt?: TelegramSystemPrompt;
169
+ };
170
+
167
171
  type TelegramBeforeAgentStartHook = (
168
172
  event: TelegramBeforeAgentStartEvent,
169
173
  ) => TelegramBeforeAgentStartResult;
170
174
 
171
- export function buildTelegramBridgeSystemPrompt(options: {
175
+ // Returning any systemPrompt makes Pi replace its prompt, which defeats prompt
176
+ // caching; return an override only when the computed prompt actually differs.
177
+ function overrideSystemPromptIfChanged(
178
+ incoming: TelegramSystemPrompt | null | undefined,
179
+ computed: TelegramSystemPrompt,
180
+ ): TelegramBeforeAgentStartResult | undefined {
181
+ const base = incoming ?? "";
182
+ const unchanged =
183
+ Array.isArray(base) && Array.isArray(computed)
184
+ ? base.length === computed.length &&
185
+ base.every((block, index) => block === computed[index])
186
+ : base === computed;
187
+ return unchanged ? undefined : { systemPrompt: computed };
188
+ }
189
+
190
+ function buildTelegramTurnSuffix(options: {
172
191
  prompt?: string;
173
- systemPrompt?: TelegramSystemPrompt | null;
174
192
  telegramPrefix?: string;
175
- localSystemPromptSuffix: string;
176
193
  telegramTurnSystemPromptSuffix: string;
177
- }): TelegramBeforeAgentStartResult {
178
- const basePrompt = options.systemPrompt ?? "";
194
+ }): string {
179
195
  const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
180
196
  const telegramHead = telegramPrefix.endsWith("]")
181
197
  ? telegramPrefix.slice(0, -1)
@@ -184,9 +200,20 @@ export function buildTelegramBridgeSystemPrompt(options: {
184
200
  const telegramTurn =
185
201
  trimmedPrompt.startsWith(`${telegramHead}]`) ||
186
202
  trimmedPrompt.startsWith(`${telegramHead}|`);
187
- const telegramSuffix = telegramTurn
203
+ return telegramTurn
188
204
  ? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
189
205
  : "";
206
+ }
207
+
208
+ export function buildTelegramBridgeSystemPrompt(options: {
209
+ prompt?: string;
210
+ systemPrompt?: TelegramSystemPrompt | null;
211
+ telegramPrefix?: string;
212
+ localSystemPromptSuffix: string;
213
+ telegramTurnSystemPromptSuffix: string;
214
+ }): TelegramBeforeAgentStartResult {
215
+ const basePrompt = options.systemPrompt ?? "";
216
+ const telegramSuffix = buildTelegramTurnSuffix(options);
190
217
  return {
191
218
  systemPrompt: Array.isArray(basePrompt)
192
219
  ? [...basePrompt, options.localSystemPromptSuffix + telegramSuffix]
@@ -236,22 +263,58 @@ export interface TelegramProactivePromptHookDeps<TContext> {
236
263
  isAvailable: (ctx: TContext) => boolean;
237
264
  }
238
265
 
266
+ const TELEGRAM_TOOL_NAMES = ["telegram_attach", "telegram_message"];
267
+
268
+ // Sections cannot modify an opaque prompt owned by an earlier handler.
269
+ // Preserve append/strip compatibility there instead of silently losing guidance.
270
+ function applyTelegramPromptSections(
271
+ event: TelegramBeforeAgentStartEvent,
272
+ available: boolean,
273
+ ): boolean {
274
+ const options = event.systemPromptOptions as
275
+ | Partial<TelegramBeforeAgentStartEvent["systemPromptOptions"]>
276
+ | undefined;
277
+ const sections = options?.sections;
278
+ if (typeof sections !== "object" || sections === null || options?.forceSystemPrompt !== undefined) return false;
279
+ if (available) {
280
+ sections.telegram = LOCAL_SYSTEM_PROMPT_SUFFIX.trim();
281
+ return true;
282
+ }
283
+ delete sections.telegram;
284
+ for (const name of TELEGRAM_TOOL_NAMES) {
285
+ if (options?.toolSnippets) delete options.toolSnippets[name];
286
+ if (options?.toolGuidelines) delete options.toolGuidelines[name];
287
+ }
288
+ return true;
289
+ }
290
+
239
291
  export function createTelegramProactiveBeforeAgentStartHook<TContext>(
240
292
  deps: TelegramProactivePromptHookDeps<TContext>,
241
293
  ): (
242
294
  event: TelegramBeforeAgentStartEvent,
243
295
  ctx: TContext,
244
- ) => Promise<TelegramBeforeAgentStartResult> {
296
+ ) => Promise<TelegramProactiveBeforeAgentStartResult | undefined> {
245
297
  const baseHook = deps.baseHook ?? createTelegramBeforeAgentStartHook();
246
298
  return async (event, ctx) => {
247
299
  deps.reconcileAvailability?.();
248
- if (!deps.isAvailable(ctx)) {
249
- return {
250
- systemPrompt: stripTelegramToolMetadataFromSystemPrompt(
251
- event.systemPrompt,
252
- ),
253
- };
300
+ const available = deps.isAvailable(ctx);
301
+ // A caller-supplied baseHook owns its own prompt text, so it stays on the forced-prompt path.
302
+ if (!deps.baseHook && applyTelegramPromptSections(event, available)) {
303
+ const turnSuffix = available ? buildTelegramTurnSuffix({
304
+ prompt: event.messageType === "custom" ? undefined : event.prompt,
305
+ telegramTurnSystemPromptSuffix: TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX,
306
+ }).trim() : "";
307
+ return turnSuffix ? { message: { customType: "telegram-turn", content: turnSuffix, display: false } } : undefined;
308
+ }
309
+ if (!available) {
310
+ return overrideSystemPromptIfChanged(
311
+ event.systemPrompt,
312
+ stripTelegramToolMetadataFromSystemPrompt(event.systemPrompt),
313
+ );
254
314
  }
255
- return baseHook(event);
315
+ return overrideSystemPromptIfChanged(
316
+ event.systemPrompt,
317
+ baseHook(event).systemPrompt,
318
+ );
256
319
  };
257
320
  }