@llblab/pi-kit 0.23.2 → 0.24.1

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 (49) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +2 -2
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -3
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +12 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +1 -1
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +50 -1
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +248 -5
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +36 -27
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +1 -1
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +3 -2
  12. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  13. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +3 -1
  14. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +6 -2
  15. package/node_modules/@llblab/pi-state-flow/docs/performance.md +48 -2
  16. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -3
  17. package/node_modules/@llblab/pi-state-flow/lib/context.ts +223 -6
  18. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +37 -32
  19. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +4 -2
  20. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  21. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +3 -1
  22. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  23. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
  24. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +2 -0
  25. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +55 -2
  26. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +144 -1
  28. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +9 -0
  29. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +19 -0
  30. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +13 -0
  31. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +29 -6
  32. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +16 -0
  33. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +5 -1
  34. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +6 -2
  35. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +7 -0
  36. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +13 -5
  37. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  38. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -2
  39. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  40. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +79 -1
  41. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +197 -0
  42. package/node_modules/@llblab/pi-telegram/lib/bus.ts +33 -0
  43. package/node_modules/@llblab/pi-telegram/lib/commands.ts +38 -6
  44. package/node_modules/@llblab/pi-telegram/lib/extension.ts +15 -0
  45. package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
  46. package/node_modules/@llblab/pi-telegram/lib/polling.ts +10 -2
  47. package/node_modules/@llblab/pi-telegram/lib/threads.ts +19 -5
  48. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  49. package/package.json +3 -3
@@ -48,6 +48,7 @@ import {
48
48
  TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
49
49
  TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
50
50
  TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
51
+ TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
51
52
  } from "./bus.ts";
52
53
  import type { TelegramConfigStore, TelegramThreadDisplayMode } from "./config.ts";
53
54
  import {
@@ -142,6 +143,11 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
142
143
  target: TelegramTarget & { threadId: number },
143
144
  ) => Promise<string>;
144
145
  setThreadDisplayMode?: (mode: TelegramThreadDisplayMode) => Promise<void>;
146
+ /** Leader-mediated durable publication or successor claim; true only after exact commit. */
147
+ requestSessionReplacement?: (
148
+ operation: "publish" | "settle",
149
+ intent: Threads.TelegramSessionReplacementIntent,
150
+ ) => Promise<boolean>;
145
151
  stop: () => void;
146
152
  }
147
153
 
@@ -1919,7 +1925,13 @@ export function createTelegramBusFollowerRegistrationRuntime<
1919
1925
  if (
1920
1926
  registrationOptions?.restoreWorkspace &&
1921
1927
  response.error?.code === "workspace-binding-unavailable"
1922
- ) return false;
1928
+ ) {
1929
+ deps.recordRuntimeEvent?.("bus", "Telegram follower auto-connect refused by leader: Workspace binding unavailable.", {
1930
+ phase: "follower-auto-connect-skip",
1931
+ reason: "leader-binding-unavailable",
1932
+ });
1933
+ return false;
1934
+ }
1923
1935
  throw new Error(
1924
1936
  response.message ??
1925
1937
  "Telegram bus follower registration was rejected.",
@@ -2144,6 +2156,72 @@ export function createTelegramBusFollowerRegistrationRuntime<
2144
2156
  }
2145
2157
  return resetName;
2146
2158
  },
