@llblab/pi-telegram 0.51.4 → 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.
Files changed (47) hide show
  1. package/AGENTS.md +1 -1
  2. package/BACKLOG.md +1 -0
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +1 -1
  5. package/dist/lib/bindings.d.ts +4 -1
  6. package/dist/lib/bindings.js +16 -12
  7. package/dist/lib/bus-follower.d.ts +3 -1
  8. package/dist/lib/bus-follower.js +62 -3
  9. package/dist/lib/bus-leader.d.ts +21 -0
  10. package/dist/lib/bus-leader.js +144 -1
  11. package/dist/lib/bus.d.ts +9 -0
  12. package/dist/lib/bus.js +19 -0
  13. package/dist/lib/commands.d.ts +25 -3
  14. package/dist/lib/commands.js +166 -89
  15. package/dist/lib/extension.js +42 -0
  16. package/dist/lib/lifecycle.d.ts +57 -2
  17. package/dist/lib/lifecycle.js +109 -4
  18. package/dist/lib/locks.js +5 -1
  19. package/dist/lib/model.js +2 -4
  20. package/dist/lib/polling.js +6 -2
  21. package/dist/lib/status.d.ts +3 -1
  22. package/dist/lib/status.js +31 -1
  23. package/dist/lib/sync.js +4 -4
  24. package/dist/lib/threads.d.ts +8 -0
  25. package/dist/lib/threads.js +30 -9
  26. package/dist/lib/workspace-retirement.d.ts +2 -2
  27. package/dist/lib/workspace-retirement.js +48 -12
  28. package/dist/package.json +1 -1
  29. package/docs/architecture.md +6 -5
  30. package/docs/multi-instance-bus.md +4 -2
  31. package/docs/public-api.md +3 -3
  32. package/docs/ui-style.md +4 -0
  33. package/lib/bindings.ts +17 -10
  34. package/lib/bus-follower.ts +87 -3
  35. package/lib/bus-leader.ts +197 -0
  36. package/lib/bus.ts +33 -0
  37. package/lib/commands.ts +173 -103
  38. package/lib/extension.ts +40 -0
  39. package/lib/lifecycle.ts +140 -4
  40. package/lib/locks.ts +6 -1
  41. package/lib/model.ts +2 -4
  42. package/lib/polling.ts +10 -2
  43. package/lib/status.ts +30 -1
  44. package/lib/sync.ts +4 -4
  45. package/lib/threads.ts +40 -8
  46. package/lib/workspace-retirement.ts +46 -13
  47. package/package.json +1 -1
