@llblab/pi-telegram 0.51.5 → 0.51.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BACKLOG.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  _This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
4
4
 
5
+ - [ ] `Connect intent across resume` (`post-release acceptance`): The operator approved 0.51.6 release on the successful smoke and green Linux/macOS/Windows CI; this expanded live matrix is not a release blocker. Run live acceptance after rebuilding/reloading: start bare Pi, invoke `/telegram-connect` (default and named profile), immediately `/resume` a session with/without a binding. Check both leader and follower; confirm one connected destination, its correct retained/new slot, no source-target transplant, stale-context error or duplicate notice. Repeat a switch during startup and confirm disconnect cancels it. Native composition, destination-binding, cancellation, and late-completion regressions pass locally. The operator observed one immediate-resume connection complete after a short wait without error; the full profile/role/binding and cancellation matrix remains unverified live.
5
6
  - [ ] [`Workspace Thread recovery`](./docs/multi-instance-bus.md) (`environment-gated`): Complete native-Windows coverage for follower restore, inaccessible callbacks, stale targets, and Singleton↔Threaded transitions while preserving accepted work and current ownership.
6
7
  - [ ] Select an approved exact-absence observation before implementing ambiguous `deletion-issued` recovery. Never replay an unknown deletion or infer absence from cache, heartbeat silence, empty `editForumTopic`, or `sendChatAction` success.