2159
+ async requestSessionReplacement(operation, intent) {
2160
+ if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
2161
+ throw new Error("Telegram follower is not registered with the leader.");
2162
+ }
2163
+ if (
2164
+ !hasTelegramBusCapability(
2165
+ deps.protocolIdentity,
2166
+ TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
2167
+ ) ||
2168
+ !hasTelegramBusCapability(
2169
+ deps.registrationState?.getLeaderProtocol(),
2170
+ TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
2171
+ )
2172
+ ) {
2173
+ throw new Error(
2174
+ "The active Telegram leader does not support follower session replacement. Update or restart that Pi instance.",
2175
+ );
2176
+ }
2177
+ const expectedLeaderSocketPath = activeLeaderSocketPath;
2178
+ const expectedRegistrationGeneration = activeRegistrationGeneration;
2179
+ const expectedAuthSecret = activeAuthSecret;
2180
+ const requestId = deps.createRequestId();
2181
+ const response = await sendTelegramBusLocalEnvelope({
2182
+ socketPath: expectedLeaderSocketPath,
2183
+ timeoutMs: registrationTimeoutMs,
2184
+ retry: getTelegramBusTransportRetryPolicy({
2185
+ endpoint: expectedLeaderSocketPath,
2186
+ operation: "operation",
2187
+ }),
2188
+ envelope: {
2189
+ kind: operation === "publish"
2190
+ ? "follower.publishSessionReplacement"
2191
+ : "follower.settleSessionReplacement",
2192
+ requestId,
2193
+ auth: expectedAuthSecret,
2194
+ instanceId: deps.instanceId,
2195
+ registrationGeneration: expectedRegistrationGeneration,
2196
+ intent,
2197
+ sentAtMs: getNowMs(),
2198
+ },
2199
+ });
2200
+ if (
2201
+ response?.kind !== "bus.ack" || !response.ok ||
2202
+ response.requestId !== requestId ||
2203
+ !isRecord(response.result) || response.result.committed !== true
2204
+ ) {
2205
+ throw new Error(
2206
+ response?.kind === "bus.ack"
2207
+ ? (response.message ?? "Telegram session replacement was rejected.")
2208
+ : "Telegram session replacement was not acknowledged.",
2209
+ );
2210
+ }
2211
+ if (
2212
+ activeLeaderSocketPath !== expectedLeaderSocketPath ||
2213
+ activeRegistrationGeneration !== expectedRegistrationGeneration ||
2214
+ activeAuthSecret !== expectedAuthSecret ||
2215
+ (deps.registrationState &&
2216
+ deps.registrationState.getGeneration() !==
2217
+ expectedRegistrationGeneration)
2218
+ ) {
2219
+ throw new Error(
2220
+ "Telegram session replacement completed for a stale follower registration.",
2221
+ );
2222
+ }
2223
+ return true;
2224
+ },
2147
2225
  async disconnectFromLeader() {
2148
2226
  if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
2149
2227
  return false;
@@ -42,6 +42,7 @@ import {
42
42
  TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
43
43
  TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
44
44
  TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
45
+ TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
45
46
  } from "./bus.ts";
46
47
  import { getTelegramBusTransportRetryPolicy } from "./bus-transport.ts";
47
48
  import type { TelegramQueueHandoffPayload } from "./queue.ts";
@@ -273,6 +274,110 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
273
274
  workspaceRotation?: TelegramWorkspaceSlotRotationPorts;
274
275
  }
275
276
 