package/AGENTS.md CHANGED
@@ -125,7 +125,7 @@ The detailed map is canonical in [`docs/architecture.md`](./docs/architecture.md
125
125
  - Command templates remain compact and shell-free. Use string leaves or ordered `template` arrays; shell operators are not an execution contract. Examples use portable executable placeholders, never machine-local paths.
126
126
  - `telegram_attach` is the canonical file path and `telegram_message` the direct Markdown text/buttons path. Both require current direct or registered-follower authority and must not replace the normal active-turn reply.
127
127
  - Inbound handlers transform text/media before queueing; outbound handlers precede programmatic/provider fallbacks. Public contracts and ordering live in `docs/inbound.md`, `docs/outbound.md`, and `docs/public-api.md`.
128
- - Pi integration uses public hooks and APIs. Telegram `/new` is scheduled against the exact durable update, dispatched only after that update is removed from the journal, and then routed through the single `/telegram-internal` Pi gateway via `pi.sendUserMessage(..., { expandPromptTemplates: true })`; only a runtime-armed typed action may execute, manual invocation reports that the command cannot be run manually, and the handler receives the real `ExtensionCommandContext` before calling `ctx.newSession()`. Before replacement, CAS-publish one exact expiring handoff in the profile target snapshot. `workspace-thread` successors re-key the matching Workspace binding; `classic-chat` successors preserve Profile/CWD/session/chat continuity without creating a binding or invoking topic APIs. Both atomically claim the intent before one terminal result, and the old `withSession` path never publishes the same success. Never replace the session while inbound authority is unsettled, store stale command contexts, accept an expired or mismatched handoff, inject terminal input, spawn a shadow Pi process, or mutate session files.
128
+ - Pi integration uses public hooks and APIs. Telegram `/new` is scheduled against the exact durable update, dispatched only after that update is removed from the journal, and then routed through the single `/telegram-internal` Pi gateway via `pi.sendUserMessage(..., { expandPromptTemplates: true })`; only a runtime-armed typed action may execute, manual invocation reports that the command cannot be run manually, and the handler receives the real `ExtensionCommandContext` before calling `ctx.newSession()`. Before replacement, CAS-publish one exact expiring handoff in the profile target snapshot. `workspace-thread` successors re-key the matching Workspace binding; `classic-chat` successors preserve Profile/CWD/session/chat continuity without creating a binding or invoking topic APIs. Both atomically claim the intent before one terminal result, and the old `withSession` path never publishes the same success. A registered follower cannot persist leader-owned state: its Workspace Thread intent is published and claimed by the leader over capability-gated exact-generation bus RPC after leader-side binding validation, and records its source runtime instance so only that follower lineage may re-key or claim it. Never replace the session while inbound authority is unsettled, store stale command contexts, accept an expired or mismatched handoff, inject terminal input, spawn a shadow Pi process, or mutate session files.
129
129
 
130
130
  ## 7. Engineering Conventions
131
131
 
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,21 @@
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
+
17
+ ## 0.51.5: Follower Thread new-session hotfix
18
+
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.
20
+ - `Follower restore diagnostics`: Session startup now records when automatic follower restore lacks a session binding, Threaded Mode, or leader-side Workspace authority, or finishes without connecting. Restore-only refusal still cannot create a new Thread; `/telegram-connect` remains the explicit recovery path.
21
+
7
22
  ## 0.51.4: Resolver-owned Skills and drift-safe Git installs
8
23
 
9
24
  - `Resolver-owned Skills`: Auto-discovered user/project checkouts now contribute source Skills even when Pi selects their compiled entrypoint, while manifest-loaded npm, Git, and Pi Kit packages retain `pi.skills` filters and package provenance. Resolver regressions cover checkout and filtered-package behavior; unsupported source manifest aliases are removed.
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;
@@ -54,6 +54,8 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
54
54
  threadId: number;
55
55
  }) => Promise<string>;
56
56
  setThreadDisplayMode?: (mode: TelegramThreadDisplayMode) => Promise<void>;
57
+ /** Leader-mediated durable publication or successor claim; true only after exact commit. */
58
+ requestSessionReplacement?: (operation: "publish" | "settle", intent: Threads.TelegramSessionReplacementIntent) => Promise<boolean>;
57
59
  stop: () => void;
58
60
  }
59
61
  export interface TelegramBusFollowerSessionReplacementSuspenderDeps {
@@ -369,7 +371,7 @@ export declare function createTelegramBusAgentMessageClient(deps: TelegramBusFol
369
371
  routeMessage: (message: TelegramBusAgentMessage) => Promise<void>;
370
372
  };
371
373
  export declare function createTelegramBusFollowerApiCaller(deps: TelegramBusFollowerApiCallerDeps): (method: string, args: unknown[]) => Promise<unknown>;
372
- export declare function createTelegramBusFollowerSessionReplacementSuspender(deps: TelegramBusFollowerSessionReplacementSuspenderDeps): () => Promise<void>;
374
+ export declare function createTelegramBusFollowerSessionReplacementSuspender(deps: TelegramBusFollowerSessionReplacementSuspenderDeps): (preserveTarget?: boolean) => Promise<void>;
373
375
  export declare function createTelegramBusFollowerSessionRefreshHook<TContext>(deps: TelegramBusFollowerSessionRefreshHookDeps<TContext>): (_event: unknown, ctx: TContext) => Promise<void>;
374
376
  export declare function createTelegramBusFollowerControlState(): TelegramBusFollowerControlState;
375
377
  export declare function createTelegramBusFollowerRegistrationState(options?: {
@@ -11,7 +11,7 @@ import * as Threads from "./threads.js";
11
11
  import { parseTelegramUpdateJournalQueueOwner } from "./journal.js";
12
12
  import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS, } from "./locks.js";
13
13
  import { isTelegramApiMethodRetrySafe, TelegramApiCommitUnknownError, TelegramApiStaleTargetError, } from "./telegram-api.js";
14
- import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusFollowerTargetController, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, } from "./bus.js";
14
+ import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusFollowerTargetController, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT, } from "./bus.js";
15
15
  import { getTelegramBusTransportRetryPolicy, TELEGRAM_BUS_REGISTRATION_RETRY, } from "./bus-transport.js";
