@llblab/pi-kit 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/BACKLOG.md +2 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +2 -2
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  5. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +24 -0
  6. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +12 -1
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +52 -3
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +8 -0
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +35 -0
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +81 -3
  11. package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -6
  12. package/node_modules/@llblab/pi-telegram/dist/lib/config.js +0 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +13 -2
  14. package/node_modules/@llblab/pi-telegram/dist/lib/generative-app-worker.mjs +5 -0
  15. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +8 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +60 -35
  17. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +9 -0
  18. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +118 -57
  19. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +4 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +3 -1
  21. package/node_modules/@llblab/pi-telegram/dist/lib/status.js +25 -5
  22. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -3
  23. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  24. package/node_modules/@llblab/pi-telegram/docs/architecture.md +13 -13
  25. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +16 -16
  26. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +67 -4
  27. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +8 -0
  28. package/node_modules/@llblab/pi-telegram/lib/bus.ts +124 -3
  29. package/node_modules/@llblab/pi-telegram/lib/config.ts +0 -8
  30. package/node_modules/@llblab/pi-telegram/lib/extension.ts +15 -2
  31. package/node_modules/@llblab/pi-telegram/lib/generative-app-worker.mjs +5 -0
  32. package/node_modules/@llblab/pi-telegram/lib/journal.ts +76 -37
  33. package/node_modules/@llblab/pi-telegram/lib/locks.ts +148 -58
  34. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +3 -0
  35. package/node_modules/@llblab/pi-telegram/lib/queue.ts +5 -1
  36. package/node_modules/@llblab/pi-telegram/lib/status.ts +24 -5
  37. package/node_modules/@llblab/pi-telegram/lib/sync.ts +5 -5
  38. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  39. package/package.json +2 -2
package/BACKLOG.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # Backlog
2
2
 
3
- The 0.30.0 composition advances Telegram to 0.53.0; package pins, resource order and bundled runtime ownership remain authoritative in `package.json`. Release outcomes belong in [CHANGELOG.md](./CHANGELOG.md).
3
+ The 0.31.0 composition advances Telegram to 0.54.2; package pins, resource order and bundled runtime ownership remain authoritative in `package.json`. Release outcomes belong in [CHANGELOG.md](./CHANGELOG.md).
4
4
 
5
5
  ## Carried checks
6
6
 
7
+ - **Installed 0.31.0 Telegram smoke (operator-owned):** After separately authorized installation/reload of every Pi process together (bus protocol v3), confirm an idle leader makes no periodic `state.json` or `telegram.json.transaction` writes, a frozen leader is replaced by its follower, and switching Threaded Mode off takes followers offline. Packed validation does not certify running clients.
7
8
  - **Installed 0.30.0 Telegram smoke (operator-owned):** After separately authorized installation/reload, confirm live Restore/rebinding in both roles, immediate temporary-tab removal after Cancel and, on Windows, Restore and follower delivery with disposable runtime storage. Packed validation does not certify running clients.
8
9
  - **Installed 0.29.2 continuity/recovery smoke (operator-owned):** After separately authorized installation/reload, use disposable sessions/storage to confirm idle Stop followed by native compaction preserves the summary and tool results through Passive reload/resume, without replaying effects. Confirm Telegram outage recovery and unconfirmed-disconnect reporting on a disposable setup. Packed validation does not certify running clients or authorize live fault injection.
9
10
  - **Installed 0.29.1 State Flow smoke (operator-owned):** After separately authorized installation/reload, use disposable storage to confirm new diagnostics use `tmp/pi-state-flow`, old `tmp/state-flow` logs and canonical memory stay untouched, and selected Telegram modes show 🟣 Off, 🟡 Passive and 🟢 Active. Packed validation does not certify installed clients.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.31.0: Telegram Idle Runtime And Bus Liveness
6
+
7
+ - `Telegram 0.54`: Advances the exact Telegram pin to `0.54.2`. Idle connected sessions make no periodic disk writes: the update journal no longer borrows the `telegram.json` lock and the leader proves liveness over the bus instead of a two-second `state.json` heartbeat. Bus protocol v3 requires updating every Pi process together.
8
+ - `Telegram fixes`: Followers go offline instead of looping when Threaded Mode is switched off, unverifiable ownership checks are recorded and tolerated before standing down, and cancelled Generative App methods cannot leave processes behind. Other pins, package membership, resources and load order are unchanged.
9
+
10
+ ## 0.30.1: Telegram Text Reply Diagnostics
11
+
12
+ - `Telegram Voice Artifacts`: Advances the exact Telegram pin to `0.53.1`. Plain text replies no longer record a false "every voice synthesis provider failed" delivery error when a reply carries no voice content; genuine voice failures still fall back to text. Other pins, package membership, resources and load order are unchanged.
13
+
5
14
  ## 0.30.0: Telegram Live Rebinding and Windows Support
6
15
 
7
16
  - `Telegram Live Rebinding`: Advances the exact Telegram pin to `0.53.0`. Restore moves a held prompt or supported command to another Pi through one same-session save/apply/release channel with bounded old-Thread cleanup. Temporary routing tabs are named 🚦 Routing, Cancel removes the tab and any All copy right away, and a restart revives a still-waiting chooser. Toasts are plain text and navigation answers silently.