277
+ export type TelegramBusFollowerSessionReplacementOperation = (
278
+ follower: TelegramBusFollowerView,
279
+ intent: Threads.TelegramSessionReplacementIntent,
280
+ isCurrent: () => boolean,
281
+ ) => Promise<boolean>;
282
+
283
+ /**
284
+ * Leader-owned durable session-replacement authority for registered followers.
285
+ * Followers cannot persist `state.json`; the leader validates the request
286
+ * against its live registry entry and authoritative Workspace binding before
287
+ * one CAS publication or claim. Follower memory is never accepted as binding
288
+ * evidence.
289
+ */
290
+ export function createTelegramBusFollowerSessionReplacementAuthority(deps: {
291
+ store: Pick<
292
+ Threads.TelegramTopicTargetStore,
293
+ | "load"
294
+ | "getWorkspaceBindingByTarget"
295
+ | "getSessionReplacementIntent"
296
+ | "commitSessionReplacementIntent"
297
+ | "removeSessionReplacementIntent"
298
+ >;
299
+ getTelegramProfile?: () => string | undefined;
300
+ getNowMs?: () => number;
301
+ ttlMs?: number;
302
+ }): {
303
+ publish: TelegramBusFollowerSessionReplacementOperation;
304
+ settle: TelegramBusFollowerSessionReplacementOperation;
305
+ } {
306
+ const getNowMs = deps.getNowMs ?? Date.now;
307
+ const ttlMs = deps.ttlMs ?? Threads.TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS;
308
+ const assertCommon = (
309
+ follower: TelegramBusFollowerView,
310
+ intent: Threads.TelegramSessionReplacementIntent,
311
+ ): { cwd: string; sessionId: string } => {
312
+ const cwd = follower.cwd
313
+ ? Threads.normalizeTelegramWorkspacePath(follower.cwd)
314
+ : undefined;
315
+ const sessionId = follower.sessionId
316
+ ? Threads.normalizeTelegramSessionId(follower.sessionId)
317
+ : undefined;
318
+ const nowMs = getNowMs();
319
+ if (
320
+ intent.continuity !== "workspace-thread" ||
321
+ typeof intent.target.threadId !== "number" ||
322
+ follower.target?.chatId !== intent.target.chatId ||
323
+ follower.target.threadId !== intent.target.threadId ||
324
+ !cwd || cwd !== intent.cwd || !sessionId ||
325
+ intent.profileName !== (deps.getTelegramProfile?.() ?? "default")
326
+ ) {
327
+ throw new Error(
328
+ "Telegram session replacement does not match the current follower registration.",
329
+ );
330
+ }
331
+ if (intent.expiresAtMs <= nowMs || intent.expiresAtMs > nowMs + ttlMs) {
332
+ throw new Error("Telegram session replacement intent is expired or unbounded.");
333
+ }
334
+ return { cwd, sessionId };
335
+ };
336
+ return {
337
+ async publish(follower, intent, isCurrent) {
338
+ const { sessionId } = assertCommon(follower, intent);
339
+ if (intent.sourceInstanceId !== follower.instanceId ||
340
+ intent.sourceSessionId !== sessionId) {
341
+ throw new Error(
342
+ "Telegram session replacement source does not match the current follower registration.",
343
+ );
344
+ }
345
+ await deps.store.load();
346
+ if (!isCurrent()) return false;
347
+ const binding = deps.store.getWorkspaceBindingByTarget(intent.target);
348
+ if (
349
+ !binding || binding.cwd !== intent.cwd ||
350
+ binding.sessionId !== intent.sourceSessionId ||
351
+ binding.slot !== intent.slot ||
352
+ (binding.manualThreadName ?? binding.threadName) !== intent.threadName
353
+ ) {
354
+ throw new Error("Telegram session replacement binding is unavailable.");
355
+ }
356
+ return deps.store.commitSessionReplacementIntent(intent, isCurrent);
357
+ },
358
+ async settle(follower, intent, isCurrent) {
359
+ const { sessionId } = assertCommon(follower, intent);
360
+ if (
361
+ !intent.sourceInstanceId ||
362
+ (intent.sourceInstanceId !== follower.instanceId &&
363
+ intent.sourceInstanceId !== follower.previousInstanceId) ||
364
+ intent.sourceSessionId === sessionId
365
+ ) {
366
+ throw new Error(
367
+ "Telegram session replacement successor does not match the current follower registration.",
368
+ );
369
+ }
370
+ await deps.store.load();
371
+ if (!isCurrent()) return false;
372
+ if (deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd !==
373
+ intent.cwd) {
374
+ throw new Error("Telegram session replacement successor binding is unavailable.");
375
+ }
376
+ return deps.store.removeSessionReplacementIntent(intent, isCurrent);
377
+ },
378
+ };
379
+ }
380
+
276
381
  export function createTelegramBusLeaderRuntimeAssembly<TContext>(
277
382
  deps: TelegramBusLeaderRuntimeAssemblyDeps<TContext>,
278
383
  ): TelegramBusLeaderRuntime<TContext> & {
@@ -624,6 +729,12 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
624
729
  }
625
730
  });
626
731
  });
732
+ const followerSessionReplacement =
733
+ createTelegramBusFollowerSessionReplacementAuthority({
734
+ store: deps.topicTargetStore,
735
+ getTelegramProfile: deps.getTelegramProfile,
736
+ getNowMs: deps.runtime.getNowMs,
737
+ });
627
738
  const runtime = createTelegramBusLeaderRuntime({
628
739
  ...deps.runtime,
629
740
  applyThreadDisplayMode,
@@ -750,6 +861,18 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
750
861
  });
751
862
  },
752
863
  ),