16
16
  import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
17
17
  export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
@@ -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({
@@ -1214,8 +1220,13 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1214
1220
  deps.setActiveAuthSecret?.(undefined);
1215
1221
  await deps.stopReceiving?.();
1216
1222
  if (registrationOptions?.restoreWorkspace &&
1217
- response.error?.code === "workspace-binding-unavailable")
1223
+ response.error?.code === "workspace-binding-unavailable") {
1224
+ deps.recordRuntimeEvent?.("bus", "Telegram follower auto-connect refused by leader: Workspace binding unavailable.", {
1225
+ phase: "follower-auto-connect-skip",
1226
+ reason: "leader-binding-unavailable",
1227
+ });
1218
1228
  return false;
1229
+ }
1219
1230
  throw new Error(response.message ??
1220
1231
  "Telegram bus follower registration was rejected.");
1221
1232
  }
@@ -1426,6 +1437,54 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1426
1437
  }
1427
1438
  return resetName;
1428
1439
  },
1440
+ async requestSessionReplacement(operation, intent) {
1441
+ if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
1442
+ throw new Error("Telegram follower is not registered with the leader.");
1443
+ }
1444
+ if (!hasTelegramBusCapability(deps.protocolIdentity, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT) ||
1445
+ !hasTelegramBusCapability(deps.registrationState?.getLeaderProtocol(), TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT)) {
1446
+ throw new Error("The active Telegram leader does not support follower session replacement. Update or restart that Pi instance.");
1447
+ }
1448
+ const expectedLeaderSocketPath = activeLeaderSocketPath;
1449
+ const expectedRegistrationGeneration = activeRegistrationGeneration;
1450
+ const expectedAuthSecret = activeAuthSecret;
1451
+ const requestId = deps.createRequestId();
1452
+ const response = await sendTelegramBusLocalEnvelope({
1453
+ socketPath: expectedLeaderSocketPath,
1454
+ timeoutMs: registrationTimeoutMs,
1455
+ retry: getTelegramBusTransportRetryPolicy({
1456
+ endpoint: expectedLeaderSocketPath,
1457
+ operation: "operation",
1458
+ }),
1459
+ envelope: {
1460
+ kind: operation === "publish"
1461
+ ? "follower.publishSessionReplacement"
1462
+ : "follower.settleSessionReplacement",
1463
+ requestId,
1464
+ auth: expectedAuthSecret,
1465
+ instanceId: deps.instanceId,
1466
+ registrationGeneration: expectedRegistrationGeneration,
1467
+ intent,
1468
+ sentAtMs: getNowMs(),
1469
+ },
1470
+ });
1471
+ if (response?.kind !== "bus.ack" || !response.ok ||
1472
+ response.requestId !== requestId ||
1473
+ !isRecord(response.result) || response.result.committed !== true) {
1474
+ throw new Error(response?.kind === "bus.ack"
1475
+ ? (response.message ?? "Telegram session replacement was rejected.")
1476
+ : "Telegram session replacement was not acknowledged.");
1477
+ }
1478
+ if (activeLeaderSocketPath !== expectedLeaderSocketPath ||
1479
+ activeRegistrationGeneration !== expectedRegistrationGeneration ||
1480
+ activeAuthSecret !== expectedAuthSecret ||
1481
+ (deps.registrationState &&
1482
+ deps.registrationState.getGeneration() !==
1483
+ expectedRegistrationGeneration)) {
1484
+ throw new Error("Telegram session replacement completed for a stale follower registration.");
1485
+ }
1486
+ return true;
1487
+ },
1429
1488
  async disconnectFromLeader() {
1430
1489
  if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
1431
1490
  return false;
@@ -135,6 +135,23 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
135
135
  runWorkspaceOperation?: TelegramBusWorkspaceAdmissionRunner;
136
136
  workspaceRotation?: TelegramWorkspaceSlotRotationPorts;
137
137
  }
