@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/dist/lib/locks.js CHANGED
@@ -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" });
package/dist/lib/model.js CHANGED
@@ -294,10 +294,8 @@ function truncateTelegramModelSwitchStatusSummary(text, maxWords = 4, maxLength
294
294
  }
295
295
  export function buildTelegramModelSwitchContinuationText(telegramPrefix, model, thinkingLevel) {
296
296
  const modelLabel = `${model.provider}/${model.id}`;
297
- const thinkingSuffix = thinkingLevel
298
- ? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.`
299
- : "";
300
- return `${telegramPrefix} Continue the interrupted previous request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`;
297
+ const thinkingSuffix = thinkingLevel ? `; thinking: ${thinkingLevel}` : "";
298
+ return `${telegramPrefix} Continue from the last unfinished step. Model: ${modelLabel}${thinkingSuffix}.`;
301
299
  }
302
300
  export function buildTelegramModelSwitchContinuationTurn(options) {
303
301
  const modelLabel = `${options.selection.model.provider}/${options.selection.model.id}`;
@@ -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 {
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Telegram status rendering helpers
3
3
  * Zones: telegram ui, pi agent diagnostics, tui
4
- * Builds usage, cost, and context summaries for the interactive Telegram status view
4
+ * Owns status summaries, redacted runtime diagnostics, and compact connection-failure copy
5
5
  */
6
+ /** UI copy is allowlisted; raw exception text belongs only in redacted diagnostics. */
7
+ export declare function formatTelegramConnectionFailure(error: unknown): string;
6
8
  export type TelegramStatusQueueLane = "control" | "priority" | "default";
7
9
  export interface TelegramUsageStats {
8
10
  totalInput: number;
@@ -1,8 +1,38 @@
1
1
  /**
2
2
  * Telegram status rendering helpers
3
3
  * Zones: telegram ui, pi agent diagnostics, tui
4
- * Builds usage, cost, and context summaries for the interactive Telegram status view
4
+ * Owns status summaries, redacted runtime diagnostics, and compact connection-failure copy
5
5
  */
6
+ /** UI copy is allowlisted; raw exception text belongs only in redacted diagnostics. */
7
+ export function formatTelegramConnectionFailure(error) {
8
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
9
+ const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
10
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
11
+ if (status === 401)
12
+ return "Telegram token rejected. Run /telegram-setup.";
13
+ if (status === 403)
14
+ return "Telegram access denied. Check /telegram-status --debug.";
15
+ if ((typeof code === "string" && ["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "ECONNRESET"].includes(code)) ||
16
+ /network unavailable|fetch failed/i.test(message)) {
17
+ return "Telegram network unavailable. Retry /telegram-connect.";
18
+ }
19
+ if (/Workspace slots?.*(?:unavailable|exhausted)|no free.*slot/i.test(message)) {
20
+ return "No Telegram slot available. Check /telegram-status --debug.";
21
+ }
22
+ if (code === "incompatible-protocol" || /protocol.*(?:incompatible|mismatch)|incompatible.*protocol/i.test(message)) {
23
+ return "Telegram instances are incompatible. Update them together.";
24
+ }
25
+ if (/unsupported.*(?:journal|version)|(?:journal|version).*unsupported/i.test(message)) {
26
+ return "Telegram state version unsupported. Use a compatible runtime.";
27
+ }
28
+ if (/follower registration failed|active in another Pi instance/i.test(message)) {
29
+ return "Telegram leader is active but unavailable. Check /telegram-status --debug.";
30
+ }
31
+ if (/unfinished Thread creation does not match/i.test(message)) {
32
+ return "Telegram Thread creation is unresolved. Check /telegram-status --debug.";
33
+ }
34
+ return "Telegram connection failed. Check /telegram-status --debug.";
35
+ }
6
36
  const TELEGRAM_STATUS_DEFAULT_PROFILE_NAME = "default";
7
37
  const TELEGRAM_STATUS_LINE_PROVIDER_REGISTRY_KEY = "__piTelegramStatusLineProviders__";
8
38
  const MAX_RECENT_TELEGRAM_RUNTIME_EVENTS = 10;
package/dist/lib/sync.js CHANGED
@@ -459,6 +459,7 @@ export async function ensureTelegramLeaderThreadBinding(deps) {
459
459
  ? deps.topicTargetStore.getWorkspaceBinding(workspaceIdentity.cwd, workspaceIdentity.instanceSlot, workspaceIdentity.sessionId)
460
460
  : undefined;
461
461
  const legacyWorkspaceBinding = workspaceIdentity?.instanceSlot === "a" &&
462
+ deps.sessionId === undefined &&
462
463
  !persistedWorkspaceBinding &&
463
464
  typeof legacyLeaderRecord?.target.threadId === "number"
464
465
  ? {
@@ -553,10 +554,9 @@ export async function ensureTelegramLeaderThreadBinding(deps) {
553
554
  return (record.instanceId === deps.instanceId &&
554
555
  (record.status === "active" || record.status === "starting"));
555
556
  });
556
- // Short-circuit: when the instance already has an active thread and we are not
557
- // force-freshing, reuse it without re-provisioning. A thread belongs to the
558
- // live instance binding, not to one transient Pi session lifecycle.
559
- if (!deps.forceFreshUnnamed && priorTargets.length > 0) {
557
+ // Legacy callers without session identity may reuse the active instance target.
558
+ // Session-aware callers must resolve their exact Workspace binding instead.
559
+ if (deps.sessionId === undefined && !deps.forceFreshUnnamed && priorTargets.length > 0) {
560
560
  const record = priorTargets[0];
561
561
  deps.recordEvent("telegram", "Leader thread preserved after session lifecycle change", {
562
562
  phase: "leader-thread-reused",
@@ -68,6 +68,7 @@ export interface TelegramThreadPendingProvision {
68
68
  owner: "leader" | "manual-follower";
69
69
  instanceId: string;
70
70
  profileKey?: string;
71
+ workspaceBindingKey?: string;
71
72
  status?: "in-flight" | "ambiguous";
72
73
  threadName?: string;
73
74
  displayTitle?: string;
@@ -187,6 +188,12 @@ export interface TelegramSessionReplacementIntent {
187
188
  threadName?: string;
188
189
  createdAtMs: number;
189
190
  expiresAtMs: number;
191
+ /**
192
+ * Present only when the leader published the intent for a registered
193
+ * follower. Successor re-key and settlement must come from this exact
194
+ * follower runtime or its authenticated same-process session handoff.
195
+ */
196
+ sourceInstanceId?: string;
190
197
  }
191
198
  export interface TelegramTopicTargetFile {
192
199
  version: 1;
@@ -385,6 +392,7 @@ export declare function isTelegramLeaderSessionHandoffFresh(handoff: TelegramLea
385
392
  }): handoff is TelegramLeaderSessionHandoff;
386
393
  export declare function getTelegramThreadOwnerKey(owner: TelegramThreadOwner): string;
387
394
  export declare function getTelegramThreadOwnerFromProfileKey(profileKey: string): TelegramThreadOwner;
395
+ export declare function normalizeTelegramSessionReplacementIntent(value: unknown): TelegramSessionReplacementIntent | undefined;
388
396
  export declare function isSameTelegramProcessInstance(left: string | undefined, right: string | undefined): boolean;
389
397
  export declare function createTelegramTopicTargetStore(options: TelegramTopicTargetStoreOptions): TelegramTopicTargetStore;
390
398
  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) {
@@ -807,6 +812,9 @@ function normalizePendingProvision(value) {
807
812
  ...(record.status === "in-flight" || record.status === "ambiguous"
808
813
  ? { status: record.status }
809
814
  : {}),
815
+ ...(typeof record.workspaceBindingKey === "string"
816
+ ? { workspaceBindingKey: record.workspaceBindingKey }
817
+ : {}),
810
818
  ...(typeof record.threadName === "string"
811
819
  ? { threadName: record.threadName }
812
820
  : {}),
@@ -949,7 +957,7 @@ function parseTopicTargetFile(value) {
949
957
  return normalized ? [normalized] : [];
950
958
  })
951
959
  : [],
952
- sessionReplacement: normalizeSessionReplacementIntent(file.sessionReplacement),
960
+ sessionReplacement: normalizeTelegramSessionReplacementIntent(file.sessionReplacement),
953
961
  reservations: Array.isArray(file.reservations)
954
962
  ? file.reservations.flatMap((reservation) => {
955
963
  const normalized = normalizeReservation(reservation);
@@ -1667,7 +1675,7 @@ export function createTelegramTopicTargetStore(options) {
1667
1675
  : undefined;
1668
1676
  },
1669
1677
  async commitSessionReplacementIntent(intent, isCurrent) {
1670
- const next = normalizeSessionReplacementIntent(intent);
1678
+ const next = normalizeTelegramSessionReplacementIntent(intent);
1671
1679
  if (!next || !isCurrent())
1672
1680
  return false;
1673
1681
  await loadFromDisk();
@@ -2081,7 +2089,10 @@ export function createTelegramTopicTargetStore(options) {
2081
2089
  replacement.expiresAtMs > getNowMs() &&
2082
2090
  replacement.profileName === (getTelegramProfile() ?? "default") &&
2083
2091
  replacement.cwd === normalizedCwd &&
2084
- replacement.sourceSessionId !== normalizedSessionId) {
2092
+ replacement.sourceSessionId !== normalizedSessionId &&
2093
+ (replacement.sourceInstanceId === undefined ||
2094
+ replacement.sourceInstanceId === instanceId ||
2095
+ replacement.sourceInstanceId === previousInstanceId)) {
2085
2096
  const sourceEntry = Array.from(workspaceBindings.entries()).find(([, binding]) => binding.cwd === normalizedCwd &&
2086
2097
  binding.sessionId === replacement.sourceSessionId &&
2087
2098
  targetMatches(binding.target, replacement.target));
@@ -3591,6 +3602,12 @@ export function createTelegramTopicTargetProvisioner(deps) {
3591
3602
  .listPendingProvisions()
3592
3603
  .find((pending) => pending.profileKey === request.profileKey ||
3593
3604
  pending.instanceId === request.instanceId);
3605
+ const pendingTarget = pendingForRequest?.target;
3606
+ if (pendingForRequest && request.workspaceBindingKey &&
3607
+ pendingForRequest.workspaceBindingKey !== request.workspaceBindingKey &&
3608
+ !(pendingTarget && deps.store.listWorkspaceBindings().some((binding) => binding.bindingKey === request.workspaceBindingKey && targetMatches(binding.target, pendingTarget)))) {
3609
+ throw new Error("Telegram unfinished Thread creation does not match this session binding.");
3610
+ }
3594
3611
  if (pendingForRequest?.target) {
3595
3612
  const target = pendingForRequest.target;
3596
3613
  const observation = deps.store.listSyncObservations().find((entry) => targetMatches(entry.target, target));
@@ -3603,8 +3620,11 @@ export function createTelegramTopicTargetProvisioner(deps) {
3603
3620
  pendingForRequest = undefined;
3604
3621
  }
3605
3622
  }
3623
+ const matchesWorkspace = (target) => !request.workspaceBindingKey ||
3624
+ deps.store.listWorkspaceBindings().some((binding) => binding.bindingKey === request.workspaceBindingKey && targetMatches(binding.target, target)) ||
3625
+ (!!pendingForRequest?.target && targetMatches(pendingForRequest.target, target));
3606
3626
  const existing = deps.store.getByProfileKey(request.profileKey);
3607
- if (existing && isCurrentThreadRecord(existing)) {
3627
+ if (existing && isCurrentThreadRecord(existing) && matchesWorkspace(existing.target)) {
3608
3628
  const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
3609
3629
  if (!slot) {
3610
3630
  throw new TelegramWorkspaceSlotUnavailableError();
@@ -3683,7 +3703,7 @@ export function createTelegramTopicTargetProvisioner(deps) {
3683
3703
  throw new Error(`Telegram topic provisioning remains ${pendingForRequest.status ?? "in-flight"} for this instance.`);
3684
3704
  }
3685
3705
  const activeForInstance = deps.store.getActiveByInstanceId(request.instanceId);
3686
- if (activeForInstance) {
3706
+ if (activeForInstance && matchesWorkspace(activeForInstance.target)) {
3687
3707
  if (!activeForInstance.slot) {
3688
3708
  throw new Error("Telegram Workspace slot reservation is unavailable.");
3689
3709
  }
@@ -3715,12 +3735,12 @@ export function createTelegramTopicTargetProvisioner(deps) {
3715
3735
  const preferredNameSlot = getTelegramThreadNameLeadingSlot(candidateThreadName) ??
3716
3736
  getNextTelegramThreadNamePaletteSlot(deps.store.list(), undefined) ??
3717
3737
  request.preferredSlot;
3718
- const slot = existing?.slot ??
3738
+ const slot = (existing && matchesWorkspace(existing.target) ? existing.slot : undefined) ??
3719
3739
  deps.store.allocateSlot(request.profileKey, isManualFollowerRequest
3720
3740
  ? request.preferredSlot
3721
3741
  : (request.preferredSlot ??
3722
3742
  (candidateThreadName ? undefined : identity?.slot) ??
3723
- preferredNameSlot), request.workspaceBindingKey);
3743
+ preferredNameSlot), request.workspaceBindingKey, { excludeCurrentRecord: !!existing && !matchesWorkspace(existing.target) });
3724
3744
  if (!slot) {
3725
3745
  throw new TelegramWorkspaceSlotUnavailableError();
3726
3746
  }
@@ -3754,6 +3774,7 @@ export function createTelegramTopicTargetProvisioner(deps) {
3754
3774
  owner: pendingOwner,
3755
3775
  instanceId: request.instanceId,
3756
3776
  profileKey: request.profileKey,
3777
+ ...(request.workspaceBindingKey ? { workspaceBindingKey: request.workspaceBindingKey } : {}),
3757
3778
  threadName: requestThreadName,
3758
3779
  ...(displayTitle ? { displayTitle } : {}),
3759
3780
  slot,
@@ -218,7 +218,7 @@ export type TelegramWorkspaceRetirementExecution = {
218
218
  };
219
219
  export type TelegramWorkspaceRetirementAbsence = "absent" | "present" | "unknown";
220
220
  export declare function executeTelegramWorkspaceRetirement(input: {
221
- store: Pick<TelegramTopicTargetStore, "captureWorkspaceSlotOccupancy" | "listWorkspaceBindings" | "listWorkspaceRetirementIntents" | "commitWorkspaceRetirement" | "removeWorkspaceRetirementIntent" | "persist">;
221
+ store: Pick<TelegramTopicTargetStore, "captureWorkspaceSlotOccupancy" | "listWorkspaceBindings" | "listWorkspaceRetirementIntents" | "commitWorkspaceRetirement" | "removeWorkspaceRetirementIntent" | "markStaleByTarget" | "persist">;
222
222
  admission: Pick<TelegramWorkspaceAdmissionLedger, "getOwner" | "read" | "acquireRetirementFence" | "adoptRetirementFence" | "issueDeletionPermit" | "confirmRetirementAbsence" | "confirmRetirementRejection" | "completeRejectedRetirementFence" | "releaseUnissuedRetirementFence" | "completeRetirementFence">;
223
223
  intent: TelegramWorkspaceRetirementIntent;
224
224
  getExternalProtection: (binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceExternalProtectionEvidence;
@@ -263,7 +263,7 @@ export type TelegramWorkspaceRetirementLifecycleResult = TelegramWorkspaceRetire
263
263
  reason: string;
264
264
  };
265
265
  export declare function runTelegramWorkspaceRetirementLifecycle(input: {
266
- store: TelegramWorkspaceRetirementPreparationDeps["store"] & Pick<TelegramTopicTargetStore, "replaceWorkspaceRetirementIntent" | "commitWorkspaceRetirement">;
266
+ store: TelegramWorkspaceRetirementPreparationDeps["store"] & Pick<TelegramTopicTargetStore, "replaceWorkspaceRetirementIntent" | "commitWorkspaceRetirement" | "markStaleByTarget">;
267
267
  getExternalProtection: (binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceExternalProtectionEvidence;
268
268
  getLeaderEpoch: () => number | string | undefined;
269
269
  getProfileKey: () => string;
@@ -605,6 +605,12 @@ export async function executeTelegramWorkspaceRetirement(input) {
605
605
  }
606
606
  return { kind: "retained", reason: "stale-intent" };
607
607
  }
608
+ if (fence?.phase === "commit-ready") {
609
+ // Exact confirmed absence retires any same-target routing record that
610
+ // survived the deletion attempt. The fence prevents replacement target
611
+ // publication while this stale local projection is removed.
612
+ input.store.markStaleByTarget(binding.target, "deleted");
613
+ }
608
614
  const eligible = () => input.store.captureWorkspaceSlotOccupancy(input.getExternalProtection, { expectedRetirement: input.intent }).bindings.find((candidate) => candidate.bindingKey === input.intent.binding.bindingKey)?.protection === "eligible";
609
615
  if (!eligible())
610
616
  return { kind: "retained", reason: "protection-changed" };
@@ -669,6 +675,9 @@ export async function executeTelegramWorkspaceRetirement(input) {
669
675
  }
670
676
  if (!isCurrent())
671
677
  return { kind: "retained", reason: "authority-changed" };
678
+ if (fence.phase === "commit-ready") {
679
+ input.store.markStaleByTarget(binding.target, "deleted");
680
+ }
672
681
  if (!eligible())
673
682
  return { kind: "retained", reason: "protection-changed" };
674
683
  if (!await input.store.commitWorkspaceRetirement(input.intent, isCurrent)) {
@@ -799,19 +808,46 @@ export async function runTelegramWorkspaceRetirementLifecycle(input) {
799
808
  });
800
809
  }
801
810
  if (intent) {
802
- const adoption = await adoptTelegramWorkspaceRetirementIntent({
803
- store: input.store,
804
- intent,
805
- getExternalProtection: input.getExternalProtection,
806
- getLeaderEpoch: input.getLeaderEpoch,
807
- getProfileKey: input.getProfileKey,
808
- isCurrent: input.isCurrent,
809
- runExclusive: input.runExclusive,
810
- });
811
- if (adoption.kind !== "adopted") {
812
- return { kind: "blocked", stage: "adoption", reason: adoption.reason };
811
+ if (retainedFence && isTelegramWorkspaceRetirementFence(retainedFence) &&
812
+ retainedFence.phase === "commit-ready" &&
813
+ matchesTelegramWorkspaceRetirementFence(retainedFence, intent)) {
814
+ const resumed = await input.runExclusive(async () => {
815
+ const epoch = input.getLeaderEpoch();
816
+ const profileKey = input.getProfileKey();
817
+ const isCurrent = () => epoch !== undefined && input.getLeaderEpoch() === epoch &&
818
+ input.getProfileKey() === profileKey && input.isCurrent?.() !== false;
819
+ if (!isCurrent() || intent.profileKey !== profileKey)
820
+ return undefined;
821
+ const intents = input.store.listWorkspaceRetirementIntents();
822
+ const binding = input.store.listWorkspaceBindings().find(candidate => candidate.bindingKey === intent.binding.bindingKey);
823
+ if (intents.length !== 1 || !isDeepStrictEqual(intents[0], intent) ||
824
+ !binding || !isDeepStrictEqual(binding, intent.binding))
825
+ return undefined;
826
+ if (intent.leaderEpoch === epoch)
827
+ return intent;
828
+ const replacement = { ...intent, leaderEpoch: epoch };
829
+ return await input.store.replaceWorkspaceRetirementIntent(intent, replacement, isCurrent)
830
+ ? replacement : undefined;
831
+ });
832
+ if (!resumed)
833
+ return { kind: "blocked", stage: "adoption", reason: "commit-rejected" };
834
+ intent = resumed;
835
+ }
836
+ else {
837
+ const adoption = await adoptTelegramWorkspaceRetirementIntent({
838
+ store: input.store,
839
+ intent,
840
+ getExternalProtection: input.getExternalProtection,
841
+ getLeaderEpoch: input.getLeaderEpoch,
842
+ getProfileKey: input.getProfileKey,
843
+ isCurrent: input.isCurrent,
844
+ runExclusive: input.runExclusive,
845
+ });
846
+ if (adoption.kind !== "adopted") {
847
+ return { kind: "blocked", stage: "adoption", reason: adoption.reason };
848
+ }
849
+ intent = adoption.intent;
813
850
  }
814
- intent = adoption.intent;
815
851
  }
816
852
  else {
817
853
  const preparation = await prepareTelegramWorkspaceRetirement({
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.51.4",
3
+ "version": "0.51.6",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -42,7 +42,7 @@ Keep this boundary explicit:
42
42
 
43
43
  ### Instance, Session, And Context Cost
44
44
 
45
- A Telegram destination follows a Pi instance, not an immutable Pi session file. Ordinary Telegram prompts enter whichever session is active in that assigned instance when dispatch occurs. If the operator replaces or resumes a session locally, pi-telegram rebinds its session-scoped runtime state while preserving the instance's Telegram target where supported. Telegram exposes compaction and new-session replacement for the active session: `/new` first completes and removes its exact durable update, then dispatches an internal registered Pi command through `pi.sendUserMessage(..., { expandPromptTemplates: true })` so the handler runs with a real `ExtensionCommandContext` and calls the same `AgentSessionRuntime.newSession()` path as the terminal. Before invoking that path, the current transport owner CAS-publishes one expiring replacement intent inside the existing profile target snapshot. The intent discriminates `workspace-thread` from `classic-chat`. A same- or cross-process successor may consume either only for the exact profile, CWD, source session, target, and fresh lifetime: Threaded continuity re-keys the retained Workspace binding and preserves Thread, slot, and display identity, while Classic continuity preserves the exact chat target without creating a Workspace binding or entering topic lifecycle APIs. A confirmed callback is acknowledged and its dialog is deleted before session replacement begins. Once the successor has delivery authority, it atomically claims the intent by CAS-clearing it and then sends one separate terminal success result with bounded in-process transport retry. The old `withSession` path never publishes success, and an unclaimed intent never sends, preventing duplicate terminal notices across same-process startup and cleanup-ack ambiguity. Mismatch or expiry never authorizes binding takeover. Resume, fork, tree navigation, session switching, and full reload remain outside the stable Telegram API until Pi exposes safe public extension hooks for them.
45
+ Live Telegram routing belongs to the active authenticated Pi instance, while a durable Workspace Thread belongs to the exact profile/CWD/session identity. Ordinary Telegram prompts enter the session currently bound to that target. Local `/resume` replaces session-scoped runtime state and selects the destination session's binding; it never inherits the source Thread merely because the process is unchanged. Telegram exposes compaction and new-session replacement for the active session: `/new` first completes and removes its exact durable update, then dispatches an internal registered Pi command through `pi.sendUserMessage(..., { expandPromptTemplates: true })` so the handler runs with a real `ExtensionCommandContext` and calls the same `AgentSessionRuntime.newSession()` path as the terminal. Before invoking that path, the current transport owner CAS-publishes one expiring replacement intent inside the existing profile target snapshot. The intent discriminates `workspace-thread` from `classic-chat`. A same- or cross-process successor may consume either only for the exact profile, CWD, source session, target, and fresh lifetime: Threaded continuity re-keys the retained Workspace binding and preserves Thread, slot, and display identity, while Classic continuity preserves the exact chat target without creating a Workspace binding or entering topic lifecycle APIs. A confirmed callback is acknowledged and its dialog is deleted before session replacement begins. Once the successor has delivery authority, it atomically claims the intent by CAS-clearing it and then sends one separate terminal success result with bounded in-process transport retry. The old `withSession` path never publishes success, and an unclaimed intent never sends, preventing duplicate terminal notices across same-process startup and cleanup-ack ambiguity. Mismatch or expiry never authorizes binding takeover. Resume, fork, tree navigation, session switching, and full reload remain outside the stable Telegram API until Pi exposes safe public extension hooks for them.
46
46
 
47
47
  `/telegram-connect` never launches a hidden or headless Pi process. A long-lived background Pi process can own Telegram only when something else explicitly launched that process and it satisfies the normal lock/runtime rules. Pi `print` and `json` modes stay passive and exit rather than becoming hidden polling owners.
48
48
 
@@ -90,8 +90,8 @@ The repository uses a **Flat Domain DAG**:
90
90
  - `generative-apps`: managed deterministic application identity, canonical installation and explicit replacement, content-addressed module loading, state timelines, cross-process transition serialization, bounded executable-plus-argv adaptation, `telegram_bind`, and pre-model-queue `app::method` invocation. It does not own Telegram transport, arbitrary shell execution, or the external application adapted by one Generative App.
91
91
  - `outbound-attachments`: `telegram_attach`, queued outbound files, stat/limit checks, ordinary photo/document delivery, and narrow single-artifact Rich Message planning/sending for probe-confirmed photo/video/audio formats. It owns known-failure fallback eligibility and ambiguous-send no-replay classification through structural error contracts without importing Bot API helpers.
92
92
  - `channel-posts`: profile/token-bound authority for agent-authored channel publication intents, one-shot send/edit/delete fencing, outcome-unknown retention, exact successful post identity, capacity refusal, and bounded local listing. It never reads Telegram history; the composition root supplies direct-leader Bot API effects only after its durable grants.
93
- - `status` / `logs`: status bar/status-message rendering, queue-lane summaries, the structural redacted event ring, profile-aware JSONL scope/reset/append behavior, exact-owner destructive commits, fail-soft synchronous and queued diagnostics persistence, status snapshot scheduling, and grouped diagnostics. `status` remains a structural leaf; `logs` composes filesystem evidence with status projections and contains every persistence failure so diagnostics cannot terminate or poison the runtime queue.
94
- - `bindings` / `lifecycle` / `prompts` / `prompt-templates` / `pi`: Pi-facing command/tool/hook registration and cohesive cross-domain binding assembly, including queue mutation/dispatch/watchdog composition over admission and transport ports; session-generation fencing and start/shutdown sequencing across Queue, grouped input, Delivery, polling, capability monitor, follower refresh, and assistant-output projection; Telegram prompt guidance; prompt-template discovery/expansion; and centralized direct Pi SDK imports.
93
+ - `status` / `logs`: status bar/status-message rendering, queue-lane summaries, the structural redacted event ring, profile-aware JSONL scope/reset/append behavior, exact-owner destructive commits, fail-soft synchronous and queued diagnostics persistence, status snapshot scheduling, grouped diagnostics, and allowlisted compact connection-failure copy shared by commands and lifecycle. `status` remains a structural leaf; `logs` composes filesystem evidence with status projections and contains every persistence failure so diagnostics cannot terminate or poison the runtime queue.
94
+ - `bindings` / `lifecycle` / `prompts` / `prompt-templates` / `pi`: Pi-facing command/tool/hook registration and cohesive cross-domain binding assembly, including queue mutation/dispatch/watchdog composition over admission and transport ports; session-generation fencing and start/shutdown sequencing across Queue, grouped input, Delivery, polling, capability monitor, follower refresh, and assistant-output projection; Telegram prompt guidance; prompt-template discovery/expansion; and centralized direct Pi SDK imports. `lifecycle` also owns bounded same-process connect intent across local `/resume`, cancellation/supersession, and fresh-context startup scheduling; it does not own transport authority, durable bindings, queue custody, or source-target continuity.
95
95
  - `command-templates`: shell-free command-template helpers, composition expansion, placeholder substitution, executable resolution, warnings, and retry/timeout semantics.
96
96
 
97
97
  ### Host Compatibility Boundary
@@ -326,14 +326,15 @@ Read-only migration probes constrain that comparison. Publishing a v2 snapshot o
326
326
  ### Runtime Ownership
327
327
 
328
328
  - `/telegram-connect` acquires or moves the active profile's owner slot before polling starts. `/telegram-disconnect` keeps its destructive confirmation, then stops polling and releases only that exact slot. In Threaded Mode it tears down the disconnecting instance's bound Telegram thread: leaders delete their own thread directly, and followers send an authenticated exact-generation disconnect envelope and wait for confirmed leader cleanup before unregistering. Graceful Pi `quit` always preserves the owner slot as restart intent, allowing a reopened same-`cwd` session to reclaim the stale lease. When `threads.automaticCleanup` is enabled (the default), quit also deletes the bound Telegram tab without releasing that restart intent; disabling it preserves the tab. After delivery, polling and inbound-worker shutdown complete, a quitting leader detaches its exact owner record under profile admission while retaining the Workspace, letter and restart ownership. Publication rechecks the captured session generation, profile, leader epoch and successful current-generation suspension; unfinished startup/teardown, unproven suspension and pending cleanup stay protected. Preparation/publication errors are diagnostic and cannot block remaining session cleanup. Failed automatic deletion likewise falls back to safe suspension.
329
- - Session start schedules polling resume asynchronously only when the owner slot already points at the current `pid`/`cwd`, or when a stale same-`cwd` owner can be safely replaced after process restart. Under a live foreign leader, startup instead attempts capability-gated restore-only follower admission when the selected profile has a remembered exact-`cwd` Workspace binding; it never provisions an unremembered Workspace. Startup and `/resume` do not wait on leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
329
+ - Without an explicit carried connection intent, session start schedules polling resume asynchronously only when the owner slot already points at the current `pid`/`cwd`, or when a stale same-`cwd` owner can be safely replaced after process restart. Under a live foreign leader, startup instead attempts capability-gated restore-only follower admission when the selected profile has a remembered exact-`cwd` Workspace binding; it never provisions an unremembered Workspace. Startup and `/resume` do not wait on leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
330
+ - On local `/resume`, an in-flight `/telegram-connect` or established connection supplies one process-local, 30-second intent containing the selected profile, exact CWD and target session file. The file matches only the lifecycle successor; the fresh public session ID still owns binding selection. Startup claims intent before asynchronous initialization and uses the normal connection path instead of concurrent auto-restore. Quit/reload/disconnect, mismatch, expiry or supersession cancel it. The predecessor carries no Pi context, callback, Thread or slot, and late completion can clear only its own attempt. Resume startup has no automatic forced takeover or repeated failure retry; errors remain diagnostic with a compact recovery notice.
330
331
  - The polling owner alone bounds `getUpdates`: each request derives its cancellation budget from Telegram's declared long-poll timeout plus 10 seconds of transport grace (10 seconds for the zero-timeout initial sync and 40 seconds for the normal 30-second poll). The request-local controller inherits poller cancellation, rejects its owner at the budget, and fences any late transport result. Ordinary Bot API and media operations do not receive speculative blanket deadlines. Existing caller signals remain authoritative through API retry waits, only retry-safe methods replay explicit retryable responses, and non-idempotent sends preserve commit-unknown evidence instead of risking duplicate mutation.
331
332
  - Ten consecutive `getUpdates` conflict responses, including initial cursor sync, terminate polling with `persistent-conflict`; a successful response or a different error resets the count. The controller detaches its inner promise before notifying the locked lifecycle, avoiding teardown waiting on itself. That lifecycle stops ownership checks, lease refresh, capability monitoring, typing, and classic or bus transport (including leader health, pruning, and IPC), withdraws local direct authority, and transactionally releases only its exact lock. A failed durable release leaves local authority revoked; explicit reacquisition mints a fresh epoch. Accepted queue receipts and local Pi dispatch survive. One terminal diagnostic distinguishes lost local ownership from a competing client despite an apparently owned lock and reports cleanup failures; ordinary status refreshes retain generic `error` until transport recovers. Remove the competing client, then use `/telegram-connect`; no automatic retry continues after the terminal threshold.
332
333
  - Manual and automatic polling starts share a lifecycle generation. A later suspend, disconnect, persistent-conflict stop, or accepted start invalidates older startup continuations; reconnect captures its generation before waiting for transport teardown and rechecks it before acquiring ownership and after awaited startup work. Obsolete completion or failure cannot report a successful connection or roll back a replacement. Admission rejects stale/unauthorized contexts before advancing its own startup generation, so a rejected call cannot cancel valid in-flight initialization. After awaited bus startup, thread-aware completion checks its generation before starting leader health or changing fallback/startup-option state; teardown clears health independently of the current mode flag. Startup probes, capability-monitor transitions, and observed-target transitions share an orchestration lifecycle fence and check it inside their effect-owning helpers after awaited queries, persistence, or transport work. Monitor stop also invalidates pending observations. These checks suppress subsequent state changes, fallback, health, and status effects; already-issued API/persistence calls retain their own transport/storage fencing.
333
334
  - `pollingActive` reports only whether this runtime still owns an unresolved polling lifecycle; it is not health evidence. A separate observable state records `starting`, `long-poll`, `persisting-journal`, `persisting-offset`, `retrying`, or `stopped`, together with phase start, current update id, last successful response time/count, and terminal stop reason. This distinguishes a stuck HTTP poll from downstream update work without a wall-clock stale heuristic.
334
335
  - Built-in read-only menu commands return after required local mutation and schedule context-fenced rendering and command synchronization independently, so those effects cannot withhold the next inbound offset.
335
336
  - Pi `print`/`json` run modes stay passive. Inherited child sessions that share `telegram.json` but do not own the exact `pid`/`cwd` slot must not poll or call `getUpdates` unless the operator force-takes ownership.
336
- - Session replacement through `reload`, `new`, `resume`, or `fork` suspends polling/watchers without releasing ownership or publishing inactivity so the next session in the same process can resume. Late shutdown cannot clear a replacement context even when its identity is reused: the captured session generation must still match. A registered follower snapshots its assigned target into a short-lived same-process handoff, stops the old receiver/heartbeat, and re-registers through the live leader without marking or replacing its Telegram thread. Hard process termination cannot run graceful teardown, so stale recovery retains its restart-hint path.
337
+ - Session replacement through `reload`, `new`, `resume`, or `fork` suspends polling/watchers without releasing ownership or publishing inactivity so the next session in the same process can resume. Late shutdown cannot clear a replacement context even when its identity is reused: the captured session generation must still match. For replacements other than `/resume`, a registered follower snapshots its assigned target into a short-lived same-process handoff, stops the old receiver/heartbeat, and re-registers through the live leader without marking or replacing its Telegram thread. Local `/resume` suppresses that source-target handoff and selects the destination session's binding instead. Hard process termination cannot run graceful teardown, so stale recovery retains its restart-hint path.
337
338
  - Live external owners require explicit takeover confirmation. Long-lived timers compare against snapshotted owner identity and stop local transport work when the slot no longer matches.
338
339
  - `owners.json` owns only Telegram transport control. Local extension and accepted queue state remain per Pi instance when ownership moves, but previews, final delivery, dispatch transport mutations, and delayed Bot API work fail closed until exact direct or follower authority becomes valid again.
339
340
  - Exact ownership remains checked every second, while the durable owner heartbeat refresh runs every two seconds and becomes stale after eight seconds. This keeps replacement detection responsive while halving steady-state atomic `owners.json` rewrites without changing the cross-platform file-transaction authority. Every acquisition, refresh, release, takeover, and stale recovery serializes through the sibling `owners.json.transaction` guard. The guard publishes one private generation-named owner record atomically, validates filename/payload generation agreement, fences stale recovery and delayed release against replacement-owner ABA, and fails closed on malformed state, unverifiable ownership, contention timeout, or unsupported filesystem behavior. The JSON store publishes through a private same-directory temporary file and atomic rename; atomic payload replacement does not replace transaction serialization.
@@ -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
 
@@ -208,6 +208,8 @@ The persisted binding stores the exact session ID and derives its new binding ke
208
208
 
209
209
  Legacy cwd-only bindings remain exact compatibility identities whose absent session component is itself a unique key. They are neither wildcard session bindings nor adoption candidates: session-aware claims never stamp, migrate, consume, or restore them, and instead allocate their own `{ cwd, sessionId }` binding subject to the same global slot and protection rules. `{ cwd, ∅ }`, `{ cwd, session-a }`, and `{ cwd, session-b }` may coexist as majestic degradation for older state and peers. The 0.46 runtime reads strict legacy records as inert independent `{ cwd, ∅ }` bindings but never migrates, adopts, or routes a session through them. Protocol v2 is session-native and intentionally incompatible with protocol-v1 0.45.x peers. Upgrade requires stopping all Telegram instances first, updating them together, and then starting them again; the first v2 process becomes leader with its own session-qualified binding.
210
210
 
211
+ Local `/resume` suppresses the same-process source-target handoff. A carried explicit connect intent or established connection starts the destination through normal owner acquisition/follower registration before ordinary auto-restore; it restores that session's exact binding or provisions a new one. Pending Thread-creation evidence records its Workspace binding key. Recovery rejects a different or unproven session key without consuming evidence or repeating creation; legacy pending evidence is reusable only when its target already proves the exact requested binding. Profile/CWD equality and a shared PID never authorize session reassignment. Telegram `/new` remains the separately authenticated, durable continuity exception described above.
212
+
211
213
  Routing authority remains the authenticated live owner plus exact numeric Telegram target. Session identity selects and restores a durable binding but never authorizes message delivery by itself. Slot pressure and retirement continue to operate on exact binding snapshots; they must carry the session component so cleanup, displacement, and letter reuse cannot target another session in the same directory.
212
214
 
213
215
  ## Leader Election
@@ -226,7 +228,7 @@ Followers first try to re-register after leader reload or unknown-heartbeat resp
226
228
 
227
229
  ### Protocol identity and compatibility
228
230
 
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.
231
+ 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
232
 
231
233
  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
234
 
@@ -40,8 +40,8 @@ import {
40
40
  Stable commands inside Pi:
41
41
 
42
42
  - `/telegram-setup` — configure/update the bot token.
43
- - `/telegram-connect` — start polling here and acquire external Telegram control ownership, optionally for a named profile. Thread names are configured from Telegram rather than Pi commands. Accepted queue/reply state stays local if ownership later moves elsewhere. A successful command queues a hidden connection-state note for delivery with the agent's next turn without triggering one; it says Telegram is connected and that connectivity alone is not user intent.
44
- - `/telegram-disconnect` — after destructive confirmation, stop polling and release ownership without deleting or silencing accepted local queue state. A successful command queues the corresponding hidden, non-triggering disconnected context note; cancelled or failed disconnects do not publish a false state transition. In Threaded Mode it deletes this instance's current Telegram thread; a follower waits for its active leader to confirm generation-fenced cleanup before stopping. Graceful Pi `quit` performs the same teardown without prompting, while `reload`, `new`, `resume`, and `fork` preserve same-process handoff.
43
+ - `/telegram-connect` — start polling here and acquire external Telegram control ownership, optionally for a named profile. Thread names are configured from Telegram rather than Pi commands. Accepted queue/reply state stays local if ownership later moves elsewhere. A successful command queues a hidden connection-state note for delivery with the agent's next turn without triggering one; it says Telegram is connected and that connectivity alone is not user intent. An immediate local `/resume` carries pending connection intent and the selected profile to the destination session without carrying the source Thread; its own binding is restored or allocated through normal authority checks. Connection failures produce one compact cause/action notice instead of rethrowing raw exception text into Pi; redacted technical evidence remains available through `/telegram-status --debug`.
44
+ - `/telegram-disconnect` — after destructive confirmation, stop polling and release ownership without deleting or silencing accepted local queue state. A successful command queues the corresponding hidden, non-triggering disconnected context note; cancelled or failed disconnects do not publish a false state transition. In Threaded Mode it deletes this instance's current Telegram thread; a follower waits for its active leader to confirm generation-fenced cleanup before stopping. Graceful Pi `quit` performs the same teardown without prompting, while session replacement suspends transport without disconnect teardown. Local `/resume` carries connection intent rather than the source Thread; other supported replacements retain their existing continuity rules.
45
45
  - `/telegram-status` — show connection, polling, execution, queue, and recent event diagnostics; debug output separates poller and worker progress, durable automatic-retry state, exact foreign queued-owner identity, and negotiated protocol/build/capabilities.
46
46
 
47
47
  ### Telegram commands
@@ -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.
package/docs/ui-style.md CHANGED
@@ -218,6 +218,10 @@ Examples:
218
218
  - Main menu → Settings: first row is `⬆️ Main menu`.
219
219
  - Settings → Voice reply mode: first row is `⬆️ Back`.
220
220
 
221
+ ## Pi Connection Notices
222
+
223
+ Pi TUI connection notices use plain text: a short known cause and one recovery action. Unknown failures use a generic connection-failed notice with `/telegram-status --debug`; never interpolate raw exceptions, credentials, stack traces or Pi lifecycle guidance. Technical evidence belongs in the redacted runtime recorder. Failed disconnect must retain the instruction to keep Pi open; do not also rethrow the same error as a second Pi banner. These TUI notices are distinct from Telegram bot message cards below.
224
+
221
225
  ## Message Cards
222
226
 
223
227
  Message cards and standalone informational notices sent by the bot should start with a strong heading.
package/lib/bindings.ts CHANGED
@@ -602,6 +602,9 @@ interface TelegramCommandsAndToolsBindingDeps {
602
602
  | undefined,
603
603
  ) => void;
604
604
  updateStatus: TelegramBridgeStatusUpdater;
605
+ isContextCurrent: (ctx: Pi.ExtensionContext) => boolean;
606
+ getSessionGeneration: () => number;
607
+ connectionIntent: NonNullable<Commands.TelegramBridgeCommandRegistrationDeps["connectionIntent"]>;
605
608
  recordRuntimeEvent: TelegramRuntimeEventRecorder;
606
609
  }
607
610
 
@@ -633,6 +636,9 @@ export function registerTelegramCommandsAndTools({
633
636
  setGenerativeAppLiveSurfaceRuntime,
634
637
  recordRuntimeEvent,
635
638
  updateStatus,
639
+ isContextCurrent,
640
+ getSessionGeneration,
641
+ connectionIntent,
636
642
  }: TelegramCommandsAndToolsBindingDeps): void {
637
643
  GenerativeApps.registerTelegramBindTool(pi, {
638
644
  agentDir,
@@ -783,38 +789,39 @@ export function registerTelegramCommandsAndTools({
783
789
  reloadConfig: configStore.load,
784
790
  hasBotToken: configStore.hasBotToken,
785
791
  getBotTokenDiagnostic: configStore.getBotTokenDiagnostic,
786
- startPolling: async (ctx, options) => {
787
- try {
788
- return await lockedPollingRuntime.start(ctx, options);
789
- } catch (error) {
790
- recordRuntimeEvent("recovery", error, { phase: "polling-start" });
791
- throw error;
792
- }
793
- },
792
+ startPolling: lockedPollingRuntime.start,
793
+ recordConnectionEvent: (error, phase) => recordRuntimeEvent("connection", error, { phase }),
794
794
  stopPolling: stopPolling ?? lockedPollingRuntime.stop,
795
795
  recoverPollingStart,
796
796
  getDisconnectThreadName,
797
797
  queueAgentConnectionContext,
798
798
  updateStatus,
799
+ isContextCurrent,
800
+ getSessionGeneration,
801
+ connectionIntent,
799
802
  getProfileNames: () =>
800
803
  Config.getTelegramProfileNames(configStore.getStoredConfig()),
801
- activateDefaultProfileConfig: async () => {
804
+ activateDefaultProfileConfig: async (_ctx, isCurrent) => {
802
805
  const previousProfileName = configStore.getActiveProfileName();
803
806
  await configStore.load();
807
+ if (!isCurrent()) return;
804
808
  if (previousProfileName) {
805
809
  await (stopPolling ?? lockedPollingRuntime.stop)();
810
+ if (!isCurrent()) return;
806
811
  }
807
812
  configStore.activateProfile(undefined);
808
813
  await onTransportChanged?.();
809
814
  },
810
- activateProfileConfig: async (_ctx, profileName) => {
815
+ activateProfileConfig: async (_ctx, profileName, isCurrent) => {
811
816
  const previousProfileName = configStore.getActiveProfileName();
812
817
  await configStore.load();
818
+ if (!isCurrent()) return false;
813
819
  if (!Config.isValidTelegramProfileName(profileName)) return false;
814
820
  const storedConfig = configStore.getStoredConfig();
815
821
  if (!storedConfig.profiles?.[profileName]) return false;
816
822
  if (previousProfileName !== profileName) {
817
823
  await (stopPolling ?? lockedPollingRuntime.stop)();
824
+ if (!isCurrent()) return false;
818
825
  }
819
826
  if (!configStore.activateProfile(profileName)) return false;
820
827
  await onTransportChanged?.();