864
+ publishFollowerSessionReplacement: (follower, intent, isCurrent) =>
865
+ runWorkspaceOperation({
866
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
867
+ operationKind: "workspace.publish-follower-session-replacement",
868
+ scopes: [{ kind: "profile" }],
869
+ }, () => followerSessionReplacement.publish(follower, intent, isCurrent)),
870
+ settleFollowerSessionReplacement: (follower, intent, isCurrent) =>
871
+ runWorkspaceOperation({
872
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
873
+ operationKind: "workspace.settle-follower-session-replacement",
874
+ scopes: [{ kind: "profile" }],
875
+ }, () => followerSessionReplacement.settle(follower, intent, isCurrent)),
753
876
  onFollowerConfirmedDead: async (follower) => {
754
877
  await runWorkspaceOperation(
755
878
  {
@@ -899,6 +1022,8 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
899
1022
  resetFollowerThreadName?: (
900
1023
  follower: TelegramBusFollowerView,
901
1024
  ) => Promise<{ threadName: string }> | { threadName: string };
1025
+ publishFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
1026
+ settleFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
902
1027
  getFollowerDisplayTitle?: (follower: TelegramBusFollowerView) => string | undefined;
903
1028
  onFollowerRegistered?: () => void;
904
1029
  applyThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
@@ -1923,6 +2048,8 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1923
2048
  resetFollowerThreadName?: (
1924
2049
  follower: TelegramBusFollowerView,
1925
2050
  ) => Promise<{ threadName: string }> | { threadName: string };
2051
+ publishFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
2052
+ settleFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
1926
2053
  getFollowerDisplayTitle?: (follower: TelegramBusFollowerView) => string | undefined;
1927
2054
  onFollowerRegistered?: () => void;
1928
2055
  applyThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
@@ -2547,6 +2674,74 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
2547
2674
  },
2548
2675
  );
2549
2676
  }