package/README.md CHANGED
@@ -18,7 +18,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
18
18
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.12.1` | Shared Codex quota/Business credit status and persistent priority Fast toggle, mirrored in Telegram |
19
19
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.9.0` | Visible continuation scheduling and bounded worker Skills through compiled, manifest-owned resources |
20
20
  | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.26.4` | Scoped context/memory compiler with step-independent current memory, stable Passive/Active modes and visible blocked-state status |
21
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.53.0` | Telegram companion with live Thread rebinding, temporary routing tabs, Workspace Restore, follower Threads, Windows support, files, voice, and controls |
21
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.54.2` | Telegram companion with live Thread rebinding, temporary routing tabs, Workspace Restore, follower Threads, Windows support, files, voice, and controls |
22
22
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
23
23
 
24
24
  Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
@@ -45,7 +45,7 @@ Prefer the kit instead of separately loading the same packages. If you already u
45
45
 
46
46
  ## Development
47
47
 
48
- The `0.30.0` composition advances Telegram to `0.53.0`: same-session live Thread rebinding for prompts and supported commands, temporary routing tabs that disappear right after Cancel, and full Windows support for Restore, live rebinding and cleanup through the same strict journal reads. State Flow stays at `0.26.4`; all other pins, resources and load order remain unchanged. Telegram runtime storage stays in `tmp/pi-telegram`. Drain the prompt queue or accept losing waiting work before reload/restart; damaged-state reset discards all profiles' runtime continuity. `npm run validate` checks exact installed pins, declared resources, dependency audit and bundled inventory. It does not certify installed-client rendering; carried checks remain in [Backlog](./BACKLOG.md).
48
+ The `0.31.0` composition advances Telegram to `0.54.2`: an idle connected session no longer writes to disk periodically (the update journal has its own lock instead of the `telegram.json` guard, the leader proves liveness over the bus instead of a two-second `state.json` heartbeat), followers go offline instead of looping when Threaded Mode is switched off, unverifiable ownership checks are recorded and tolerated, and cancelled Generative App methods cannot leave processes behind. Telegram 0.54 uses bus protocol v3: update every Pi process that runs pi-telegram together, because a 0.53.x follower cannot register with a 0.54 leader. State Flow stays at `0.26.4`; all other pins, resources and load order remain unchanged. Telegram runtime storage stays in `tmp/pi-telegram`. Drain the prompt queue or accept losing waiting work before reload/restart; damaged-state reset discards all profiles' runtime continuity. `npm run validate` checks exact installed pins, declared resources, dependency audit and bundled inventory. It does not certify installed-client rendering; carried checks remain in [Backlog](./BACKLOG.md).
49
49
 
50
50
  ```bash
51
51
  npm install
@@ -76,7 +76,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
76
76
  - Runtime files live under `<agent-dir>/tmp/pi-telegram` (releases before 0.52.0 used `tmp/telegram`, which a new release never writes, migrates or deletes). The older `owners.json` is read-only evidence: a live fresh owner there blocks acquisition so two release generations never poll one bot, and only its identity is exposed, never its bus endpoint or secret. Only `state.json` and `logs.jsonl` are persistent root files: the per-profile `transport` section of `state.json` is the sole transport-owner authority, `workspace` owns canonical Workspace/Restore evidence and `admission` its leases; the `runtime` section and `logs.jsonl` never grant routing authority. Each section changes only through the shared transaction under its own domain authority (nonleader admission remains process/birth-owned, not transport-owned); Workspace/runtime publication captures exact session authority, and acquisition, refresh, release, takeover, and irreversible leader work fence the exact owner/epoch. Ordinary reads and section mutations still refuse a damaged shared envelope without repair. Only a non-election leader start (`/telegram-connect` or session auto-start) runs the operator-approved optimistic reset: under the shared transaction guard it replaces damaged envelope/transport/Workspace/admission evidence with an empty envelope, accepting loss of every profile's runtime continuity; filesystem access errors still refuse (see [Damaged-State Reset](./docs/architecture.md#damaged-state-reset-operator-approved)). Use the [session journal contract](./docs/multi-instance-bus.md#session-owned-journal-storage) for owners-named polling continuity and the explicitly approved optimistic session-family sweep; that sweep is not Thread deletion or receipt settlement.
77
77
  - Threaded Mode has exactly one live leader per bot profile. Followers are real operator-started Pi processes and must authenticate/register over local IPC; Telegram never spawns hidden Pi processes. A live but unreachable owner does not authorize split-brain polling.
78
78
  - Local IPC is a trust boundary, not merely a private socket. Unknown, stale, mismatched-generation, or unauthorized requests must not inject prompts, callbacks, API sends, artifacts, liveness, or bindings. Follower registration identity may prepare a binding, but inbound generation authority is published only after successful preparation for the same generation and current context; pending or failed startup cannot append into a retained old journal. Readiness also binds the active Pi context and supplied session generation. Same-session refresh awaits binding preparation without re-registering; a changed session ID cannot publish readiness until its exact leader registration is acknowledged. Reusing a context object cannot carry readiness across a session-generation or ID change. Registration requests capture session authority before asynchronous startup and fence every publication/finalization against the current attempt. Stop or supersession invalidates that attempt; obsolete cleanup cannot erase a newer registration or a refreshed context.