138
+ export type TelegramBusFollowerSessionReplacementOperation = (follower: TelegramBusFollowerView, intent: Threads.TelegramSessionReplacementIntent, isCurrent: () => boolean) => Promise<boolean>;
139
+ /**
140
+ * Leader-owned durable session-replacement authority for registered followers.
141
+ * Followers cannot persist `state.json`; the leader validates the request
142
+ * against its live registry entry and authoritative Workspace binding before
143
+ * one CAS publication or claim. Follower memory is never accepted as binding
144
+ * evidence.
145
+ */
146
+ export declare function createTelegramBusFollowerSessionReplacementAuthority(deps: {
147
+ store: Pick<Threads.TelegramTopicTargetStore, "load" | "getWorkspaceBindingByTarget" | "getSessionReplacementIntent" | "commitSessionReplacementIntent" | "removeSessionReplacementIntent">;
148
+ getTelegramProfile?: () => string | undefined;
149
+ getNowMs?: () => number;
150
+ ttlMs?: number;
151
+ }): {
152
+ publish: TelegramBusFollowerSessionReplacementOperation;
153
+ settle: TelegramBusFollowerSessionReplacementOperation;
154
+ };
138
155
  export declare function createTelegramBusLeaderRuntimeAssembly<TContext>(deps: TelegramBusLeaderRuntimeAssemblyDeps<TContext>): TelegramBusLeaderRuntime<TContext> & {
139
156
  renameLeaderThreadAdmitted: (threadName: string, expectedTarget?: Threads.TelegramTopicTargetRecord["target"]) => Promise<Threads.TelegramTopicTargetRecord>;
140
157
  resetLeaderThreadName: (expectedTarget: Threads.TelegramTopicTargetRecord["target"]) => Promise<{
@@ -188,6 +205,8 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
188
205
  }> | {
189
206
  threadName: string;
190
207
  };
208
+ publishFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
209
+ settleFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
191
210
  getFollowerDisplayTitle?: (follower: TelegramBusFollowerView) => string | undefined;
192
211
  onFollowerRegistered?: () => void;
193
212
  applyThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
@@ -273,6 +292,8 @@ export declare function createTelegramBusLeaderEnvelopeHandler(deps: {
273
292
  }> | {
274
293
  threadName: string;
275
294
  };
295
+ publishFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
296
+ settleFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
276
297
  getFollowerDisplayTitle?: (follower: TelegramBusFollowerView) => string | undefined;
277
298
  onFollowerRegistered?: () => void;
278
299
  applyThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
@@ -9,7 +9,7 @@ import { createTelegramThreadDisplayReconciler, resolveTelegramInitialWorkspaceD
9
9
  import * as ThreadReconciler from "./thread-reconciler.js";
10
10
  import { getTelegramApiErrorRequestTarget, isTelegramApiCommitUnknownError, } from "./telegram-api.js";
11
11
  import * as Threads from "./threads.js";
12
- import { createTelegramBusLocalServer, createUnauthorizedBusAck, getTelegramBusEnvelopeTrafficClass, getTelegramBusProtocolCompatibility, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, getTelegramBusFollowerSocketPath, sendTelegramBusLocalEnvelope, stripTelegramBusApiMetadata, TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF, TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, } from "./bus.js";
12
+ import { createTelegramBusLocalServer, createUnauthorizedBusAck, getTelegramBusEnvelopeTrafficClass, getTelegramBusProtocolCompatibility, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, getTelegramBusFollowerSocketPath, sendTelegramBusLocalEnvelope, stripTelegramBusApiMetadata, TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF, TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT, } from "./bus.js";
13
13
  import { getTelegramBusTransportRetryPolicy } from "./bus-transport.js";
14
14
  import { createTelegramWorkspaceOperationRuntime, createTelegramWorkspaceSlotRotation, } from "./workspace-retirement.js";
15
15
  import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
@@ -27,6 +27,75 @@ function formatTelegramBusInstanceLabel(input) {
27
27
  return escapeHtml(threadName);
28
28
  return input.slot && /^[A-Z]$/.test(input.slot) ? input.slot : "?";
29
29
  }
30
+ /**
31
+ * Leader-owned durable session-replacement authority for registered followers.
32
+ * Followers cannot persist `state.json`; the leader validates the request
33
+ * against its live registry entry and authoritative Workspace binding before
34
+ * one CAS publication or claim. Follower memory is never accepted as binding
35
+ * evidence.
36
+ */
37
+ export function createTelegramBusFollowerSessionReplacementAuthority(deps) {
38
+ const getNowMs = deps.getNowMs ?? Date.now;
39
+ const ttlMs = deps.ttlMs ?? Threads.TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS;
40
+ const assertCommon = (follower, intent) => {
41
+ const cwd = follower.cwd
42
+ ? Threads.normalizeTelegramWorkspacePath(follower.cwd)
43
+ : undefined;
44
+ const sessionId = follower.sessionId
45
+ ? Threads.normalizeTelegramSessionId(follower.sessionId)
46
+ : undefined;
47
+ const nowMs = getNowMs();
48
+ if (intent.continuity !== "workspace-thread" ||
49
+ typeof intent.target.threadId !== "number" ||
50
+ follower.target?.chatId !== intent.target.chatId ||
51
+ follower.target.threadId !== intent.target.threadId ||
52
+ !cwd || cwd !== intent.cwd || !sessionId ||
53
+ intent.profileName !== (deps.getTelegramProfile?.() ?? "default")) {
54
+ throw new Error("Telegram session replacement does not match the current follower registration.");
55
+ }
56
+ if (intent.expiresAtMs <= nowMs || intent.expiresAtMs > nowMs + ttlMs) {
57
+ throw new Error("Telegram session replacement intent is expired or unbounded.");
58
+ }
59
+ return { cwd, sessionId };
60
+ };
61
+ return {
62
+ async publish(follower, intent, isCurrent) {
63
+ const { sessionId } = assertCommon(follower, intent);
64
+ if (intent.sourceInstanceId !== follower.instanceId ||
65
+ intent.sourceSessionId !== sessionId) {
66
+ throw new Error("Telegram session replacement source does not match the current follower registration.");
67
+ }
68
+ await deps.store.load();
69
+ if (!isCurrent())
70
+ return false;
71
+ const binding = deps.store.getWorkspaceBindingByTarget(intent.target);
72
+ if (!binding || binding.cwd !== intent.cwd ||
73
+ binding.sessionId !== intent.sourceSessionId ||
74
+ binding.slot !== intent.slot ||
75
+ (binding.manualThreadName ?? binding.threadName) !== intent.threadName) {
76
+ throw new Error("Telegram session replacement binding is unavailable.");
77
+ }
78
+ return deps.store.commitSessionReplacementIntent(intent, isCurrent);
79
+ },
80
+ async settle(follower, intent, isCurrent) {
81
+ const { sessionId } = assertCommon(follower, intent);
82
+ if (!intent.sourceInstanceId ||
83
+ (intent.sourceInstanceId !== follower.instanceId &&
84
+ intent.sourceInstanceId !== follower.previousInstanceId) ||
85
+ intent.sourceSessionId === sessionId) {
86
+ throw new Error("Telegram session replacement successor does not match the current follower registration.");
87
+ }
88
+ await deps.store.load();
89
+ if (!isCurrent())
90
+ return false;
91
+ if (deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd !==
92
+ intent.cwd) {
93
+ throw new Error("Telegram session replacement successor binding is unavailable.");
94
+ }
95
+ return deps.store.removeSessionReplacementIntent(intent, isCurrent);
96
+ },
97
+ };
98
+ }
30
99
  export function createTelegramBusLeaderRuntimeAssembly(deps) {
31
100
  const runWorkspaceAdmission = deps.getWorkspaceAdmission
32
101
  ? (input, operation) => {
@@ -327,6 +396,11 @@ export function createTelegramBusLeaderRuntimeAssembly(deps) {
327
396
  }
328
397
  });
329
398
  });
399
+ const followerSessionReplacement = createTelegramBusFollowerSessionReplacementAuthority({
400
+ store: deps.topicTargetStore,
401
+ getTelegramProfile: deps.getTelegramProfile,
402
+ getNowMs: deps.runtime.getNowMs,
403
+ });
330
404
  const runtime = createTelegramBusLeaderRuntime({
331
405
  ...deps.runtime,
332
406
  applyThreadDisplayMode,
@@ -427,6 +501,16 @@ export function createTelegramBusLeaderRuntimeAssembly(deps) {
427
501
  }
428
502
  });
429
503
  }),