2677
+ case "follower.publishSessionReplacement":
2678
+ case "follower.settleSessionReplacement": {
2679
+ const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
2680
+ return runFollowerMutation(
2681
+ registeredFollower ?? { instanceId: envelope.instanceId },
2682
+ async () => {
2683
+ const publish = envelope.kind === "follower.publishSessionReplacement";
2684
+ const operation = publish
2685
+ ? deps.publishFollowerSessionReplacement
2686
+ : deps.settleFollowerSessionReplacement;
2687
+ const epoch = deps.getCurrentLeaderEpoch?.();
2688
+ const follower = deps.followerRegistry.get(envelope.instanceId);
2689
+ const isCurrent = () => epoch !== undefined &&
2690
+ deps.getCurrentLeaderEpoch?.() === epoch &&
2691
+ deps.followerRegistry.get(envelope.instanceId)?.registrationGeneration ===
2692
+ envelope.registrationGeneration;
2693
+ if (
2694
+ !follower?.registrationGeneration ||
2695
+ follower.registrationGeneration !== envelope.registrationGeneration ||
2696
+ !isCurrent() || !operation ||
2697
+ !hasTelegramBusCapability(
2698
+ deps.protocolIdentity,
2699
+ TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
2700
+ ) ||
2701
+ !hasTelegramBusCapability(
2702
+ follower.protocol,
2703
+ TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
2704
+ )
2705
+ ) {
2706
+ return {
2707
+ kind: "bus.ack" as const,
2708
+ requestId: envelope.requestId,
2709
+ ok: false,
2710
+ message:
2711
+ "Telegram session replacement requires current follower registration and compatible leader authority.",
2712
+ };
2713
+ }
2714
+ try {
2715
+ const committed = await operation(follower, envelope.intent, isCurrent);
2716
+ if (!committed || !isCurrent()) {
2717
+ return {
2718
+ kind: "bus.ack" as const,
2719
+ requestId: envelope.requestId,
2720
+ ok: false,
2721
+ message: publish
2722
+ ? "Telegram session replacement intent was not persisted."
2723
+ : "Telegram session replacement intent was not claimed.",
2724
+ };
2725
+ }
2726
+ return {
2727
+ kind: "bus.ack" as const,
2728
+ requestId: envelope.requestId,
2729
+ ok: true,
2730
+ result: { committed: true },
2731
+ };
2732
+ } catch (error) {
2733
+ return {
2734
+ kind: "bus.ack" as const,
2735
+ requestId: envelope.requestId,
2736
+ ok: false,
2737
+ message: error instanceof Error
2738
+ ? error.message
2739
+ : "Telegram session replacement request failed.",
2740
+ };
2741
+ }
2742
+ },
2743
+ );
2744
+ }
2550
2745
  case "follower.disconnect": {
2551
2746
  const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
2552
2747
  return runFollowerMutation(
@@ -3118,6 +3313,8 @@ export function createTelegramBusLeaderRuntime<TContext>(
3118
3313
  onFollowerDisconnected: deps.onFollowerDisconnected,
3119
3314
  renameFollowerThread: deps.renameFollowerThread,
3120
3315
  resetFollowerThreadName: deps.resetFollowerThreadName,
3316
+ publishFollowerSessionReplacement: deps.publishFollowerSessionReplacement,
3317
+ settleFollowerSessionReplacement: deps.settleFollowerSessionReplacement,
3121
3318
  getFollowerDisplayTitle: deps.getFollowerDisplayTitle,
3122
3319
  onFollowerRegistered: deps.onFollowerRegistered,
3123
3320
  applyThreadDisplayMode: deps.applyThreadDisplayMode,
@@ -52,6 +52,10 @@ import type { TelegramTarget } from "./target.ts";
52
52
  import type { TelegramThreadDisplayMode } from "./config.ts";
53
53
  import { isProcessAlive } from "./locks.ts";
54
54
  import { resolveAgentDir } from "./paths.ts";
55
+ import {
56
+ normalizeTelegramSessionReplacementIntent,
57
+ type TelegramSessionReplacementIntent,
58
+ } from "./threads.ts";
55
59
 
56
60
  export interface TelegramBusProcessRuntime {
57
61
  instanceId: string;
@@ -234,6 +238,8 @@ export const TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT =
234
238
  "directory-display-format-v1" as const;
235
239
  export const TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT =
236
240
  "workspace-follower-auto-connect-v1" as const;
241
+ export const TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT =
242
+ "session-replacement-intent-v1" as const;
237
243
 
238
244
  export interface TelegramBusProtocolIdentity {
239
245
  protocolVersion: number;
@@ -816,6 +822,16 @@ export type TelegramBusEnvelope = (
816
822
  target: TelegramTarget & { threadId: number };
817
823
  sentAtMs: number;
818
824
  }
825
+ | {
826
+ kind:
827
+ | "follower.publishSessionReplacement"
828
+ | "follower.settleSessionReplacement";
829
+ requestId: string;
830
+ instanceId: string;
831
+ registrationGeneration: string;
832
+ intent: TelegramSessionReplacementIntent;
833
+ sentAtMs: number;
834
+ }
819
835
  | {
820
836
  kind: "leader.forwardCallback";
821
837
  requestId: string;
@@ -1055,6 +1071,23 @@ export function parseTelegramBusEnvelope(
1055
1071
  }
1056
1072
  break;
1057
1073
  }
1074
+ case "follower.publishSessionReplacement":
1075
+ case "follower.settleSessionReplacement": {
1076
+ const intent = normalizeTelegramSessionReplacementIntent(value.intent);
1077
+ if (typeof value.instanceId === "string" &&
1078
+ typeof value.registrationGeneration === "string" &&
1079
+ typeof value.sentAtMs === "number" && intent) {
1080
+ envelope = {
1081
+ kind,
1082
+ requestId,
1083
+ instanceId: value.instanceId,
1084
+ registrationGeneration: value.registrationGeneration,
1085
+ intent,
1086
+ sentAtMs: value.sentAtMs,
1087
+ };
1088
+ }
1089
+ break;
1090
+ }
1058
1091
  case "leader.forwardCallback":
1059
1092
  envelope = parseForwardCallbackEnvelope(value, requestId);
1060
1093
  break;
@@ -2440,6 +2440,19 @@ export interface TelegramSessionActionAssemblyDeps {
2440
2440
  };
2441
2441
  getProfileName: () => string | undefined;
2442
2442
  ownsPersistence: () => boolean;
2443
+ /**
2444
+ * Registered-follower port. A follower cannot persist leader-owned state, so
2445
+ * its Workspace Thread intent is published and claimed by the leader over
2446
+ * authenticated generation-fenced bus RPC.
2447
+ */
2448
+ follower?: {
2449
+ instanceId: string;
2450
+ isRegisteredFor: (target: { chatId: number; threadId?: number }) => boolean;
2451
+ requestSessionReplacement: (
2452
+ operation: "publish" | "settle",
2453
+ intent: TelegramSessionReplacementIntent,
2454
+ ) => Promise<boolean>;
2455
+ };
2443
2456
  sendResult: (
2444
2457
  target: { chatId: number; threadId?: number },
2445
2458
  html: string,
@@ -2478,7 +2491,13 @@ export function createTelegramSessionActionAssembly(
2478
2491
  sendUserMessage: deps.sendUserMessage,
2479
2492
  notifyResult(target, result) { return sendTerminalResult(target, result); },
2480
2493
  async prepareReplacement(ctx, updateId, target) {
2481
- await deps.store.load();
2494
+ const follower = !deps.ownsPersistence() && typeof target.threadId === "number" &&
2495
+ deps.follower?.isRegisteredFor(target)
2496
+ ? deps.follower
2497
+ : undefined;
2498
+ // Follower memory is not authority; reread the leader-published snapshot.
2499
+ if (follower && deps.store.refresh) await deps.store.refresh();
2500
+ else await deps.store.load();
2482
2501
  const sessionId = ctx.sessionManager.getSessionId();
2483
2502
  const binding = typeof target.threadId === "number"
2484
2503
  ? deps.store.getWorkspaceBindingByTarget(target)
@@ -2488,7 +2507,7 @@ export function createTelegramSessionActionAssembly(
2488
2507
  throw new Error("Telegram session replacement binding is unavailable.");
2489
2508
  }
2490
2509
  const createdAtMs = now();
2491
- if (!await deps.store.commitSessionReplacementIntent({
2510
+ const intent: TelegramSessionReplacementIntent = {
2492
2511
  continuity: binding ? "workspace-thread" : "classic-chat",
2493
2512
  cwd: binding?.cwd ?? ctx.cwd,
2494
2513
  profileName: deps.getProfileName() ?? "default",
@@ -2501,7 +2520,11 @@ export function createTelegramSessionActionAssembly(
2501
2520
  ? { threadName: binding.manualThreadName ?? binding.threadName } : {}),
2502
2521
  createdAtMs,
2503
2522
  expiresAtMs: createdAtMs + deps.handoffTtlMs,
2504
- }, deps.ownsPersistence)) {
2523
+ ...(follower ? { sourceInstanceId: follower.instanceId } : {}),
2524
+ };
2525
+ if (!await (follower
2526
+ ? follower.requestSessionReplacement("publish", intent)
2527
+ : deps.store.commitSessionReplacementIntent(intent, deps.ownsPersistence))) {
2505
2528
  throw new Error("Telegram session replacement intent was not persisted.");
2506
2529
  }
2507
2530
  },
@@ -2514,11 +2537,20 @@ export function createTelegramSessionActionAssembly(
2514
2537
  return {
2515
2538
  async getIntent() { await deps.store.refresh?.(); return deps.store.getSessionReplacementIntent(); },
2516
2539
  hasSuccessorContinuity(intent) {
2517
- return intent.continuity === "classic-chat" ||
2518
- deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd === intent.cwd;
2540
+ if (intent.continuity === "classic-chat") return true;
2541
+ if (deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd !==
2542
+ intent.cwd) return false;
2543
+ // A follower successor claims only after its own re-registration is live.
2544
+ return intent.sourceInstanceId === undefined || deps.ownsPersistence() ||
2545
+ deps.follower?.isRegisteredFor(intent.target) === true;
2519
2546
  },
2520
2547
  editSuccess(intent) { return deps.sendResult(intent.target, "<b>🆕 New session started.</b>"); },
2521
- clearIntent(intent) { return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence); },
2548
+ async clearIntent(intent) {
2549
+ if (intent.sourceInstanceId === undefined || deps.ownsPersistence()) {
2550
+ return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence);
2551
+ }
2552
+ return await deps.follower?.requestSessionReplacement("settle", intent) ?? false;
2553
+ },
2522
2554
  profileName: deps.getProfileName() ?? "default",
2523
2555
  cwd: ctx.cwd,
2524
2556
  sessionId,
@@ -65,6 +65,7 @@ const telegramBusProtocolIdentity =
65
65
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
66
66
  Bus.TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
67
67
  Bus.TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
68
+ Bus.TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
68
69
  ]);
69
70
 
70
71
  // --- Extension Runtime ---
@@ -244,6 +245,20 @@ export default function (pi: Pi.ExtensionAPI) {
244
245
  store: threadStore,
245
246
  getProfileName: configStore.getActiveProfileName,
246
247
  ownsPersistence: lockRuntime.owns,
248
+ follower: {
249
+ instanceId: telegramInstanceId,
250
+ isRegisteredFor(target) {
251
+ const registered = telegramBusFollowerRegistrationState.getTarget();
252
+ return telegramBusFollowerRegistrationState.isRegistered() &&
253
+ registered?.chatId === target.chatId &&
254
+ registered.threadId === target.threadId;
255
+ },
256
+ async requestSessionReplacement(operation, intent) {
257
+ const request = telegramBusFollowerRegistration.requestSessionReplacement;
258
+ if (!request) return false;
259
+ return await request(operation, intent);
260
+ },
261
+ },
247
262
  async sendResult(target, html) {
248
263
  const delivery = await Delivery.sendTelegramView(
249
264
  { text: html, parseMode: "html", replyMarkup: { inline_keyboard: [] } },
@@ -1515,7 +1515,12 @@ export function createTelegramLockedPollingRuntime<
1515
1515
  ctx,
1516
1516
  state.lock,
1517
1517
  );
1518
- if (!isCurrent() || !restored) return;
1518
+ if (!isCurrent()) return;
1519
+ if (!restored) {
1520
+ deps.recordRuntimeEvent?.("lock", "Telegram follower auto-connect did not restore this session.",
1521
+ { phase: "follower-auto-connect-unavailable" });
1522
+ return;
1523
+ }
1519
1524
  deps.onTransportAvailabilityChanged?.();
1520
1525
  deps.updateStatus(ctx);
1521
1526
  deps.recordRuntimeEvent?.(
@@ -1180,8 +1180,16 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
1180
1180
  ctx: TContext,
1181
1181
  owner: TOwner,
1182
1182
  ): Promise<boolean | undefined> => {
1183
- if (!(await refreshFollowerState())) return undefined;
1184
- if (!deps.hasRememberedWorkspaceBinding?.(ctx)) return undefined;
1183
+ if (!(await refreshFollowerState())) {
1184
+ deps.recordEvent("bus", "Telegram follower auto-connect skipped: Threaded Mode is not enabled.",
1185
+ { phase: "follower-auto-connect-skip", reason: "thread-mode-unavailable" });
1186
+ return undefined;
1187
+ }
1188
+ if (!deps.hasRememberedWorkspaceBinding?.(ctx)) {
1189
+ deps.recordEvent("bus", "Telegram follower auto-connect skipped: no binding for this session.",
1190
+ { phase: "follower-auto-connect-skip", reason: "session-binding-unavailable" });
1191
+ return undefined;
1192
+ }
1185
1193
  return deps.restoreFollowerWithLeader?.(ctx, owner);
1186
1194
  };
1187
1195
  return {
@@ -371,6 +371,12 @@ export interface TelegramSessionReplacementIntent {
371
371
  threadName?: string;
372
372
  createdAtMs: number;
373
373
  expiresAtMs: number;
374
+ /**
375
+ * Present only when the leader published the intent for a registered
376
+ * follower. Successor re-key and settlement must come from this exact
377
+ * follower runtime or its authenticated same-process session handoff.
378
+ */
379
+ sourceInstanceId?: string;
374
380
  }
375
381
 
376
382
  export interface TelegramTopicTargetFile {
@@ -1329,7 +1335,7 @@ function cloneSessionReplacementIntent(
1329
1335
  return { ...intent, target: { ...intent.target } };
1330
1336
  }
1331
1337
 
1332
- function normalizeSessionReplacementIntent(
1338
+ export function normalizeTelegramSessionReplacementIntent(
1333
1339
  value: unknown,
1334
1340
  ): TelegramSessionReplacementIntent | undefined {
1335
1341
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
@@ -1346,7 +1352,10 @@ function normalizeSessionReplacementIntent(
1346
1352
  typeof record.messageId !== "number" || !Number.isSafeInteger(record.messageId) ||
1347
1353
  typeof record.createdAtMs !== "number" || !Number.isSafeInteger(record.createdAtMs) ||
1348
1354
  typeof record.expiresAtMs !== "number" || !Number.isSafeInteger(record.expiresAtMs) ||
1349
- record.expiresAtMs <= record.createdAtMs
1355
+ record.expiresAtMs <= record.createdAtMs ||
1356
+ (record.sourceInstanceId !== undefined &&
1357
+ (typeof record.sourceInstanceId !== "string" || !record.sourceInstanceId ||
1358
+ record.sourceInstanceId.length > 256))
1350
1359
  ) return undefined;
1351
1360
  const continuity = record.continuity === "workspace-thread" ||
1352
1361
  record.continuity === "classic-chat"
@@ -1370,6 +1379,8 @@ function normalizeSessionReplacementIntent(
1370
1379
  ...(typeof record.threadName === "string" ? { threadName: record.threadName } : {}),
1371
1380
  createdAtMs: record.createdAtMs,
1372
1381
  expiresAtMs: record.expiresAtMs,
1382
+ ...(typeof record.sourceInstanceId === "string"
1383
+ ? { sourceInstanceId: record.sourceInstanceId } : {}),
1373
1384
  };
1374
1385
  }
1375
1386
 
@@ -1680,7 +1691,7 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
1680
1691
  return normalized ? [normalized] : [];
1681
1692
  })
1682
1693
  : [],
1683
- sessionReplacement: normalizeSessionReplacementIntent(file.sessionReplacement),
1694
+ sessionReplacement: normalizeTelegramSessionReplacementIntent(file.sessionReplacement),
1684
1695
  reservations: Array.isArray(file.reservations)
1685
1696
  ? file.reservations.flatMap((reservation) => {
1686
1697
  const normalized = normalizeReservation(reservation);
@@ -2510,7 +2521,7 @@ export function createTelegramTopicTargetStore(
2510
2521
  : undefined;
2511
2522
  },
2512
2523
  async commitSessionReplacementIntent(intent, isCurrent) {
2513
- const next = normalizeSessionReplacementIntent(intent);
2524
+ const next = normalizeTelegramSessionReplacementIntent(intent);
2514
2525
  if (!next || !isCurrent()) return false;
2515
2526
  await loadFromDisk();
2516
2527
  if (!isCurrent()) return false;
@@ -2925,7 +2936,10 @@ export function createTelegramTopicTargetStore(
2925
2936
  replacement.expiresAtMs > getNowMs() &&
2926
2937
  replacement.profileName === (getTelegramProfile() ?? "default") &&
2927
2938
  replacement.cwd === normalizedCwd &&
2928
- replacement.sourceSessionId !== normalizedSessionId
2939
+ replacement.sourceSessionId !== normalizedSessionId &&
2940
+ (replacement.sourceInstanceId === undefined ||
2941
+ replacement.sourceInstanceId === instanceId ||
2942
+ replacement.sourceInstanceId === previousInstanceId)
2929
2943
  ) {
2930
2944
  const sourceEntry = Array.from(workspaceBindings.entries()).find(([, binding]) =>
2931
2945
  binding.cwd === normalizedCwd &&
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.51.4",
3
+ "version": "0.51.5",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.23.2",
3
+ "version": "0.24.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -45,8 +45,8 @@
45
45
  "@llblab/pi-clean-room": "0.2.0",
46
46
  "@llblab/pi-codex-usage": "0.10.0",
47
47
  "@llblab/pi-grow-loop": "0.8.2",
48
- "@llblab/pi-state-flow": "0.19.1",
49
- "@llblab/pi-telegram": "0.51.4",
48
+ "@llblab/pi-state-flow": "0.21.0",
49
+ "@llblab/pi-telegram": "0.51.5",
50
50
  "@llblab/skills": "1.15.0"
51
51
  },
52
52
  "bundledDependencies": [