79
- - Protocol compatibility is independent from package version. Registration negotiates protocol version, runtime build, and canonical capabilities before target provisioning or live publication. `durable-follower-admission-v1` gates source forwarding; `queue-handoff-v1` independently gates semantic queue transfer for every participant and is advertised only with exact source/recipient journal-binding composition. `follower.register` and capability-gated restore-only `follower.restoreWorkspace` are bootstrap requests; other requests require exact live-registry generation authority, and `bus.ack` is response-only. `thread-display-mode-v1` gates follower display-setting requests; the leader owns their serialized profile preference and title application.
79
+ - Protocol compatibility is independent from package version. Registration negotiates protocol version, runtime build, and canonical capabilities before target provisioning or live publication. `durable-follower-admission-v1` gates source forwarding; `queue-handoff-v1` independently gates semantic queue transfer for every participant and is advertised only with exact source/recipient journal-binding composition. `follower.register`, capability-gated restore-only `follower.restoreWorkspace` and side-effect-free liveness `bus.probe` are bootstrap requests; other requests require exact live-registry generation authority, and `bus.ack` is response-only. `thread-display-mode-v1` gates follower display-setting requests; the leader owns their serialized profile preference and title application.
80
80
  - Long-lived timers, pollers, watchers, receivers, heartbeats, background delivery, and deferred dispatch are session-bound. Shutdown cancels/drains diagnostics publications and fences deferred status reads before they can acquire guards or repair files. Replacement stops stale activity and makes late work inert; teardown must recheck the captured session generation even when context identity is reused. Same-process handoff may preserve exact profile/target identity but never stale Pi context or cross-profile authority. Follower heartbeat and leader-to-follower Restore/live-rebind control reply deadlines align with the eight-second leader stale-liveness window rather than the generic one-second local-RPC default; an ordinary multi-second Pi/TUI event-loop stall or a slower Windows named pipe is not registration or recipient loss. Participating source observations must hold a reference for the actual read, including pending-mutation/count queries against a stopped worker's retained source; a scoped observation never restores receipt readiness or execution authority. A donor cancellation resumed after remote handoff awaits must also hold a source reference; only the existing exact journal CAS may cancel, never undo accepted recipient custody. Stable source keys do not certify captured callable lifetimes: snapshot prepared worker capabilities at construction and replace them on source-handle renewal without replaying unsettled input. Aborting a durable update generation does not release that `update_id`: replacement replay waits for its actual handler settlement, and effectful handlers use the shared execution fence immediately before commit and after awaited delegation. Admission also rechecks that fence after the default handler returns, before its outcome can settle custody; a stopped handler's ordinary return is not completion authority. Internal clones explicitly carry the hidden fence; reroute forwarding, thread-store mutation, cleanup, and Bot API boundaries retain the originating generation.
81
81
  - Runtime state is event-driven reconciliation of local assumptions against Telegram signals, not a complete bot read-model and not permission to query Telegram on every action. Destructive thread cleanup goes through `thread-reconciler` with current proof and leader fencing. Fresh Workspace Thread creation derives its initial Bot API title from the active display mode before issuance; the stable generated `threadName` remains separate from the acknowledged `displayTitle`.
82
82
 