7
8
  - [ ] [`Automatic pairing and follower input custody`](./docs/architecture.md#automatic-pairing-confirmation-design) (`activation-gated`): Keep production custody disabled until historical sources and physical references are reconciled, all legacy writers/consumers are retired or excluded under operator authority, migration is complete, and every participating peer supports the final protocol. Preserve outcome-unknown running work, exact receipt/group authority, authenticated handoff, and rollback/downgrade safety. Then compose the prepared v3 lifecycle/bus ports, add bounded operator disposition for retained legacy retry state, and run real-process plus native-Windows acceptance.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.51.6: Connection resume and Workspace recovery hotfix
8
+
9
+ - `Workspace slot recovery`: Confirmed pressure-retirement deletion invalidates the exact stale active-target record before binding removal. Same-process and successor retries finish a retained `commit-ready` fence without repeating Telegram deletion, preventing exhausted A–Z slots from deadlocking on `protection-changed`. Includes [#305](https://github.com/llblab/pi-telegram/pull/305).
10
+ - `Connect lifecycle`: `/telegram-connect` checks plain session generation before reading Pi context getters after awaited work. Replaced commands cannot publish stale connection notices or retry startup against the old session; config, startup, recovery and takeover confirmation discard obsolete results.
11
+ - `Resume connection`: In-flight `/telegram-connect` or an already connected bridge carries bounded same-process intent into the resumed session, retaining the selected profile but not the source Thread. Startup restores the destination's exact binding or provisions its own slot; cancellation, expiry and late completion cannot revive obsolete work.
12
+ - `Thread binding isolation`: Session-aware startup no longer reuses another session's active target or legacy instance binding. Pending Thread creation records its Workspace binding key; unproven or foreign-session recovery is blocked without consuming evidence or repeating creation.
13
+ - `Connection feedback`: Manual/resumed connection failures use compact cause-and-action notices rather than raw exception guidance. Detailed errors remain in redacted diagnostics; recovery success produces one short notice, and failed disconnect keeps Pi open without a duplicate exception banner. Delayed disconnect completion cannot touch a replaced Pi context.
14
+ - `Model continuation`: In-flight model switching injects one compact line with the resume instruction, selected model and optional thinking level, preserving the control lane and exact reply target.
15
+ - `Lifecycle ownership`: Connect intent and resume scheduling belong to the existing session lifecycle domain; compact failure formatting belongs to status. Transport authority, durable bindings and queue custody keep their existing owners, without introducing a separate connection domain.
16
+
7
17
  ## 0.51.5: Follower Thread new-session hotfix
8
18
 
9
19
  - `Follower Thread /new`: Telegram `/new` now starts a new session in a follower's Pi process and preserves its Thread binding. The leader publishes and later claims the durable replacement intent through capability-gated, generation-fenced bus requests, validating its own live registration and Workspace binding before accepting the follower's request. Incompatible or stale leaders fail closed rather than silently switching sessions.
package/README.md CHANGED
@@ -57,7 +57,7 @@ Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved tok
57
57
  /telegram-connect
58
58
  ```
59
59
 
60
- The connected Pi instance owns Telegram polling. Use `/telegram-connect <profile>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the `default` profile keeps unsuffixed runtime paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers. Reopening or resuming the same Pi session restores its remembered Thread at session startup; a distinct session in the same directory has its own binding and still requires explicit `/telegram-connect` when no remembered binding exists.
60
+ The connected Pi instance owns Telegram polling. Use `/telegram-connect <profile>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the `default` profile keeps unsuffixed runtime paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers. Reopening or resuming the same Pi session restores its remembered Thread at session startup. If `/resume` immediately follows `/telegram-connect`, or the bridge is already connected, connection intent follows the switch: the destination restores its own Thread/slot or receives a new binding, never the source session's Thread. Otherwise, a distinct unbound session still requires explicit `/telegram-connect`.
61
61
 
62
62
  After an unclean computer shutdown, `/telegram-connect` detects truncated or structurally invalid temporary ownership/routing files, quarantines only the damaged files under `tmp/telegram/recovery/`, and retries once. A journal snapshot removed by older broad temp cleanup is rebuilt when its complete segment history proves an empty result, while a revisionless snapshot is repaired from the first surviving segment's exact predecessor when the reconstructed tail validates. Otherwise the snapshot and segments are quarantined as recovery evidence, a fresh journal is published, and startup continues with an informational diagnostic instead of requiring manual JSON repair. Unsupported journal versions block recovery without rewriting or quarantining the retained files; use a compatible runtime rather than deleting journals. Saved `telegram.json` configuration and runtime diagnostics remain intact. Recovery never replaces a verifiable live owner; if safe automatic recovery cannot complete, the command gives one explicit Pi-restart instruction instead of requiring deletion of the whole `tmp/` directory.
63
63
 
@@ -194,9 +194,12 @@ interface TelegramCommandsAndToolsBindingDeps {
194
194
  canSendDirect: () => boolean;
195
195
  setGenerativeAppLiveSurfaceRuntime?: (runtime: GenerativeApps.GenerativeAppLiveSurfaceRuntime<GenerativeApps.TelegramBindLiveHandle> | undefined) => void;
196
196
  updateStatus: TelegramBridgeStatusUpdater;
197
+ isContextCurrent: (ctx: Pi.ExtensionContext) => boolean;
198
+ getSessionGeneration: () => number;
199
+ connectionIntent: NonNullable<Commands.TelegramBridgeCommandRegistrationDeps["connectionIntent"]>;
197
200
  recordRuntimeEvent: TelegramRuntimeEventRecorder;
198
201
  }
199
- export declare function registerTelegramCommandsAndTools({ pi, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, recoverPollingStart, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, }: TelegramCommandsAndToolsBindingDeps): void;
202
+ export declare function registerTelegramCommandsAndTools({ pi, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, recoverPollingStart, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, isContextCurrent, getSessionGeneration, connectionIntent, }: TelegramCommandsAndToolsBindingDeps): void;
200
203
  interface TelegramLifecycleBindingDeps {
201
204
  pi: Pi.ExtensionAPI;
202
205
  publicationRuntime: TelegramBridgePublicationRuntime;
@@ -323,7 +323,7 @@ export function createTelegramActivityBindingRuntime(deps) {
323
323
  },
324
324
  };
325
325
  }
326
- export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, recoverPollingStart, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, }) {
326
+ export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, recoverPollingStart, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, isContextCurrent, getSessionGeneration, connectionIntent, }) {
327
327
  GenerativeApps.registerTelegramBindTool(pi, {
328
328
  agentDir,
329
329
  getActiveProfileName: configStore.getActiveProfileName,
@@ -452,33 +452,35 @@ export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, pe
452
452
  reloadConfig: configStore.load,
453
453
  hasBotToken: configStore.hasBotToken,
454
454
  getBotTokenDiagnostic: configStore.getBotTokenDiagnostic,
455
- startPolling: async (ctx, options) => {
456
- try {
457
- return await lockedPollingRuntime.start(ctx, options);
458
- }
459
- catch (error) {
460
- recordRuntimeEvent("recovery", error, { phase: "polling-start" });
461
- throw error;
462
- }
463
- },
455
+ startPolling: lockedPollingRuntime.start,
456
+ recordConnectionEvent: (error, phase) => recordRuntimeEvent("connection", error, { phase }),
464
457
  stopPolling: stopPolling ?? lockedPollingRuntime.stop,
465
458
  recoverPollingStart,
466
459
  getDisconnectThreadName,
467
460
  queueAgentConnectionContext,
468
461
  updateStatus,
462
+ isContextCurrent,
463
+ getSessionGeneration,
464
+ connectionIntent,
469
465
  getProfileNames: () => Config.getTelegramProfileNames(configStore.getStoredConfig()),
470
- activateDefaultProfileConfig: async () => {
466
+ activateDefaultProfileConfig: async (_ctx, isCurrent) => {
471
467
  const previousProfileName = configStore.getActiveProfileName();
472
468
  await configStore.load();
469
+ if (!isCurrent())
470
+ return;
473
471
  if (previousProfileName) {
474
472
  await (stopPolling ?? lockedPollingRuntime.stop)();
473
+ if (!isCurrent())
474
+ return;
475
475
  }
476
476
  configStore.activateProfile(undefined);
477
477
  await onTransportChanged?.();
478
478
  },
479
- activateProfileConfig: async (_ctx, profileName) => {
479
+ activateProfileConfig: async (_ctx, profileName, isCurrent) => {
480
480
  const previousProfileName = configStore.getActiveProfileName();
481
481
  await configStore.load();
482
+ if (!isCurrent())
483
+ return false;
482
484
  if (!Config.isValidTelegramProfileName(profileName))
483
485
  return false;
484
486
  const storedConfig = configStore.getStoredConfig();
@@ -486,6 +488,8 @@ export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, pe
486
488
  return false;
487
489
  if (previousProfileName !== profileName) {
488
490
  await (stopPolling ?? lockedPollingRuntime.stop)();
491
+ if (!isCurrent())
492
+ return false;
489
493
  }
490
494
  if (!configStore.activateProfile(profileName))
491
495
  return false;
@@ -371,7 +371,7 @@ export declare function createTelegramBusAgentMessageClient(deps: TelegramBusFol
371
371
  routeMessage: (message: TelegramBusAgentMessage) => Promise<void>;
372
372
  };
373
373
  export declare function createTelegramBusFollowerApiCaller(deps: TelegramBusFollowerApiCallerDeps): (method: string, args: unknown[]) => Promise<unknown>;
374
- export declare function createTelegramBusFollowerSessionReplacementSuspender(deps: TelegramBusFollowerSessionReplacementSuspenderDeps): () => Promise<void>;
374
+ export declare function createTelegramBusFollowerSessionReplacementSuspender(deps: TelegramBusFollowerSessionReplacementSuspenderDeps): (preserveTarget?: boolean) => Promise<void>;
375
375
  export declare function createTelegramBusFollowerSessionRefreshHook<TContext>(deps: TelegramBusFollowerSessionRefreshHookDeps<TContext>): (_event: unknown, ctx: TContext) => Promise<void>;
376
376
  export declare function createTelegramBusFollowerControlState(): TelegramBusFollowerControlState;
377
377
  export declare function createTelegramBusFollowerRegistrationState(options?: {
@@ -519,7 +519,13 @@ function isTelegramStaleContextError(error) {
519
519
  export function createTelegramBusFollowerSessionReplacementSuspender(deps) {
520
520
  const getNowMs = deps.getNowMs ?? Date.now;
521
521
  const getPid = deps.getPid ?? (() => process.pid);
522
- return async () => {
522
+ return async (preserveTarget = true) => {
523
+ if (!preserveTarget) {
524
+ setTelegramFollowerSessionHandoff(undefined);
525
+ Threads.setTelegramLeaderSessionHandoff(undefined);
526
+ await deps.suspendPolling();
527
+ return;
528
+ }
523
529
  const target = deps.registrationState.getTarget();
524
530
  if (deps.registrationState.isRegistered() && target) {
525
531
  setTelegramFollowerSessionHandoff({
@@ -6,7 +6,7 @@
6
6
  import { type TelegramConfigStore } from "./config.ts";
7
7
  import type * as Pi from "./pi.ts";
8
8
  import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
9
- import type { TelegramBridgeStatusLineOptions } from "./status.ts";
9
+ import { type TelegramBridgeStatusLineOptions } from "./status.ts";
10
10
  import type { TelegramSessionReplacementIntent } from "./threads.ts";
11
11
  import { type PendingTelegramControlItem, type TelegramQueueAdmissionReceipt } from "./queue.ts";
12
12
  export interface ParsedTelegramCommand {
@@ -112,12 +112,21 @@ export interface TelegramBridgeCommandRegistrationDeps {
112
112
  startPolling: (ctx: ExtensionCommandContext, options?: TelegramBridgeCommandStartPollingOptions) => void | Promise<void | TelegramBridgeCommandStartPollingResult> | TelegramBridgeCommandStartPollingResult;
113
113
  stopPolling: () => Promise<void | string>;
114
114
  recoverPollingStart?: (error: unknown) => Promise<TelegramPollingStartRecoveryResult>;
115
+ recordConnectionEvent?: (error: unknown, phase: string) => void;
115
116
  getDisconnectThreadName?: () => string | undefined;
116
117
  queueAgentConnectionContext?: (connected: boolean) => void;
117
118
  updateStatus: (ctx: ExtensionCommandContext) => void;
119
+ isContextCurrent?: (ctx: ExtensionCommandContext) => boolean;
120
+ getSessionGeneration?: () => number;
121
+ connectionIntent?: {
122
+ begin(cwd: string, profileName?: string): string;
123
+ finish(id: string): void;
124
+ isActive(id: string): boolean;
125
+ cancel(): void;
126
+ };
118
127
  getProfileNames?: () => string[];
119
- activateDefaultProfileConfig?: (ctx: ExtensionCommandContext) => Promise<void>;
120
- activateProfileConfig?: (ctx: ExtensionCommandContext, profileName: string) => Promise<boolean>;
128
+ activateDefaultProfileConfig?: (ctx: ExtensionCommandContext, isCurrent: () => boolean) => Promise<void>;
129
+ activateProfileConfig?: (ctx: ExtensionCommandContext, profileName: string, isCurrent: () => boolean) => Promise<boolean>;
121
130
  }
122
131
  export type TelegramThreadDisplayNameRenamePort = (target: {
123
132
  chatId: number;
@@ -6,6 +6,7 @@
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { pairTelegramUserIfNeeded, TELEGRAM_DEFAULT_PROFILE_NAME, } from "./config.js";
8
8
  import { escapeHtml } from "./rendering.js";
9
+ import { formatTelegramConnectionFailure } from "./status.js";
9
10
  import { createTelegramControlItemBuilder, createTelegramControlQueueController, createTelegramQueueAdmissionReceipt, } from "./queue.js";
10
11
  const TELEGRAM_EXTENSION_COMMAND_REGISTRY_KEY = "__piTelegramCommandRegistry__";
11
12
  const TELEGRAM_BOT_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
@@ -270,121 +271,174 @@ export function registerTelegramBridgeCommands(pi, deps) {
270
271
  pi.registerCommand("telegram-connect", {
271
272
  description: "<profile> — Start Telegram bridge",
272
273
  handler: async (args, ctx) => {
274
+ const sessionGeneration = deps.getSessionGeneration?.();
275
+ let intentId;
276
+ // Pi context getters throw after replacement; check plain intent/generation first.
277
+ const isCurrent = () => (!intentId || deps.connectionIntent?.isActive(intentId) !== false) &&
278
+ (sessionGeneration === undefined || deps.getSessionGeneration?.() === sessionGeneration) &&
279
+ deps.isContextCurrent?.(ctx) !== false;
280
+ if (!isCurrent())
281
+ return;
273
282
  if (args.trim().split(/\s+/).some((word) => /^as=/i.test(word))) {
274
283
  ctx.ui.notify("Thread names are configured from Telegram, not from Pi commands.", "warning");
275
284
  deps.updateStatus(ctx);
276
285
  return;
277
286
  }
278
287
  const profileName = parseTelegramProfileArg(args);
279
- if (profileName && deps.activateProfileConfig) {
280
- const ok = await deps.activateProfileConfig(ctx, profileName);
281
- if (!ok) {
282
- ctx.ui.notify(`Profile "${profileName}" not found.`, "error");
283
- deps.updateStatus(ctx);
284
- return;
285
- }
286
- ctx.ui.notify(`Activated profile "${profileName}".`, "info");
287
- }
288
- else {
289
- await (deps.activateDefaultProfileConfig?.(ctx) ?? deps.reloadConfig());
290
- }
291
- if (!deps.hasBotToken()) {
292
- const botTokenDiagnostic = deps.getBotTokenDiagnostic?.();
293
- if (botTokenDiagnostic)
294
- ctx.ui.notify(botTokenDiagnostic, "error");
295
- const profileNames = deps.getProfileNames?.() ?? [];
296
- if (!profileName && profileNames.length > 0) {
297
- ctx.ui.notify(`No default Telegram profile configured. Available profiles: ${profileNames.join(", ")}. Use /telegram-connect <profileName> or /telegram-setup to create a default profile.`, "info");
298
- deps.updateStatus(ctx);
299
- return;
288
+ intentId = deps.connectionIntent?.begin(ctx.cwd, profileName);
289
+ try {
290
+ if (profileName && deps.activateProfileConfig) {
291
+ const ok = await deps.activateProfileConfig(ctx, profileName, isCurrent);
292
+ if (!isCurrent())
293
+ return;
294
+ if (!ok) {
295
+ ctx.ui.notify(`Profile "${profileName}" not found.`, "error");
296
+ deps.updateStatus(ctx);
297
+ return;
298
+ }
299
+ ctx.ui.notify(`Activated profile "${profileName}".`, "info");
300
300
  }
301
- await deps.promptForConfig(ctx, profileName);
302
- return;
303
- }
304
- let recoveryUsed = false;
305
- const startWithRecovery = async (options) => {
306
- try {
307
- return await deps.startPolling(ctx, options);
301
+ else {
302
+ await (deps.activateDefaultProfileConfig?.(ctx, isCurrent) ?? deps.reloadConfig());
303
+ if (!isCurrent())
304
+ return;
308
305
  }
309
- catch (error) {
310
- if (!deps.recoverPollingStart || recoveryUsed)
311
- throw error;
312
- const recovery = await deps.recoverPollingStart(error);
313
- if (recovery.kind === "unhandled")
314
- throw error;
315
- if (recovery.kind === "blocked") {
316
- return { ok: false, message: recovery.message };
306
+ if (!deps.hasBotToken()) {
307
+ const botTokenDiagnostic = deps.getBotTokenDiagnostic?.();
308
+ if (botTokenDiagnostic)
309
+ ctx.ui.notify(botTokenDiagnostic, "error");
310
+ const profileNames = deps.getProfileNames?.() ?? [];
311
+ if (!profileName && profileNames.length > 0) {
312
+ ctx.ui.notify(`No default Telegram profile configured. Available profiles: ${profileNames.join(", ")}. Use /telegram-connect <profileName> or /telegram-setup to create a default profile.`, "info");
313
+ deps.updateStatus(ctx);
314
+ return;
317
315
  }
318
- recoveryUsed = true;
316
+ await deps.promptForConfig(ctx, profileName);
317
+ return;
318
+ }
319
+ let recoveryUsed = false;
320
+ const startWithRecovery = async (options) => {
319
321
  try {
320
- const retry = await deps.startPolling(ctx, options);
321
- if (!retry) {
322
- return { ok: true, message: recovery.message };
322
+ return await deps.startPolling(ctx, options);
323
+ }
324
+ catch (error) {
325
+ if (!isCurrent())
326
+ return;
327
+ if (!deps.recoverPollingStart || recoveryUsed)
328
+ throw error;
329
+ deps.recordConnectionEvent?.(error, "polling-start");
330
+ const recovery = await deps.recoverPollingStart(error);
331
+ if (!isCurrent())
332
+ return;
333
+ if (recovery.kind === "unhandled")
334
+ throw error;
335
+ if (recovery.kind === "blocked") {
336
+ return { ok: false, message: recovery.message,
337
+ notice: "Telegram recovery blocked. Check /telegram-status --debug." };
338
+ }
339
+ recoveryUsed = true;
340
+ deps.recordConnectionEvent?.(recovery.message, "recovery");
341
+ try {
342
+ const retry = await deps.startPolling(ctx, options);
343
+ if (!isCurrent())
344
+ return;
345
+ if (!retry)
346
+ return { ok: true, message: "Telegram bridge connected; temporary state recovered." };
347
+ return {
348
+ ...retry,
349
+ message: retry.ok
350
+ ? "Telegram bridge connected; temporary state recovered."
351
+ : retry.message,
352
+ };
353
+ }
354
+ catch (error) {
355
+ if (!isCurrent())
356
+ return;
357
+ deps.recordConnectionEvent?.(error, "recovery-retry");
358
+ return {
359
+ ok: false,
360
+ notice: "Telegram recovery failed. Restart this Pi instance.",
361
+ };
323
362
  }
324
- return {
325
- ...retry,
326
- message: retry.ok
327
- ? `${recovery.message} ${retry.message ?? "Telegram bridge connected."}`
328
- : retry.message,
329
- };
330
363
  }
331
- catch {
332
- return {
333
- ok: false,
334
- message: "Telegram temporary state was recovered, but the bridge could not restart. Restart this Pi instance and run /telegram-connect again.",
335
- };
364
+ };
365
+ let result = await startWithRecovery({ forceFreshLeaderThread: true });
366
+ if (!isCurrent())
367
+ return;
368
+ if (result && !result.ok && result.canTakeover) {
369
+ const confirmed = await ctx.ui.confirm(formatTelegramTakeoverTitle(ctx), formatTelegramTakeoverPrompt(ctx, result.owner));
370
+ if (!isCurrent())
371
+ return;
372
+ if (!confirmed) {
373
+ ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
374
+ deps.updateStatus(ctx);
375
+ return;
336
376
  }
377
+ result = await startWithRecovery({ force: true, forceFreshLeaderThread: true });
378
+ if (!isCurrent())
379
+ return;
337
380
  }
338
- };
339
- let result = await startWithRecovery({
340
- forceFreshLeaderThread: true,
341
- });
342
- if (result && !result.ok && result.canTakeover) {
343
- const confirmed = await ctx.ui.confirm(formatTelegramTakeoverTitle(ctx), formatTelegramTakeoverPrompt(ctx, result.owner));
344
- if (!confirmed) {
345
- ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
346
- deps.updateStatus(ctx);
347
- return;
381
+ if (result && !result.ok) {
382
+ if (result.message)
383
+ deps.recordConnectionEvent?.(result.message, "connect-refused");
384
+ ctx.ui.notify(result.notice ?? formatTelegramConnectionFailure(result.message), "warning");
348
385
  }
349
- result = await startWithRecovery({
350
- force: true,
351
- forceFreshLeaderThread: true,
352
- });
386
+ else if (result?.message) {
387
+ ctx.ui.notify(result.message, "info");
388
+ }
389
+ if (!result || result.ok)
390
+ deps.queueAgentConnectionContext?.(true);
391
+ deps.updateStatus(ctx);
353
392
  }
354
- if (result?.message) {
355
- ctx.ui.notify(result.message, result.ok ? "info" : "warning");
393
+ catch (error) {
394
+ if (!isCurrent())
395
+ return;
396
+ deps.recordConnectionEvent?.(error, "connect");
397
+ ctx.ui.notify(formatTelegramConnectionFailure(error), "warning");
398
+ deps.updateStatus(ctx);
356
399
  }
357
- if (!result || result.ok) {
358
- deps.queueAgentConnectionContext?.(true);
400
+ finally {
401
+ if (intentId)
402
+ deps.connectionIntent?.finish(intentId);
359
403
  }
360
- deps.updateStatus(ctx);
361
404
  },
362
405
  });
363
406
  pi.registerCommand("telegram-disconnect", {
364
407
  description: "Stop Telegram and delete current thread in Threaded Mode",
365
408
  handler: async (_args, ctx) => {
366
- const threadName = deps.getDisconnectThreadName?.();
367
- if (threadName) {
368
- const confirmed = await ctx.ui.confirm(ctx.ui.theme.fg("accent", "pi-telegram"), `Delete Telegram thread ${ctx.ui.theme.fg("warning", threadName)} and disconnect this Pi session?`);
369
- if (!confirmed) {
370
- ctx.ui.notify("Telegram disconnect cancelled.", "info");
371
- deps.updateStatus(ctx);
372
- return;
373
- }
374
- }
409
+ const generation = deps.getSessionGeneration?.();
410
+ const isCurrent = () => (generation === undefined || deps.getSessionGeneration?.() === generation) &&
411
+ deps.isContextCurrent?.(ctx) !== false;
412
+ if (!isCurrent())
413
+ return;
414
+ deps.connectionIntent?.cancel();
375
415
  try {
416
+ const threadName = deps.getDisconnectThreadName?.();
417
+ if (threadName) {
418
+ const confirmed = await ctx.ui.confirm(ctx.ui.theme.fg("accent", "pi-telegram"), `Delete Telegram thread ${ctx.ui.theme.fg("warning", threadName)} and disconnect this Pi session?`);
419
+ if (!isCurrent())
420
+ return;
421
+ if (!confirmed) {
422
+ ctx.ui.notify("Telegram disconnect cancelled.", "info");
423
+ return;
424
+ }
425
+ }
376
426
  const message = await deps.stopPolling();
427
+ if (!isCurrent())
428
+ return;
377
429
  if (message)
378
430
  ctx.ui.notify(message, "info");
379
431
  deps.queueAgentConnectionContext?.(false);
380
432
  }
381
433
  catch (error) {
382
- const detail = error instanceof Error ? error.message : String(error);
383
- ctx.ui.notify(`Telegram disconnect did not complete: ${detail} Keep this Pi session open, restore leader connectivity, inspect /telegram-status --debug, and retry /telegram-disconnect.`, "warning");
384
- throw error;
434
+ deps.recordConnectionEvent?.(error, "disconnect");
435
+ if (!isCurrent())
436
+ return;
437
+ ctx.ui.notify("Telegram disconnect incomplete; keep Pi open. Check /telegram-status --debug.", "warning");
385
438
  }
386
439
  finally {
387
- deps.updateStatus(ctx);
440
+ if (isCurrent())
441
+ deps.updateStatus(ctx);
388
442
  }
389
443
  },
390
444
  });
@@ -1361,6 +1361,28 @@ export default function (pi) {
1361
1361
  resolveAutomaticThreadCleanupEnabled: configControls.resolveAutomaticThreadCleanupEnabled,
1362
1362
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1363
1363
  });
1364
+ const connectionIntent = Lifecycle.createTelegramConnectionIntentRuntime();
1365
+ const connectionLifecycle = Lifecycle.createTelegramConnectionLifecycle({
1366
+ intent: connectionIntent,
1367
+ getGeneration: telegramSessionContextStore.getGeneration,
1368
+ isCurrent: telegramSessionContextStore.isCurrent,
1369
+ getProfileName: configStore.getActiveProfileName,
1370
+ isConnected() {
1371
+ return lockRuntime.owns() || telegramBusFollowerRegistrationState.isRegistered();
1372
+ },
1373
+ async activateProfile(profileName, isCurrent) {
1374
+ await configStore.load();
1375
+ if (!isCurrent())
1376
+ return false;
1377
+ return configStore.activateProfile(profileName) && configStore.hasBotToken();
1378
+ },
1379
+ start(ctx) {
1380
+ return lockedPollingRuntime.start(ctx);
1381
+ },
1382
+ recordError(error) {
1383
+ recordRuntimeEvent("connection", error, { phase: "resume-connect" });
1384
+ },
1385
+ });
1364
1386
  const telegramBridgeSessionLifecycleDeps = Lifecycle.createTelegramBridgeSessionLifecycleDeps({
1365
1387
  contextStore: telegramSessionContextStore,
1366
1388
  queue: {
@@ -1408,6 +1430,7 @@ export default function (pi) {
1408
1430
  },
1409
1431
  delivery: deliveryLifecycleRuntime,
1410
1432
  polling: lockedPollingRuntime,
1433
+ connection: connectionLifecycle,
1411
1434
  inboundWorker: {
1412
1435
  onSessionShutdown: updateAdmissionRuntimeBinding.onSessionShutdown,
1413
1436
  },
@@ -1536,6 +1559,9 @@ export default function (pi) {
1536
1559
  modelContextAvailabilityRuntime.reconcile();
1537
1560
  },
1538
1561
  getStatusLines,
1562
+ isContextCurrent: telegramSessionContextStore.isCurrent,
1563
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
1564
+ connectionIntent,
1539
1565
  buttonActionStore,
1540
1566
  sendMarkdownReply,
1541
1567
  async sendChannelMarkdownMessage(channel, markdown, options) {
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Telegram lifecycle hook registration helpers
2
+ * Telegram session lifecycle coordination and hook registration
3
3
  * Zones: pi agent lifecycle, telegram session
4
- * Binds prepared Telegram lifecycle runtimes to pi extension lifecycle events
4
+ * Owns context generations, bounded connect intent across resume, and session sequencing.
5
+ * Transport authority, durable bindings, and queue custody remain with their owners.
5
6
  */
6
7
  import * as BusFollower from "./bus-follower.ts";
7
8
  import * as Queue from "./queue.ts";
@@ -50,6 +51,58 @@ export interface TelegramSessionLifecycleHooks {
50
51
  onSessionStart: (event: SessionStartEvent, ctx: ExtensionContext) => Promise<void>;
51
52
  onSessionShutdown: (event: SessionShutdownEvent, ctx: ExtensionContext) => Promise<void>;
52
53
  }
54
+ export interface TelegramConnectionIntent {
55
+ id: string;
56
+ cwd: string;
57
+ profileName?: string;
58
+ }
59
+ interface TelegramConnectionHandoff extends TelegramConnectionIntent {
60
+ pid: number;
61
+ targetSessionFile: string;
62
+ expiresAtMs: number;
63
+ }
64
+ export interface TelegramConnectionHandoffStore {
65
+ pending?: TelegramConnectionHandoff;
66
+ }
67
+ export interface TelegramConnectionLifecycle {
68
+ prepare(event: SessionStartEvent, ctx: ExtensionContext): (() => void) | undefined;
69
+ onSessionShutdown(event: SessionShutdownEvent, ctx: ExtensionContext): void;
70
+ }
71
+ export declare function createTelegramConnectionLifecycle(deps: {
72
+ intent: ReturnType<typeof createTelegramConnectionIntentRuntime>;
73
+ getGeneration(): number;
74
+ isCurrent(ctx: ExtensionContext): boolean;
75
+ getProfileName(): string | undefined;
76
+ isConnected(): boolean;
77
+ activateProfile(profileName: string | undefined, isCurrent: () => boolean): Promise<boolean>;
78
+ start(ctx: ExtensionContext): Promise<{
79
+ ok: boolean;
80
+ message?: string;
81
+ }>;
82
+ recordError(error: unknown): void;
83
+ }): TelegramConnectionLifecycle;
84
+ export declare function createTelegramConnectionIntentRuntime(options?: {
85
+ store?: TelegramConnectionHandoffStore;
86
+ now?: () => number;
87
+ pid?: number;
88
+ }): {
89
+ begin(cwd: string, profileName?: string): string;
90
+ isActive(id: string): boolean;
91
+ finish(id: string): void;
92
+ cancel(): void;
93
+ suspend(input: {
94
+ reason: string;
95
+ cwd: string;
96
+ targetSessionFile?: string;
97
+ connected: boolean;
98
+ profileName?: string;
99
+ }): void;
100
+ resume(input: {
101
+ reason: string;
102
+ cwd: string;
103
+ sessionFile?: string;
104
+ }): TelegramConnectionIntent | undefined;
105
+ };
53
106
  export interface TelegramSessionContextStore<TContext> {
54
107
  get: () => TContext | undefined;
55
108
  getGeneration: () => number;
@@ -62,6 +115,7 @@ export declare function createTelegramSessionContextStore<TContext>(options?: {
62
115
  }): TelegramSessionContextStore<TContext>;
63
116
  export declare function createTelegramSessionGenerationFence(store: TelegramSessionContextStore<ExtensionContext>, hooks: TelegramSessionLifecycleHooks): TelegramSessionLifecycleHooks;
64
117
  export interface TelegramBridgeSessionServiceRuntime {
118
+ connection?: TelegramConnectionLifecycle;
65
119
  resumeGroupedInput(ctx: ExtensionContext): void;
66
120
  suspendGroupedInput(): void;
67
121
  delivery: {
@@ -108,6 +162,7 @@ export interface TelegramBridgeSessionLifecyclePorts<TQueueItem, TModel = unknow
108
162
  };
109
163
  delivery: TelegramBridgeSessionServiceRuntime["delivery"];
110
164
  polling: TelegramBridgeSessionServiceRuntime["polling"];
165
+ connection?: TelegramBridgeSessionServiceRuntime["connection"];
111
166
  inboundWorker: TelegramBridgeSessionServiceRuntime["inboundWorker"];
112
167
  capabilityMonitor: TelegramBridgeSessionServiceRuntime["capabilityMonitor"];
113
168
  queueWatchdog: TelegramBridgeSessionServiceRuntime["queueWatchdog"];