504
+ publishFollowerSessionReplacement: (follower, intent, isCurrent) => runWorkspaceOperation({
505
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
506
+ operationKind: "workspace.publish-follower-session-replacement",
507
+ scopes: [{ kind: "profile" }],
508
+ }, () => followerSessionReplacement.publish(follower, intent, isCurrent)),
509
+ settleFollowerSessionReplacement: (follower, intent, isCurrent) => runWorkspaceOperation({
510
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
511
+ operationKind: "workspace.settle-follower-session-replacement",
512
+ scopes: [{ kind: "profile" }],
513
+ }, () => followerSessionReplacement.settle(follower, intent, isCurrent)),
430
514
  onFollowerConfirmedDead: async (follower) => {
431
515
  await runWorkspaceOperation({
432
516
  operationId: createTelegramWorkspaceAdmissionOperationId(),
@@ -1796,6 +1880,63 @@ export function createTelegramBusLeaderEnvelopeHandler(deps) {
1796
1880
  }
1797
1881
  });
1798
1882
  }
1883
+ case "follower.publishSessionReplacement":
1884
+ case "follower.settleSessionReplacement": {
1885
+ const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
1886
+ return runFollowerMutation(registeredFollower ?? { instanceId: envelope.instanceId }, async () => {
1887
+ const publish = envelope.kind === "follower.publishSessionReplacement";
1888
+ const operation = publish
1889
+ ? deps.publishFollowerSessionReplacement
1890
+ : deps.settleFollowerSessionReplacement;
1891
+ const epoch = deps.getCurrentLeaderEpoch?.();
1892
+ const follower = deps.followerRegistry.get(envelope.instanceId);
1893
+ const isCurrent = () => epoch !== undefined &&
1894
+ deps.getCurrentLeaderEpoch?.() === epoch &&
1895
+ deps.followerRegistry.get(envelope.instanceId)?.registrationGeneration ===
1896
+ envelope.registrationGeneration;
1897
+ if (!follower?.registrationGeneration ||
1898
+ follower.registrationGeneration !== envelope.registrationGeneration ||
1899
+ !isCurrent() || !operation ||
1900
+ !hasTelegramBusCapability(deps.protocolIdentity, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT) ||
1901
+ !hasTelegramBusCapability(follower.protocol, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT)) {
1902
+ return {
1903
+ kind: "bus.ack",
1904
+ requestId: envelope.requestId,
1905
+ ok: false,
1906
+ message: "Telegram session replacement requires current follower registration and compatible leader authority.",
1907
+ };
1908
+ }
1909
+ try {
1910
+ const committed = await operation(follower, envelope.intent, isCurrent);
1911
+ if (!committed || !isCurrent()) {
1912
+ return {
1913
+ kind: "bus.ack",
1914
+ requestId: envelope.requestId,
1915
+ ok: false,
1916
+ message: publish
1917
+ ? "Telegram session replacement intent was not persisted."
1918
+ : "Telegram session replacement intent was not claimed.",
1919
+ };
1920
+ }
1921
+ return {
1922
+ kind: "bus.ack",
1923
+ requestId: envelope.requestId,
1924
+ ok: true,
1925
+ result: { committed: true },
1926
+ };
1927
+ }
1928
+ catch (error) {
1929
+ return {
1930
+ kind: "bus.ack",
1931
+ requestId: envelope.requestId,
1932
+ ok: false,
1933
+ message: error instanceof Error
1934
+ ? error.message
1935
+ : "Telegram session replacement request failed.",
1936
+ };
1937
+ }
1938
+ });
1939
+ }
1799
1940
  case "follower.disconnect": {
1800
1941
  const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
1801
1942
  return runFollowerMutation(registeredFollower ?? { instanceId: envelope.instanceId }, async () => {
@@ -2303,6 +2444,8 @@ export function createTelegramBusLeaderRuntime(deps) {
2303
2444
  onFollowerDisconnected: deps.onFollowerDisconnected,
2304
2445
  renameFollowerThread: deps.renameFollowerThread,
2305
2446
  resetFollowerThreadName: deps.resetFollowerThreadName,
2447
+ publishFollowerSessionReplacement: deps.publishFollowerSessionReplacement,
2448
+ settleFollowerSessionReplacement: deps.settleFollowerSessionReplacement,
2306
2449
  getFollowerDisplayTitle: deps.getFollowerDisplayTitle,
2307
2450
  onFollowerRegistered: deps.onFollowerRegistered,
2308
2451
  applyThreadDisplayMode: deps.applyThreadDisplayMode,
package/dist/lib/bus.d.ts CHANGED
@@ -8,6 +8,7 @@ import { type TelegramBusTransportEventRecorder, type TelegramBusTransportRetryP
8
8
  import { type TelegramQueueHandoffPayload } from "./queue.ts";
9
9
  import type { TelegramTarget } from "./target.ts";
10
10
  import type { TelegramThreadDisplayMode } from "./config.ts";
11
+ import { type TelegramSessionReplacementIntent } from "./threads.ts";
11
12
  export interface TelegramBusProcessRuntime {
12
13
  instanceId: string;
13
14
  processId: number;
@@ -60,6 +61,7 @@ export declare const TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME: "workspace
60
61
  export declare const TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE: "thread-display-mode-v1";
61
62
  export declare const TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT: "directory-display-format-v1";
62
63
  export declare const TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT: "workspace-follower-auto-connect-v1";
64
+ export declare const TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT: "session-replacement-intent-v1";
63
65
  export interface TelegramBusProtocolIdentity {
64
66
  protocolVersion: number;
65
67
  runtimeBuild: string;
@@ -249,6 +251,13 @@ export type TelegramBusEnvelope = ({
249
251
  threadId: number;
250
252
  };
251
253
  sentAtMs: number;
254
+ } | {
255
+ kind: "follower.publishSessionReplacement" | "follower.settleSessionReplacement";
256
+ requestId: string;
257
+ instanceId: string;
258
+ registrationGeneration: string;
259
+ intent: TelegramSessionReplacementIntent;
260
+ sentAtMs: number;
252
261
  } | {
253
262
  kind: "leader.forwardCallback";
254
263
  requestId: string;
package/dist/lib/bus.js CHANGED
@@ -15,6 +15,7 @@ import { classifyTelegramBusTransportError, createTelegramBusTransportTimeoutErr
15
15
  import { TELEGRAM_QUEUE_HANDOFF_MAX_RECEIPTS, TELEGRAM_QUEUE_HANDOFF_PAYLOAD_MAX_BYTES, } from "./queue.js";
16
16
  import { isProcessAlive } from "./locks.js";
17
17
  import { resolveAgentDir } from "./paths.js";
18
+ import { normalizeTelegramSessionReplacementIntent, } from "./threads.js";
18
19
  function readDarwinProcessStart(pid) {
19
20
  return execFileSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], {
20
21
  encoding: "utf8",
@@ -124,6 +125,7 @@ export const TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME = "workspace-thread
124
125
  export const TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE = "thread-display-mode-v1";
125
126
  export const TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT = "directory-display-format-v1";
126
127
  export const TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT = "workspace-follower-auto-connect-v1";
128
+ export const TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT = "session-replacement-intent-v1";
127
129
  export function createTelegramBusProtocolIdentity(input) {
128
130
  const runtimeBuild = input.runtimeBuild.trim();
129
131
  if (!runtimeBuild || runtimeBuild.length > 128) {
@@ -505,6 +507,23 @@ export function parseTelegramBusEnvelope(line) {
505
507
  }
506
508
  break;
507
509
  }
510
+ case "follower.publishSessionReplacement":
511
+ case "follower.settleSessionReplacement": {
512
+ const intent = normalizeTelegramSessionReplacementIntent(value.intent);
513
+ if (typeof value.instanceId === "string" &&
514
+ typeof value.registrationGeneration === "string" &&
515
+ typeof value.sentAtMs === "number" && intent) {
516
+ envelope = {
517
+ kind,
518
+ requestId,
519
+ instanceId: value.instanceId,
520
+ registrationGeneration: value.registrationGeneration,
521
+ intent,
522
+ sentAtMs: value.sentAtMs,
523
+ };
524
+ }
525
+ break;
526
+ }
508
527
  case "leader.forwardCallback":
509
528
  envelope = parseForwardCallbackEnvelope(value, requestId);
510
529
  break;
@@ -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;
@@ -645,6 +654,19 @@ export interface TelegramSessionActionAssemblyDeps {
645
654
  };
646
655
  getProfileName: () => string | undefined;
647
656
  ownsPersistence: () => boolean;
657
+ /**
658
+ * Registered-follower port. A follower cannot persist leader-owned state, so
659
+ * its Workspace Thread intent is published and claimed by the leader over
660
+ * authenticated generation-fenced bus RPC.
661
+ */
662
+ follower?: {
663
+ instanceId: string;
664
+ isRegisteredFor: (target: {
665
+ chatId: number;
666
+ threadId?: number;
667
+ }) => boolean;
668
+ requestSessionReplacement: (operation: "publish" | "settle", intent: TelegramSessionReplacementIntent) => Promise<boolean>;
669
+ };
648
670
  sendResult: (target: {
649
671
  chatId: number;
650
672
  threadId?: number;