@@ -4,6 +4,30 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.54.2: Diagnosable ownership checks and safe app cancellation
8
+
9
+ - `Ownership checks`: An ownership check that cannot be verified no longer silently stops a live transport. Each such miss is recorded (`ownership-check-failed`) and polling stands down only after two consecutive tolerated misses; a confirmed loss is recorded as `ownership-lost` and stands down at once (#319).
10
+ - `Generative Apps`: A cancelled app method can no longer start a process: an abort that arrives before the method calls `run` previously let the worker spawn a child that its own termination could orphan.
11
+
12
+ ## 0.54.1: Followers go offline when Threaded Mode is turned off
13
+
14
+ - `Threaded Mode downgrade`: Turning Threaded Mode off in BotFather now takes followers offline instead of looping between `electing` and `disconnected` (and filling the log) against a leader that switched to classic polling; reconnect with `/telegram-connect` after turning it back on. `/telegram-disconnect` on a follower without a live leader now disconnects locally and reports the Thread as kept, and a pending recovery retry no longer re-registers a disconnected follower.
15
+ - `Ownership reads`: A lock-free ownership check that races a concurrent `state.json` replacement is now retried and confirmed through the serialized path before the leader stops polling, so a transient read miss (seen on slow Windows runners) is no longer mistaken for lost ownership. The integration test for a revoked follower provisioning now waits for its asynchronous lease release.
16
+
17
+ ## 0.54.0: No periodic disk writes while idle
18
+
19
+ - `Journal serialization`: The update journal no longer borrows the `telegram.json` lock. Journal operations serialize on their own `tmp/pi-telegram/runtime/journals.transaction`, so receiving messages and repairing journals never touch the config file or its guard; only config writes and sender admission still take it. Bus protocol moves to v3 so 0.53.x peers, which serialize on the old lock, cannot register alongside 0.54 writers.
20
+ - `Idle status writes`: The persisted runtime status no longer stores poll-cycle timestamps, so an idle connected session stops rewriting `state.json` after every long-poll response.
21
+ - `Leader liveness`: The leader no longer rewrites `state.json` every two seconds. A dead PID is taken over at once; a live but frozen Threaded Mode leader is replaced only after an authenticated `bus.probe` proves it unresponsive, bound to that exact owner. Classic mode never takes over a live PID silently: `/telegram-connect` asks first. An idle connected session now makes no periodic disk writes.
22
+
23
+ ## 0.53.2: Idle runtime without lock churn
24
+
25
+ - `Idle disk load`: Polling-cursor and status reads of the update journal no longer open config and journal transactions, so a connected idle session stops creating `telegram.json.transaction.staged.*` and `inbox.json.transaction.staged.*` guard folders; only reads that need repair take the serialized path. Unchanged runtime status snapshots are compared before locking and skip their `state.json` transaction.
26
+
27
+ ## 0.53.1: Text replies without false voice errors
28
+
29
+ - `Voice artifacts`: A reply plan without voice content no longer reaches outbound voice artifact delivery, so plain text replies stop recording "every voice synthesis provider failed" delivery errors; explicit `telegram_voice` markup and genuine synthesis failures keep their existing fallback behavior (#318).
30
+
7
31
  ## 0.53.0: Live Thread rebinding, routing tabs and Windows support
8
32
 
9
33
  - `Live-rebind state`: Atomic same-session Workspace binding/operation publication, fenced recipient application and bounded one-shot cleanup scheduling. Failed/unknown cleanup keeps the new binding without blocking the next attempt; chooser retirement keeps saved inputs. Shared-state retries recheck captured authority before every rename, so a revoked grant cannot overwrite retained state.
@@ -179,6 +179,8 @@ export declare function createTelegramManualFollowerProfileKeyResolver(input: {
179
179
  }): () => string;
180
180
  export interface TelegramBusFollowerElection {
181
181
  expectedOwner?: TelegramLockEntry;
182
+ /** The expected owner is bus-proven unresponsive; the lock CAS still requires it to be the current owner. */
183
+ unresponsive?: boolean;
182
184
  }
183
185
  export type TelegramBusFollowerPromotionHandler<TContext> = (ctx: TContext, binding: TelegramBusFollowerPromotedBinding, election: TelegramBusFollowerElection) => Promise<boolean>;
184
186
  type TelegramBusFollowerWorkspaceAdmissionDeps = {
@@ -227,6 +229,10 @@ export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
227
229
  scheduleRetry?: (retry: () => void, delayMs: number) => void;
228
230
  getActiveContext?: () => TContext | undefined;
229
231
  promotionGraceMs?: number;
232
+ /** Bus liveness proof for a live-PID leader that no longer answers; replaces the retired file heartbeat. */
233
+ proveLeaderUnresponsive?: (owner: TelegramLockEntry) => Promise<boolean>;
234
+ /** Shared bot state says Threaded Mode is off: followers cannot exist, so recovery goes offline instead. */
235
+ isThreadModeDisabled?: () => boolean | Promise<boolean>;
230
236
  recordRuntimeEvent: (category: string, error: unknown, details?: Record<string, unknown>) => void;
231
237
  }
232
238
  export interface TelegramBusForwardedUpdateReceiverRuntimeDeps<TContext> {
@@ -415,7 +421,12 @@ export declare function createTelegramBusFollowerControlState(): TelegramBusFoll
415
421
  export declare function createTelegramBusFollowerRegistrationState(options?: {
416
422
  onAvailabilityChanged?: () => void;
417
423
  }): TelegramBusFollowerRegistrationState;
418
- export declare function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(deps: TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext>): (error: unknown, ctx: TContext) => Promise<void>;
424
+ /** Heartbeat-failure recovery that a deliberate local stop halts until the next deliberate registration. */
425
+ export type TelegramBusFollowerHeartbeatRecoveryHandler<TContext> = ((error: unknown, ctx: TContext) => Promise<void>) & {
426
+ halt: () => void;
427
+ resume: () => void;
428
+ };
429
+ export declare function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(deps: TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext>): TelegramBusFollowerHeartbeatRecoveryHandler<TContext>;
419
430
  export declare function createTelegramBusFollowerRegistrationRuntime<TContext extends {
420
431
  cwd?: string;
421
432
  }>(deps: TelegramBusFollowerRegistrationRuntimeDeps<TContext>): TelegramBusFollowerRegistrationRuntime<TContext>;
@@ -190,7 +190,7 @@ export function createTelegramBusFollowerRuntimeAssembly(ports) {
190
190
  ...ports.recovery,
191
191
  registrationState: ports.registrationState,
192
192
  recordRuntimeEvent: ports.recordRuntimeEvent,
193
- getRegistrationRuntime: () => registration,
193
+ getRegistrationRuntime: () => baseRegistration,
194
194
  });
195
195
  registration = createTelegramBusFollowerRegistrationRuntime({
196
196
  ...ports.registration,
@@ -203,6 +203,15 @@ export function createTelegramBusFollowerRuntimeAssembly(ports) {
203
203
  const baseRegistration = registration;
204
204
  registration = {
205
205
  ...baseRegistration,
206
+ // Public stop is deliberate (disconnect, suspension); recovery keeps the base stop for promotion.
207
+ stop() {
208
+ recovery.halt();
209
+ baseRegistration.stop();
210
+ },
211
+ registerWithLeader(ctx, leader, options) {
212
+ recovery.resume();
213
+ return baseRegistration.registerWithLeader(ctx, leader, options);
214
+ },
206
215
  async setContext(ctx) {
207
216
  const sessionGeneration = ports.registration.getSessionGeneration?.();
208
217
  if (ports.registration.isContextActive?.(ctx) === false)
@@ -1214,6 +1223,9 @@ export function createTelegramBusFollowerRegistrationState(options = {}) {
1214
1223
  };
1215
1224
  }
1216
1225
  export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1226
+ // Promotion stops registration internally and must keep retrying; only an operator stop or a confirmed
1227
+ // switch to classic mode halts recovery, so it never re-registers behind a disconnect.
1228
+ let halted = false;
1217
1229
  const promotionGraceMs = deps.promotionGraceMs ?? TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS;
1218
1230
  const sleep = deps.sleep ??
1219
1231
  ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -1246,6 +1258,10 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1246
1258
  .registerWithLeader(ctx, leader, binding?.target ? { target: binding.target } : undefined);
1247
1259
  if (!restored)
1248
1260
  return false;
1261
+ if (halted) {
1262
+ deps.getRegistrationRuntime().stop();
1263
+ return false;
1264
+ }
1249
1265
  deps.setLifecyclePhase(undefined);
1250
1266
  safeUpdateStatus(ctx);
1251
1267
  deps.recordRuntimeEvent("bus", "Telegram follower registration restored", {
@@ -1266,6 +1282,8 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1266
1282
  });
1267
1283
  const scheduleRecovery = (reason, fallbackCtx, binding) => {
1268
1284
  const retry = () => {
1285
+ if (halted)
1286
+ return;
1269
1287
  const activeCtx = deps.getActiveContext
1270
1288
  ? deps.getActiveContext()
1271
1289
  : fallbackCtx;
@@ -1336,12 +1354,29 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1336
1354
  });
1337
1355
  };
1338
1356
  const recover = async (error, ctx, carriedBinding) => {
1339
- if (promotionPending)
1357
+ if (promotionPending || halted)
1340
1358
  return;
1341
1359
  promotionPending = true;
1342
1360
  deps.registrationState.beginRecovery();
1343
1361
  const initialBinding = carriedBinding ?? snapshotBinding();
1344
1362
  try {
1363
+ let threadModeDisabled = false;
1364
+ try {
1365
+ threadModeDisabled = (await deps.isThreadModeDisabled?.()) === true;
1366
+ }
1367
+ catch (stateError) {
1368
+ deps.recordRuntimeEvent("bus", stateError, {
1369
+ phase: "follower-thread-mode-read",
1370
+ });
1371
+ }
1372
+ if (threadModeDisabled) {
1373
+ halted = true;
1374
+ deps.getRegistrationRuntime().stop();
1375
+ deps.setLifecyclePhase(undefined);
1376
+ safeUpdateStatus(ctx);
1377
+ deps.recordRuntimeEvent("bus", "Telegram follower went offline: Threaded Mode is off. Run /telegram-connect after turning it back on.", { phase: "follower-thread-mode-disabled" });
1378
+ return;
1379
+ }
1345
1380
  const state = deps.getLeaderState();
1346
1381
  if (state.kind === "active-elsewhere") {
1347
1382
  clearRegisteredState(ctx);
@@ -1357,6 +1392,13 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1357
1392
  if (await tryRegisterWithLeader(ctx, graceState.lock, "follower-register-restore-grace", initialBinding)) {
1358
1393
  return;
1359
1394
  }
1395
+ if (await deps.proveLeaderUnresponsive?.(graceState.lock)) {
1396
+ await promoteToLeader(error, ctx, initialBinding, {
1397
+ expectedOwner: graceState.lock,
1398
+ unresponsive: true,
1399
+ });
1400
+ return;
1401
+ }
1360
1402
  deps.setLifecyclePhase(undefined);
1361
1403
  safeUpdateStatus(ctx);
1362
1404
  deps.recordRuntimeEvent("bus", "Telegram follower promotion blocked by live leader lease", {
@@ -1390,7 +1432,14 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1390
1432
  promotionPending = false;
1391
1433
  }
1392
1434
  };
1393
- return recover;
1435
+ return Object.assign(recover, {
1436
+ halt() {
1437
+ halted = true;
1438
+ },
1439
+ resume() {
1440
+ halted = false;
1441
+ },
1442
+ });
1394
1443
  }
1395
1444
  export function createTelegramBusFollowerRegistrationRuntime(deps) {
1396
1445
  const getNowMs = deps.getNowMs ?? Date.now;
@@ -2184,6 +2184,14 @@ export function createTelegramBusLeaderEnvelopeHandler(deps) {
2184
2184
  return createUnauthorizedBusAck(envelope.requestId);
2185
2185
  }
2186
2186
  switch (envelope.kind) {
2187
+ case "bus.probe":
2188
+ // Liveness only: no registry, routing, journal or Bot API effect.
2189
+ return {
2190
+ kind: "bus.ack",
2191
+ requestId: envelope.requestId,
2192
+ ok: true,
2193
+ protocol: deps.protocolIdentity,
2194
+ };
2187
2195
  case "follower.register":
2188
2196
  case "follower.restoreWorkspace": {
2189
2197
  const compatibility = getTelegramBusProtocolCompatibility({
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { type TelegramBusEndpointLayout, type TelegramBusTransportEventRecorder, type TelegramBusTransportRetryPolicy } from "./bus-transport.ts";
8
8
  import type { TelegramThreadDisplayMode } from "./config.ts";
9
+ import { type TelegramLockEntry, type TelegramLockState } from "./locks.ts";
9
10
  import { type TelegramQueueHandoffPayload } from "./queue.ts";
10
11
  import { type TelegramTarget } from "./target.ts";
11
12
  import { type TelegramSessionReplacementIntent } from "./threads.ts";
@@ -525,6 +526,10 @@ export type TelegramBusEnvelope = ({
525
526
  method: string;
526
527
  args: unknown[];
527
528
  sentAtMs: number;
529
+ } | {
530
+ /** Side-effect-free leader liveness probe; any acknowledgement proves a responsive event loop. */
531
+ kind: "bus.probe";
532
+ requestId: string;
528
533
  } | {
529
534
  kind: "bus.ack";
530
535
  requestId: string;
@@ -558,6 +563,36 @@ export declare function createTelegramBusRequestId(input: {
558
563
  export declare function createTelegramBusRequestIdFactory(instanceId: string): () => string;
559
564
  export declare function encodeTelegramBusEnvelope(envelope: TelegramBusEnvelope): string;
560
565
  export declare function parseTelegramBusEnvelope(line: string): TelegramBusEnvelope | undefined;
566
+ /**
567
+ * One leader liveness observation. `silent` (connected or connecting, no answer within the window) and
568
+ * `unreachable` (an endpoint that refuses connections: nobody listens) can support a takeover. A missing endpoint is
569
+ * `unknown`: a live classic-mode leader has no socket at all. Any bytes or an orderly close prove the peer's event
570
+ * loop handled the connection, so even an older leader rejecting the unknown kind is `responsive`.
571
+ */
572
+ export type TelegramBusLeaderProbeResult = "responsive" | "silent" | "unreachable" | "unknown";
573
+ export declare function probeTelegramBusLeader(input: {
574
+ socketPath: TelegramBusSocketPathSource;
575
+ secret?: string;
576
+ timeoutMs?: number;
577
+ }): Promise<TelegramBusLeaderProbeResult>;
578
+ /**
579
+ * Takeover evidence replacing the retired file heartbeat: a full silent window, or an unreachable endpoint
580
+ * confirmed again after one more window (a just-started leader binds its socket well within it). The owner must
581
+ * stay the same throughout; the lock acquisition then CAS-checks that exact owner.
582
+ */
583
+ export declare function proveTelegramBusLeaderUnresponsive(input: {
584
+ probe: () => Promise<TelegramBusLeaderProbeResult>;
585
+ isSameOwner: () => boolean;
586
+ sleep: (ms: number) => Promise<void>;
587
+ windowMs?: number;
588
+ }): Promise<boolean>;
589
+ /** Composition-ready takeover proof for one profile's leader lock and endpoint. */
590
+ export declare function createTelegramBusLeaderUnresponsivenessProof(deps: {
591
+ getLeaderState: () => TelegramLockState;
592
+ getLeaderSocketPath: () => string;
593
+ probe?: typeof probeTelegramBusLeader;
594
+ sleep?: (ms: number) => Promise<void>;
595
+ }): (owner: TelegramLockEntry) => Promise<boolean>;
561
596
  interface TelegramBusLocalServer {
562
597
  start: () => Promise<void>;
563
598
  stop: () => Promise<void>;
@@ -12,7 +12,7 @@ import { platform as getPlatform, tmpdir } from "node:os";
12
12
  import { basename, dirname, join } from "node:path";
13
13
  import { isDeepStrictEqual } from "node:util";
14
14
  import { classifyTelegramBusTransportError, createTelegramBusTransportTimeoutError, delayTelegramBusTransportRetry, getTelegramBusEndpointDiagnostics, getTelegramBusFollowerEndpoint, getTelegramBusLeaderEndpoint, getTelegramBusPipePath, getTelegramBusTransportRetryPolicy, isRetryableTelegramBusTransportError, isTelegramBusPipePath, probeTelegramBusEndpoint, TELEGRAM_BUS_MAX_DIRECT_UNIX_ENDPOINT_BYTES, } from "./bus-transport.js";
15
- import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS } from "./locks.js";
15
+ import { isSameTelegramLockOwner, TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS, } from "./locks.js";
16
16
  import { resolveAgentDir } from "./paths.js";
17
17
  import { getTelegramProcessBirthIdentity } from "./process-identity.js";
18
18
  import { parseTelegramQueueHandoffPayload, TELEGRAM_QUEUE_HANDOFF_PAYLOAD_MAX_BYTES, } from "./queue.js";
@@ -46,7 +46,8 @@ export function createTelegramBusProcessRuntime(input) {
46
46
  export function createTelegramBusAuthSecret() {
47
47
  return randomBytes(32).toString("base64url");
48
48
  }
49
- const TELEGRAM_BUS_PROTOCOL_VERSION = 2;
49
+ // v3: journal source serialization left the config transaction; mixed v2/v3 peers would not exclude each other.
50
+ const TELEGRAM_BUS_PROTOCOL_VERSION = 3;
50
51
  export const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION = "durable-follower-admission-v1";
51
52
  export const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF = "queue-handoff-v1";
52
53
  export const TELEGRAM_BUS_CAPABILITY_INPUT_CUSTODY_REFERENCE = "input-custody-reference-v1";
@@ -466,7 +467,8 @@ export function createTelegramBusFollowerSourceReferenceDeliveryIdentity(input)
466
467
  }
467
468
  export function getTelegramBusEnvelopeTrafficClass(envelope) {
468
469
  if (envelope.kind === "follower.register" ||
469
- envelope.kind === "follower.restoreWorkspace")
470
+ envelope.kind === "follower.restoreWorkspace" ||
471
+ envelope.kind === "bus.probe")
470
472
  return "bootstrap";
471
473
  if (envelope.kind === "bus.ack")
472
474
  return "response";
@@ -718,6 +720,9 @@ export function parseTelegramBusEnvelope(line) {
718
720
  case "follower.callApi":
719
721
  envelope = parseCallApiEnvelope(value, requestId);
720
722
  break;
723
+ case "bus.probe":
724
+ envelope = { kind, requestId };
725
+ break;
721
726
  case "bus.ack":
722
727
  envelope = parseAckEnvelope(value, requestId);
723
728
  break;
@@ -729,6 +734,79 @@ export function parseTelegramBusEnvelope(line) {
729
734
  envelope.auth = auth;
730
735
  return envelope;
731
736
  }
737
+ export function probeTelegramBusLeader(input) {
738
+ const timeoutMs = input.timeoutMs ?? TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS;
739
+ return new Promise((resolve) => {
740
+ let settled = false;
741
+ let socket;
742
+ const finish = (result) => {
743
+ if (settled)
744
+ return;
745
+ settled = true;
746
+ clearTimeout(timer);
747
+ socket?.destroy();
748
+ resolve(result);
749
+ };
750
+ const timer = setTimeout(() => finish("silent"), timeoutMs);
751
+ let connected = false;
752
+ try {
753
+ socket = createConnection(resolveTelegramBusSocketPath(input.socketPath));
754
+ }
755
+ catch {
756
+ finish("unknown");
757
+ return;
758
+ }
759
+ socket.once("connect", () => {
760
+ connected = true;
761
+ socket.write(encodeTelegramBusEnvelope({
762
+ kind: "bus.probe",
763
+ requestId: `probe:${randomBytes(8).toString("hex")}`,
764
+ ...(input.secret ? { auth: input.secret } : {}),
765
+ }));
766
+ });
767
+ socket.once("data", () => finish("responsive"));
768
+ socket.once("end", () => finish("responsive"));
769
+ socket.once("error", (error) => {
770
+ const code = error.code;
771
+ finish(!connected && code === "ECONNREFUSED" ? "unreachable" : "unknown");
772
+ });
773
+ });
774
+ }
775
+ /**
776
+ * Takeover evidence replacing the retired file heartbeat: a full silent window, or an unreachable endpoint
777
+ * confirmed again after one more window (a just-started leader binds its socket well within it). The owner must
778
+ * stay the same throughout; the lock acquisition then CAS-checks that exact owner.
779
+ */
780
+ export async function proveTelegramBusLeaderUnresponsive(input) {
781
+ const first = await input.probe();
782
+ if (first === "silent")
783
+ return input.isSameOwner();
784
+ if (first !== "unreachable")
785
+ return false;
786
+ await input.sleep(input.windowMs ?? TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS);
787
+ if (!input.isSameOwner())
788
+ return false;
789
+ const second = await input.probe();
790
+ return (second === "silent" || second === "unreachable") && input.isSameOwner();
791
+ }
792
+ /** Composition-ready takeover proof for one profile's leader lock and endpoint. */
793
+ export function createTelegramBusLeaderUnresponsivenessProof(deps) {
794
+ const probe = deps.probe ?? probeTelegramBusLeader;
795
+ const sleep = deps.sleep ??
796
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
797
+ return (owner) => proveTelegramBusLeaderUnresponsive({
798
+ probe: () => probe({
799
+ socketPath: owner.busSocketPath ?? deps.getLeaderSocketPath(),
800
+ secret: owner.busSecret,
801
+ }),
802
+ isSameOwner: () => {
803
+ const state = deps.getLeaderState();
804
+ return (state.kind === "active-elsewhere" &&
805
+ isSameTelegramLockOwner(state.lock, owner));
806
+ },
807
+ sleep,
808
+ });
809
+ }
732
810
  const TELEGRAM_ACTIVE_LOCAL_SERVERS = Symbol.for("@llblab/pi-telegram/active-local-servers");
733
811
  function getActiveTelegramBusLocalServers() {
734
812
  const root = globalThis;
@@ -116,12 +116,6 @@ export interface TelegramConfigStore {
116
116
  setAllowedUserId: (userId: number) => void;
117
117
  /** Publish an unpaired profile owner atomically; true only for the resulting exact owner. */
118
118
  persistAllowedUserId: (userId: number, assertExecutionCurrent?: () => void, commitIfOwned?: (commit: () => void) => boolean) => Promise<boolean>;
119
- /** Lock-only serialization for trusted synchronous source operations, not authorization.
120
- * Does not read/adopt config. Acquire required Workspace admission first; never
121
- * acquire owners or nest config admission here. Do not pass async callbacks:
122
- * returned promises are not protected after their synchronous prefix.
123
- */
124
- withSourceSerialization: <T>(operation: () => T) => T;
125
119
  /** Trusted synchronous publication only; caller acquires Workspace admission before this config transaction. */
126
120
  withPairingAdmission: <T>(profileName: string, tokenSha256: string, publish: (preApprovalExcluded: boolean) => T) => T;
127
121
  /** Observe an existing exact owner and refresh an unpaired cache; never create an owner. Callback must be synchronous. */
@@ -449,7 +449,6 @@ export function createTelegramConfigStore(options = {}) {
449
449
  nextConfig.allowedUserId = userId;
450
450
  setEffectiveConfig(nextConfig);
451
451
  },
452
- withSourceSerialization: (operation) => withTelegramFileTransaction(`${configPath}.transaction`, operation),
453
452
  withPairingAdmission: (profileName, tokenSha256, publish) => withPersistedPairingProfile(profileName, tokenSha256, (_latest, profile) => publish(profile.allowedUserId === undefined)),
454
453
  withPairedUserAdmission: (profileName, tokenSha256, userId, publish, assertExecutionCurrent) => {
455
454
  if (!Number.isSafeInteger(userId) || userId <= 0)
@@ -142,12 +142,13 @@ export default function (pi) {
142
142
  },
143
143
  getProfileName: configStore.getActiveProfileName,
144
144
  });
145
+ const withTelegramJournalSourceSerialization = Journal.createTelegramJournalSourceSerialization();
145
146
  const telegramJournalBindingRuntime = Journal.createTelegramUpdateJournalBindingRuntime({
146
147
  base: {
147
148
  getProfileName: configStore.getActiveProfileName,
148
149
  getBotToken: configStore.getBotToken,
149
150
  getBotId: getTelegramBotId,
150
- withSourceSerialization: configStore.withSourceSerialization,
151
+ withSourceSerialization: withTelegramJournalSourceSerialization,
151
152
  onRecovery(event) {
152
153
  recordRuntimeEvent("recovery", event.kind === "repaired"
153
154
  ? "Telegram update journal was repaired automatically."
@@ -199,6 +200,10 @@ export default function (pi) {
199
200
  return telegramLeaderJournalPath.createJournalPath();
200
201
  },
201
202
  });
203
+ const proveTelegramLeaderUnresponsive = Bus.createTelegramBusLeaderUnresponsivenessProof({
204
+ getLeaderState: lockRuntime.getState,
205
+ getLeaderSocketPath: getTelegramBusSocketPath,
206
+ });
202
207
  const telegramSessionContextStore = Lifecycle.createTelegramSessionContextStore({
203
208
  getIdentity(ctx) {
204
209
  return ctx.sessionManager ?? ctx.cwd;
@@ -1069,7 +1074,7 @@ export default function (pi) {
1069
1074
  botToken,
1070
1075
  botId: getTelegramBotId(),
1071
1076
  });
1072
- return configStore.withSourceSerialization(function () {
1077
+ return withTelegramJournalSourceSerialization(function () {
1073
1078
  return Journal.isTelegramThreadCleanupJournalNamespaceClear({
1074
1079
  directory: Paths.resolveTelegramTempDir(),
1075
1080
  profile,
@@ -1554,6 +1559,11 @@ export default function (pi) {
1554
1559
  },
1555
1560
  recovery: {
1556
1561
  getLeaderState: lockRuntime.getState,
1562
+ proveLeaderUnresponsive: proveTelegramLeaderUnresponsive,
1563
+ async isThreadModeDisabled() {
1564
+ await threadStore.refresh?.();
1565
+ return threadStore.getBotState().threadMode === "disabled";
1566
+ },
1557
1567
  setLifecyclePhase: telegramBusFollowerControlState.setLifecyclePhase,
1558
1568
  updateStatus,
1559
1569
  promoteToLeader: promoteTelegramBusFollowerToLeader,
@@ -1860,6 +1870,7 @@ export default function (pi) {
1860
1870
  const threadAwarePollingPorts = telegramThreadCapabilityRuntime.pollingPorts;
1861
1871
  const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
1862
1872
  lock: lockRuntime,
1873
+ proveOwnerUnresponsive: proveTelegramLeaderUnresponsive,
1863
1874
  resetDamagedState() {
1864
1875
  return Locks.resetDamagedTelegramRuntimeState(Paths.resolveTelegramStatePath(), function (profile, sections) {
1865
1876
  Threads.parseTelegramWorkspaceStateSection(sections.workspace, profile);
@@ -22,6 +22,11 @@ parentPort.on("message", (message) => {
22
22
  });
23
23
 
24
24
  function runBoundedProcess(input) {
25
+ // After an abort the parent may terminate this worker before execFile's own abort kill runs, which
26
+ // would orphan a freshly spawned child; never start a process once cancelled.
27
+ if (controller.signal.aborted) {
28
+ return Promise.reject(new Error("Generative App method was cancelled."));
29
+ }
25
30
  if (!input || typeof input !== "object") {
26
31
  throw new Error("Generative App run input must be an object.");
27
32
  }
@@ -860,6 +860,14 @@ export declare function createTelegramUpdateJournalBindingRuntime(deps: {
860
860
  getActiveFollowerSessionId?: () => string | undefined;
861
861
  isFollowerRegistered: () => boolean;
862
862
  }): TelegramUpdateJournalBindingRuntime;
863
+ /**
864
+ * Journal-owned cross-family source serializer, lock-only and never authorization. Its guard lives
865
+ * in the runtime service directory, so journal work never touches `telegram.json` or its
866
+ * transaction. Acquire Workspace admission and any sender (config) admission first; never acquire
867
+ * owners inside it. Callbacks must be synchronous: a returned promise is unprotected after its
868
+ * synchronous prefix.
869
+ */
870
+ export declare function createTelegramJournalSourceSerialization(getTransactionPath?: () => string): <T>(operation: () => T) => T;
863
871
  export declare function createTelegramUpdateJournalStore(options: TelegramUpdateJournalStoreOptions): TelegramUpdateJournalStore;
864
872
  /** Opt-in v3 only; does not migrate old files or expose legacy unowned mutation ports. */
865
873
  export declare function createTelegramInputJournalStore(options: TelegramInputJournalStoreOptions): TelegramInputJournalStore;