@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
@@ -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,
@@ -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;
@@ -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;
@@ -645,6 +645,19 @@ export interface TelegramSessionActionAssemblyDeps {
645
645
  };
646
646
  getProfileName: () => string | undefined;
647
647
  ownsPersistence: () => boolean;
648
+ /**
649
+ * Registered-follower port. A follower cannot persist leader-owned state, so
650
+ * its Workspace Thread intent is published and claimed by the leader over
651
+ * authenticated generation-fenced bus RPC.
652
+ */
653
+ follower?: {
654
+ instanceId: string;
655
+ isRegisteredFor: (target: {
656
+ chatId: number;
657
+ threadId?: number;
658
+ }) => boolean;
659
+ requestSessionReplacement: (operation: "publish" | "settle", intent: TelegramSessionReplacementIntent) => Promise<boolean>;
660
+ };
648
661
  sendResult: (target: {
649
662
  chatId: number;
650
663
  threadId?: number;
@@ -1294,7 +1294,15 @@ export function createTelegramSessionActionAssembly(deps) {
1294
1294
  sendUserMessage: deps.sendUserMessage,
1295
1295
  notifyResult(target, result) { return sendTerminalResult(target, result); },
1296
1296
  async prepareReplacement(ctx, updateId, target) {
1297
- await deps.store.load();
1297
+ const follower = !deps.ownsPersistence() && typeof target.threadId === "number" &&
1298
+ deps.follower?.isRegisteredFor(target)
1299
+ ? deps.follower
1300
+ : undefined;
1301
+ // Follower memory is not authority; reread the leader-published snapshot.
1302
+ if (follower && deps.store.refresh)
1303
+ await deps.store.refresh();
1304
+ else
1305
+ await deps.store.load();
1298
1306
  const sessionId = ctx.sessionManager.getSessionId();
1299
1307
  const binding = typeof target.threadId === "number"
1300
1308
  ? deps.store.getWorkspaceBindingByTarget(target)
@@ -1304,7 +1312,7 @@ export function createTelegramSessionActionAssembly(deps) {
1304
1312
  throw new Error("Telegram session replacement binding is unavailable.");
1305
1313
  }
1306
1314
  const createdAtMs = now();
1307
- if (!await deps.store.commitSessionReplacementIntent({
1315
+ const intent = {
1308
1316
  continuity: binding ? "workspace-thread" : "classic-chat",
1309
1317
  cwd: binding?.cwd ?? ctx.cwd,
1310
1318
  profileName: deps.getProfileName() ?? "default",
@@ -1317,7 +1325,11 @@ export function createTelegramSessionActionAssembly(deps) {
1317
1325
  ? { threadName: binding.manualThreadName ?? binding.threadName } : {}),
1318
1326
  createdAtMs,
1319
1327
  expiresAtMs: createdAtMs + deps.handoffTtlMs,
1320
- }, deps.ownsPersistence)) {
1328
+ ...(follower ? { sourceInstanceId: follower.instanceId } : {}),
1329
+ };
1330
+ if (!await (follower
1331
+ ? follower.requestSessionReplacement("publish", intent)
1332
+ : deps.store.commitSessionReplacementIntent(intent, deps.ownsPersistence))) {
1321
1333
  throw new Error("Telegram session replacement intent was not persisted.");
1322
1334
  }
1323
1335
  },
@@ -1331,11 +1343,22 @@ export function createTelegramSessionActionAssembly(deps) {
1331
1343
  return {
1332
1344
  async getIntent() { await deps.store.refresh?.(); return deps.store.getSessionReplacementIntent(); },
1333
1345
  hasSuccessorContinuity(intent) {
1334
- return intent.continuity === "classic-chat" ||
1335
- deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd === intent.cwd;
1346
+ if (intent.continuity === "classic-chat")
1347
+ return true;
1348
+ if (deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd !==
1349
+ intent.cwd)
1350
+ return false;
1351
+ // A follower successor claims only after its own re-registration is live.
1352
+ return intent.sourceInstanceId === undefined || deps.ownsPersistence() ||
1353
+ deps.follower?.isRegisteredFor(intent.target) === true;
1336
1354
  },
1337
1355
  editSuccess(intent) { return deps.sendResult(intent.target, "<b>🆕 New session started.</b>"); },
1338
- clearIntent(intent) { return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence); },
1356
+ async clearIntent(intent) {
1357
+ if (intent.sourceInstanceId === undefined || deps.ownsPersistence()) {
1358
+ return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence);
1359
+ }
1360
+ return await deps.follower?.requestSessionReplacement("settle", intent) ?? false;
1361
+ },
1339
1362
  profileName: deps.getProfileName() ?? "default",
1340
1363
  cwd: ctx.cwd,
1341
1364
  sessionId,
@@ -60,6 +60,7 @@ const telegramBusProtocolIdentity = Bus.createTelegramCurrentBusProtocolIdentity
60
60
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
61
61
  Bus.TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
62
62
  Bus.TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
63
+ Bus.TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
63
64
  ]);
64
65
  // --- Extension Runtime ---
65
66
  export default function (pi) {
@@ -191,6 +192,21 @@ export default function (pi) {
191
192
  store: threadStore,
192
193
  getProfileName: configStore.getActiveProfileName,
193
194
  ownsPersistence: lockRuntime.owns,
195
+ follower: {
196
+ instanceId: telegramInstanceId,
197
+ isRegisteredFor(target) {
198
+ const registered = telegramBusFollowerRegistrationState.getTarget();
199
+ return telegramBusFollowerRegistrationState.isRegistered() &&
200
+ registered?.chatId === target.chatId &&
201
+ registered.threadId === target.threadId;
202
+ },
203
+ async requestSessionReplacement(operation, intent) {
204
+ const request = telegramBusFollowerRegistration.requestSessionReplacement;
205
+ if (!request)
206
+ return false;
207
+ return await request(operation, intent);
208
+ },
209
+ },
194
210
  async sendResult(target, html) {
195
211
  const delivery = await Delivery.sendTelegramView({ text: html, parseMode: "html", replyMarkup: { inline_keyboard: [] } }, { scope: { kind: "target", target } });
196
212
  return delivery.ok ? { ok: true } : { ok: false,
@@ -1171,8 +1171,12 @@ export function createTelegramLockedPollingRuntime(deps) {
1171
1171
  return;
1172
1172
  if (canRestoreRememberedFollower && state?.kind === "active-elsewhere") {
1173
1173
  const restored = await deps.restoreFollowerWithOwner?.(ctx, state.lock);
1174
- if (!isCurrent() || !restored)
1174
+ if (!isCurrent())
1175
1175
  return;
1176
+ if (!restored) {
1177
+ deps.recordRuntimeEvent?.("lock", "Telegram follower auto-connect did not restore this session.", { phase: "follower-auto-connect-unavailable" });
1178
+ return;
1179
+ }
1176
1180
  deps.onTransportAvailabilityChanged?.();
1177
1181
  deps.updateStatus(ctx);
1178
1182
  deps.recordRuntimeEvent?.("bus", "Telegram follower auto-connect completed", { phase: "follower-auto-connect" });
@@ -752,10 +752,14 @@ export function createTelegramThreadAwarePollingPorts(deps) {
752
752
  return deps.registerFollowerWithLeader(ctx, owner);
753
753
  };
754
754
  const restoreFollowerWithOwner = async (ctx, owner) => {
755
- if (!(await refreshFollowerState()))
755
+ if (!(await refreshFollowerState())) {
756
+ deps.recordEvent("bus", "Telegram follower auto-connect skipped: Threaded Mode is not enabled.", { phase: "follower-auto-connect-skip", reason: "thread-mode-unavailable" });
756
757
  return undefined;
757
- if (!deps.hasRememberedWorkspaceBinding?.(ctx))
758
+ }
759
+ if (!deps.hasRememberedWorkspaceBinding?.(ctx)) {
760
+ deps.recordEvent("bus", "Telegram follower auto-connect skipped: no binding for this session.", { phase: "follower-auto-connect-skip", reason: "session-binding-unavailable" });
758
761
  return undefined;
762
+ }
759
763
  return deps.restoreFollowerWithLeader?.(ctx, owner);
760
764
  };
761
765
  return {
@@ -187,6 +187,12 @@ export interface TelegramSessionReplacementIntent {
187
187
  threadName?: string;
188
188
  createdAtMs: number;
189
189
  expiresAtMs: number;
190
+ /**
191
+ * Present only when the leader published the intent for a registered
192
+ * follower. Successor re-key and settlement must come from this exact
193
+ * follower runtime or its authenticated same-process session handoff.
194
+ */
195
+ sourceInstanceId?: string;
190
196
  }
191
197
  export interface TelegramTopicTargetFile {
192
198
  version: 1;
@@ -385,6 +391,7 @@ export declare function isTelegramLeaderSessionHandoffFresh(handoff: TelegramLea
385
391
  }): handoff is TelegramLeaderSessionHandoff;
386
392
  export declare function getTelegramThreadOwnerKey(owner: TelegramThreadOwner): string;
387
393
  export declare function getTelegramThreadOwnerFromProfileKey(profileKey: string): TelegramThreadOwner;
394
+ export declare function normalizeTelegramSessionReplacementIntent(value: unknown): TelegramSessionReplacementIntent | undefined;
388
395
  export declare function isSameTelegramProcessInstance(left: string | undefined, right: string | undefined): boolean;
389
396
  export declare function createTelegramTopicTargetStore(options: TelegramTopicTargetStoreOptions): TelegramTopicTargetStore;
390
397
  export declare function normalizeTelegramTopicTargetThreadName(threadName: string): string;
@@ -648,7 +648,7 @@ function cloneWorkspaceBinding(binding) {
648
648
  function cloneSessionReplacementIntent(intent) {
649
649
  return { ...intent, target: { ...intent.target } };
650
650
  }
651
- function normalizeSessionReplacementIntent(value) {
651
+ export function normalizeTelegramSessionReplacementIntent(value) {
652
652
  if (!value || typeof value !== "object" || Array.isArray(value))
653
653
  return undefined;
654
654
  const record = value;
@@ -663,7 +663,10 @@ function normalizeSessionReplacementIntent(value) {
663
663
  typeof record.messageId !== "number" || !Number.isSafeInteger(record.messageId) ||
664
664
  typeof record.createdAtMs !== "number" || !Number.isSafeInteger(record.createdAtMs) ||
665
665
  typeof record.expiresAtMs !== "number" || !Number.isSafeInteger(record.expiresAtMs) ||
666
- record.expiresAtMs <= record.createdAtMs)
666
+ record.expiresAtMs <= record.createdAtMs ||
667
+ (record.sourceInstanceId !== undefined &&
668
+ (typeof record.sourceInstanceId !== "string" || !record.sourceInstanceId ||
669
+ record.sourceInstanceId.length > 256)))
667
670
  return undefined;
668
671
  const continuity = record.continuity === "workspace-thread" ||
669
672
  record.continuity === "classic-chat"
@@ -687,6 +690,8 @@ function normalizeSessionReplacementIntent(value) {
687
690
  ...(typeof record.threadName === "string" ? { threadName: record.threadName } : {}),
688
691
  createdAtMs: record.createdAtMs,
689
692
  expiresAtMs: record.expiresAtMs,
693
+ ...(typeof record.sourceInstanceId === "string"
694
+ ? { sourceInstanceId: record.sourceInstanceId } : {}),
690
695
  };
691
696
  }
692
697
  function normalizeWorkspaceRetirementIntent(value) {
@@ -949,7 +954,7 @@ function parseTopicTargetFile(value) {
949
954
  return normalized ? [normalized] : [];
950
955
  })
951
956
  : [],
952
- sessionReplacement: normalizeSessionReplacementIntent(file.sessionReplacement),
957
+ sessionReplacement: normalizeTelegramSessionReplacementIntent(file.sessionReplacement),
953
958
  reservations: Array.isArray(file.reservations)
954
959
  ? file.reservations.flatMap((reservation) => {
955
960
  const normalized = normalizeReservation(reservation);
@@ -1667,7 +1672,7 @@ export function createTelegramTopicTargetStore(options) {
1667
1672
  : undefined;
1668
1673
  },
1669
1674
  async commitSessionReplacementIntent(intent, isCurrent) {
1670
- const next = normalizeSessionReplacementIntent(intent);
1675
+ const next = normalizeTelegramSessionReplacementIntent(intent);
1671
1676
  if (!next || !isCurrent())
1672
1677
  return false;
1673
1678
  await loadFromDisk();
@@ -2081,7 +2086,10 @@ export function createTelegramTopicTargetStore(options) {
2081
2086
  replacement.expiresAtMs > getNowMs() &&
2082
2087
  replacement.profileName === (getTelegramProfile() ?? "default") &&
2083
2088
  replacement.cwd === normalizedCwd &&
2084
- replacement.sourceSessionId !== normalizedSessionId) {
2089
+ replacement.sourceSessionId !== normalizedSessionId &&
2090
+ (replacement.sourceInstanceId === undefined ||
2091
+ replacement.sourceInstanceId === instanceId ||
2092
+ replacement.sourceInstanceId === previousInstanceId)) {
2085
2093
  const sourceEntry = Array.from(workspaceBindings.entries()).find(([, binding]) => binding.cwd === normalizedCwd &&
2086
2094
  binding.sessionId === replacement.sourceSessionId &&
2087
2095
  targetMatches(binding.target, replacement.target));
@@ -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"
@@ -83,7 +83,7 @@ Threaded Mode meaning:
83
83
  tmp/telegram/owners.json / <profile-slot> -> bus leader identity + heartbeat
84
84
  ```
85
85
 
86
- Followers do not poll. They register with the leader and receive routed inbound updates from it. Followers still own their local queue, active-turn state, previews, final delivery planning, model switches, and Pi lifecycle. The leader owns only Telegram transport and update fanout. Pi session replacement (`new`) changes follower agent context, not bus membership: a registered follower preserves its registration and refreshes the live context instead of disconnecting. A new follower process also attempts a restore-only registration at session startup when the selected profile's local state contains an exact-`cwd` Workspace binding. The Telegram bus belongs to the local set of cooperating visible Pi instances rather than to the first terminal session forever: if the visible terminal leader exits, a live registered follower can take over leadership.
86
+ Followers do not poll. They register with the leader and receive routed inbound updates from it. Followers still own their local queue, active-turn state, previews, final delivery planning, model switches, and Pi lifecycle. The leader owns only Telegram transport and update fanout. Pi session replacement (`new`) changes follower agent context, not bus membership: a registered follower preserves its registration and refreshes the live context instead of disconnecting. A new follower process also attempts a restore-only registration at session startup when the selected profile's local state contains a Workspace binding for the exact `cwd` and session ID. The recent-event log distinguishes missing session bindings, disabled Threaded Mode, leader-side restore refusal, and a restore that returned without connecting; none of these refusals silently provisions a new Thread. The Telegram bus belongs to the local set of cooperating visible Pi instances rather than to the first terminal session forever: if the visible terminal leader exits, a live registered follower can take over leadership.
87
87
 
88
88
  The assembled follower receiver admits a registration generation only after its journal-binding preparation succeeds for the same generation and current Pi context. Registration identity is available during preparation so the worker can bind, but early inbound delivery receives a negative ACK rather than writing into a retained old journal. Readiness also binds the Pi session generation, so a reused context object cannot preserve it across session replacement. A registered follower's session refresh awaits binding preparation through `setContext` without re-registering or changing its bus generation. Preparation failure keeps inbound delivery unavailable and records a diagnostic; a later refresh can retry. Normal delivery retry proceeds after successful preparation. Registration requests also retain their original Pi session generation and local attempt authority across awaits. A late reply after session drift, stop or a newer request cannot publish success or tear down newer local authority; a context refresh likewise protects its retained membership from an older pending request. This refusal does not undo remote Thread provisioning or authorize deletion.
89
89
 
@@ -226,7 +226,7 @@ Followers first try to re-register after leader reload or unknown-heartbeat resp
226
226
 
227
227
  ### Protocol identity and compatibility
228
228
 
229
- The session-native local wire contract has protocol version `2`, independent from the npm package version. Follower registration and the leader acknowledgement carry `{ protocolVersion, runtimeBuild, capabilities }`. Capability names are canonical, unique, and sorted. A leader rejects missing or mismatched protocol identity before provisioning a target or publishing the follower into live routing; a strict follower likewise rejects an acknowledgement without compatible leader identity. Different package builds remain compatible when their protocol versions agree. `durable-follower-admission-v1` gates source forwarding, while `queue-handoff-v1` independently gates live semantic queue transfer; every participant in a routed handoff must advertise it. `workspace-follower-auto-connect-v1` gates restore-only startup admission. Session identity is part of protocol v2 rather than an optional capability. Every cwd-scoped v2 registration carries the exact bounded session ID; missing identity is rejected before provisioning or publication. Reconnect, replacement, and promotion preserve it. Protocol-v1 peers are rejected by the base version check, avoiding mixed semantic branches. `thread-display-mode-v1` gates exact-generation follower display-setting requests and Letters/Directories require compatible connected followers; returning to Names allows legacy peers. Registration checks the current display-mode requirement before provisioning and again before live publication. The leader serializes config persistence and title application. `workspace-thread-rename-v1` independently gates follower rename requests whose exact registration generation is checked before the leader mutates Telegram and persists the Workspace binding.
229
+ The session-native local wire contract has protocol version `2`, independent from the npm package version. Follower registration and the leader acknowledgement carry `{ protocolVersion, runtimeBuild, capabilities }`. Capability names are canonical, unique, and sorted. A leader rejects missing or mismatched protocol identity before provisioning a target or publishing the follower into live routing; a strict follower likewise rejects an acknowledgement without compatible leader identity. Different package builds remain compatible when their protocol versions agree. `durable-follower-admission-v1` gates source forwarding, while `queue-handoff-v1` independently gates live semantic queue transfer; every participant in a routed handoff must advertise it. `workspace-follower-auto-connect-v1` gates restore-only startup admission. Session identity is part of protocol v2 rather than an optional capability. Every cwd-scoped v2 registration carries the exact bounded session ID; missing identity is rejected before provisioning or publication. Reconnect, replacement, and promotion preserve it. Protocol-v1 peers are rejected by the base version check, avoiding mixed semantic branches. `thread-display-mode-v1` gates exact-generation follower display-setting requests and Letters/Directories require compatible connected followers; returning to Names allows legacy peers. Registration checks the current display-mode requirement before provisioning and again before live publication. The leader serializes config persistence and title application. `workspace-thread-rename-v1` independently gates follower rename requests whose exact registration generation is checked before the leader mutates Telegram and persists the Workspace binding. `session-replacement-intent-v1` gates Telegram `/new` from a follower Thread: followers never write `state.json`, so `follower.publishSessionReplacement` asks the leader to CAS-publish the durable intent only when the live registration generation, leader epoch, Profile, exact `cwd`, source session, Thread target, and the leader's own Workspace binding slot/name all match, with a bounded unexpired TTL. The intent records the source runtime instance; only a successor registration from that instance or its authenticated same-process handoff (`previousInstanceId`) may re-key the binding, and `follower.settleSessionReplacement` claims it once only after the leader's store shows that successor session bound to the same target. A missing capability, stale generation, or mismatch fails the command closed.
230
230
 
231
231
  Negotiated identities remain on the live follower registry and appear in `/telegram-status --debug` plus the observational state snapshot. The Threaded Mode capability monitor owns one in-flight probe across lifecycle generations: stop/restart invalidates a late read, and a replacement monitor waits for the previous request to settle instead of creating overlapping transport transitions. Durable follower admission is authorized only when both peers advertise `durable-follower-admission-v1`, never inferred from package version: a capable runtime rejects missing support before provisioning, inbound routing, or election-roster eligibility. Authentication and exact registration generation remain mandatory independently of protocol compatibility. `follower.register` is the explicit bootstrap request and may provision; capability-gated `follower.restoreWorkspace` is startup-only and may claim, probe, or replace a remembered binding but returns `workspace-binding-unavailable` without allocating when no binding or claim-fenced legacy exact-`cwd` record exists. Both carry a fresh generation; every later request is exact-generation-fenced against the live registry entry. `bus.ack` is response-only and is rejected if submitted as a server request. Leader forwarding never synthesizes authority for an unknown recipient and preserves the follower's exact durable receipt end to end.
232
232
 
@@ -51,7 +51,7 @@ Stable commands inside Pi:
51
51
  Stable commands inside the paired Telegram DM:
52
52
 
53
53
  - `/start` — pair when needed and open the main application menu.
54
- - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-internal` Pi gateway using `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Manual invocation reports that the gateway cannot be run manually; the armed handler receives a real `ExtensionCommandContext` and calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. Busy, identity-mismatch, and unavailable-host paths fail closed.
54
+ - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-internal` Pi gateway using `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Manual invocation reports that the gateway cannot be run manually; the armed handler receives a real `ExtensionCommandContext` and calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. In a follower Thread the leader publishes and claims the intent on the follower's behalf over capability-gated, exact-generation local bus requests; followers never persist leader-owned state. Busy, identity-mismatch, stale-registration, incompatible-leader, and unavailable-host paths fail closed.
55
55
  - `/compact` — open confirmation and compact when idle.
56
56
  - `/next` — abort active work first when needed, attempt the interrupted prompt's abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. That dispatch notice owns the turn's one reply header; later answer messages do not reply to the same prompt again. Abort-notice failure is diagnostic and cannot block dispatch. A later `/abort` or `/stop` cancels both pending transition notices before taking ownership. The `/next` command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
57
57
  - `/continue` — enqueue a priority `continue` prompt.