@llblab/pi-kit 0.35.1 → 0.35.2

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 (43) hide show
  1. package/BACKLOG.md +1 -0
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +1 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -1
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -11
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +12 -0
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +22 -0
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +75 -16
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +1 -1
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +11 -1
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +30 -19
  12. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +21 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +36 -0
  14. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +80 -0
  15. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +2 -1
  16. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +18 -3
  17. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +13 -0
  18. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +33 -12
  19. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +11 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +31 -8
  21. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +2 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +2 -1
  23. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +5 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +5 -1
  25. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +3 -2
  26. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +6 -2
  27. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +57 -12
  28. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  29. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +3 -1
  30. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +111 -15
  31. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -0
  32. package/node_modules/@llblab/pi-telegram/lib/bus.ts +30 -21
  33. package/node_modules/@llblab/pi-telegram/lib/extension.ts +21 -1
  34. package/node_modules/@llblab/pi-telegram/lib/logging.ts +122 -0
  35. package/node_modules/@llblab/pi-telegram/lib/paths.ts +22 -3
  36. package/node_modules/@llblab/pi-telegram/lib/polling.ts +44 -10
  37. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +40 -7
  38. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +8 -5
  39. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +6 -1
  40. package/node_modules/@llblab/pi-telegram/lib/threads.ts +12 -5
  41. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +69 -17
  42. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  43. package/package.json +2 -2
@@ -111,10 +111,25 @@ export function getTelegramProfilePathSuffix(profileName) {
111
111
  export function resolveTelegramProfileTempFilePath(baseName, extension, agentDir = resolveAgentDir(), profileName) {
112
112
  return join(resolveTelegramTempDir(agentDir), `${baseName}${getTelegramProfilePathSuffix(profileName)}.${extension}`);
113
113
  }
114
- export function getTelegramDiagnosticsDisplayPaths(_profileName) {
114
+ /** Shared state/log paths for every profile, under the resolved agent dir (honors `PI_CODING_AGENT_DIR`). */
115
+ export function getTelegramDiagnosticsDisplayPaths(_profileName, agentDir = resolveAgentDir()) {
116
+ // Storage paths are canonical, so also match a canonical home (e.g. /home -> /var/home).
117
+ const homes = [homedir()];
118
+ try {
119
+ homes.push(realpathSync(homes[0]));
120
+ }
121
+ catch {
122
+ /* An unreadable home only loses the ~ abbreviation. */
123
+ }
124
+ const display = (path) => {
125
+ const home = homes.find((candidate) => path === candidate || path.startsWith(`${candidate}${sep}`));
126
+ return home
127
+ ? `~${path.slice(home.length).split(sep).join("/")}`
128
+ : path;
129
+ };
115
130
  return {
116
- state: "~/.pi/agent/tmp/pi-telegram/state.json",
117
- logs: "~/.pi/agent/tmp/pi-telegram/logs.jsonl",
131
+ state: display(resolveTelegramStatePath(agentDir)),
132
+ logs: display(resolveTelegramRuntimeLogPath(agentDir)),
118
133
  };
119
134
  }
120
135
  /** Durable Workspace admission ledger (<agentDir>/tmp/pi-telegram/workspace-admission[.<profile>].json). */
@@ -4,6 +4,15 @@
4
4
  * Owns polling request builders, stop conditions, and the long-poll loop runtime for Telegram updates
5
5
  */
6
6
  type MaybePromise<T> = T | Promise<T>;
7
+ /**
8
+ * Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
9
+ * again, and the closing success records one outcome. The composition root owns the collapsed implementation.
10
+ */
11
+ export interface TelegramRepeatedFailureLogPort {
12
+ record: (error: unknown, details?: Record<string, unknown>) => void;
13
+ settled: () => void;
14
+ reset: () => void;
15
+ }
7
16
  export interface TelegramPollingConfig {
8
17
  botToken?: string;
9
18
  }
@@ -174,6 +183,8 @@ export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThread
174
183
  getNowMs?: () => number;
175
184
  }
176
185
  export interface TelegramThreadCapabilityRuntimeDeps<TContext> extends TelegramThreadCapabilityReaderDeps {
186
+ /** Collapses one sustained probe failure run; the caller supplies its owner. */
187
+ failureLog: TelegramRepeatedFailureLogPort;
177
188
  lifecycle?: TelegramThreadCapabilityLifecycle;
178
189
  topicTargetStore: TelegramThreadCapabilityStore;
179
190
  ownsLock: (ctx: TContext) => boolean;
@@ -251,6 +262,8 @@ export interface TelegramThreadAwarePollingDeps<TContext, TOwner> extends Telegr
251
262
  setForceFreshLeaderThreadOnNextStart: (forceFresh: boolean) => void;
252
263
  }
253
264
  export interface TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner> extends TelegramThreadCapabilityReaderDeps, TelegramThreadAwarePollingControls<TContext, TOwner> {
265
+ /** Collapses one sustained probe failure run; the composition root supplies its owner. */
266
+ failureLog: TelegramRepeatedFailureLogPort;
254
267
  state: TelegramThreadCapabilityStateRuntime;
255
268
  topicTargetStore: TelegramThreadCapabilityStore;
256
269
  ownsLock: (ctx: TContext) => boolean;
@@ -471,6 +471,7 @@ export function createTelegramThreadCapabilityOrchestration(deps) {
471
471
  isTopicModeUnavailableError: deps.isTopicModeUnavailableError,
472
472
  updateStatus: deps.updateStatus,
473
473
  recordEvent: deps.recordEvent,
474
+ failureLog: deps.failureLog,
474
475
  };
475
476
  return {
476
477
  monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
@@ -813,8 +814,37 @@ export function createTelegramThreadCapabilityMonitor(deps) {
813
814
  let generation = 0;
814
815
  let transitionPromise;
815
816
  let consecutiveDisabledProbes = 0;
817
+ // An absent leader endpoint repeats one identical probe failure every interval; the port collapses the run.
818
+ // Running it through one guard keeps a faulty diagnostics port from breaking probes or the interval callback.
819
+ const capabilityFailureLog = {
820
+ record(error, details) {
821
+ try {
822
+ deps.failureLog.record(error, details);
823
+ }
824
+ catch {
825
+ // Monitor diagnostics cannot create an uncaught interval exception.
826
+ }
827
+ },
828
+ settled() {
829
+ try {
830
+ deps.failureLog.settled();
831
+ }
832
+ catch {
833
+ // Monitor diagnostics cannot fail a completed probe.
834
+ }
835
+ },
836
+ reset() {
837
+ try {
838
+ deps.failureLog.reset();
839
+ }
840
+ catch {
841
+ // Monitor diagnostics cannot fail a monitor stop.
842
+ }
843
+ },
844
+ };
816
845
  const stop = () => {
817
846
  generation += 1;
847
+ capabilityFailureLog.reset();
818
848
  if (interval)
819
849
  clearInterval(interval);
820
850
  interval = undefined;
@@ -832,6 +862,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
832
862
  .then(async (threadModeEnabled) => {
833
863
  if (!isCurrent())
834
864
  return;
865
+ capabilityFailureLog.settled();
835
866
  if (threadModeEnabled === undefined) {
836
867
  if (deps.topicTargetStore.getBotState().threadMode !== "enabled" &&
837
868
  !deps.getPollingStartedWithTelegramBus() &&
@@ -879,12 +910,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
879
910
  .catch((error) => {
880
911
  if (!isCurrent())
881
912
  return;
882
- try {
883
- deps.recordEvent("bus", error, { phase: "capability-monitor" });
884
- }
885
- catch {
886
- // Monitor diagnostics cannot create an unhandled interval rejection.
887
- }
913
+ capabilityFailureLog.record(error);
888
914
  })
889
915
  .finally(() => {
890
916
  if (transitionPromise === tracked)
@@ -911,12 +937,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
911
937
  catch {
912
938
  /* Timer shutdown must not escape the callback. */
913
939
  }
914
- try {
915
- deps.recordEvent("bus", error, { phase: "capability-monitor" });
916
- }
917
- catch {
918
- // Monitor diagnostics cannot create an uncaught interval exception.
919
- }
940
+ capabilityFailureLog.record(error);
920
941
  }
921
942
  }, intervalMs);
922
943
  interval.unref?.();
@@ -4,6 +4,15 @@
4
4
  * Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
5
5
  */
6
6
  import * as Machine from "./machine.ts";
7
+ /**
8
+ * Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
9
+ * again, and the closing success records one outcome. The composition root owns the collapsed implementation.
10
+ */
11
+ export interface TelegramRepeatedFailureLogPort {
12
+ record: (error: unknown, details?: Record<string, unknown>) => void;
13
+ settled: () => void;
14
+ reset: () => void;
15
+ }
7
16
  export interface TelegramRuntimeQueueCounters {
8
17
  nextQueuedTelegramItemOrder: number;
9
18
  nextQueuedTelegramControlOrder: number;
@@ -103,6 +112,8 @@ export interface TelegramRuntimeEventRecorderPort {
103
112
  recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
104
113
  }
105
114
  export interface TelegramTypingLoopStarterDeps<TContext> extends TelegramRuntimeEventRecorderPort {
115
+ /** Collapses one sustained presence failure run; the caller supplies its owner. */
116
+ failureLog: TelegramRepeatedFailureLogPort;
106
117
  typing: TelegramRuntimeTypingPort;
107
118
  getDefaultChatId: () => number | undefined;
108
119
  sendTypingAction: (chatId: number, options?: {
@@ -162,6 +162,34 @@ function updateTelegramRuntimeStatusSafely(updateStatus, ctx, options) {
162
162
  }
163
163
  export function createTelegramTypingLoopStarter(deps) {
164
164
  let currentOwner;
165
+ // An unreachable transport repeats one identical presence failure every tick; the port collapses the run.
166
+ // Running it through one guard keeps a faulty diagnostics port from escaping the in-flight action owner.
167
+ const typingFailureLog = {
168
+ record(error, details) {
169
+ try {
170
+ deps.failureLog.record(error, details);
171
+ }
172
+ catch {
173
+ // Typing diagnostics cannot escape the in-flight action owner.
174
+ }
175
+ },
176
+ settled() {
177
+ try {
178
+ deps.failureLog.settled();
179
+ }
180
+ catch {
181
+ // A successful presence outranks its own diagnostic.
182
+ }
183
+ },
184
+ reset() {
185
+ try {
186
+ deps.failureLog.reset();
187
+ }
188
+ catch {
189
+ // Typing diagnostics cannot fail a loop stop.
190
+ }
191
+ },
192
+ };
165
193
  return (ctx, chatId, options) => {
166
194
  const sessionGeneration = deps.getSessionGeneration?.();
167
195
  const transportAuthority = deps.getTransportAuthority?.();
@@ -214,6 +242,7 @@ export function createTelegramTypingLoopStarter(deps) {
214
242
  throw new Error("Typing context authority changed.");
215
243
  },
216
244
  });
245
+ typingFailureLog.settled();
217
246
  }
218
247
  catch (error) {
219
248
  if (!active)
@@ -222,19 +251,13 @@ export function createTelegramTypingLoopStarter(deps) {
222
251
  deps.typing.stop();
223
252
  return;
224
253
  }
225
- try {
226
- deps.recordRuntimeEvent?.("typing", error, {
227
- chatId: targetChatId,
228
- });
229
- }
230
- catch {
231
- // Typing diagnostics cannot escape the in-flight action owner.
232
- }
254
+ typingFailureLog.record(error, { chatId: targetChatId });
233
255
  }
234
256
  },
235
257
  shouldContinue: canContinue,
236
258
  onStopped: () => {
237
259
  active = false;
260
+ typingFailureLog.reset();
238
261
  if (currentOwner === owner)
239
262
  currentOwner = undefined;
240
263
  },
@@ -299,6 +299,8 @@ export interface TelegramApiCallOptions {
299
299
  sleep?: (ms: number) => Promise<void>;
300
300
  /** Observability hook fired before a 429 retry wait, never for 5xx waits. */
301
301
  onRetryWait?: (wait: TelegramApiRetryWait) => void;
302
+ /** Caller-owned expected failure: still thrown, but the caller records the outcome instead of the API log. */
303
+ isExpectedError?: (error: unknown) => boolean;
302
304
  }
303
305
  export declare class TelegramApiAuthorityError extends Error {
304
306
  readonly requestIssued: boolean;
@@ -1134,7 +1134,8 @@ export function createTelegramBridgeApiRuntime(deps) {
1134
1134
  "Telegram API deleteMessage failed: HTTP 400: Bad Request: message to delete not found") {
1135
1135
  return true;
1136
1136
  }
1137
- deps.recordRuntimeEvent("api", error, withTelegramTransportDiagnostics(error, { method }));
1137
+ if (!options?.isExpectedError?.(error))
1138
+ deps.recordRuntimeEvent("api", error, withTelegramTransportDiagnostics(error, { method }));
1138
1139
  throw error;
1139
1140
  }
1140
1141
  };
@@ -339,7 +339,11 @@ export async function applyThreadReconciliationPlan(plan, ports) {
339
339
  }
340
340
  let deleteConfirmed = false;
341
341
  let superseded = false;
342
- for (const method of ports.skipCloseBeforeDelete
342
+ // Private-chat topics (positive chat ids) cannot be closed; Telegram only supports closeForumTopic in forums.
343
+ const skipClose = ports.skipCloseBeforeDelete ||
344
+ (action.kind === "close-delete-graceful-shutdown-topic" &&
345
+ action.target.chatId > 0);
346
+ for (const method of skipClose
343
347
  ? ["deleteForumTopic"]
344
348
  : ["closeForumTopic", "deleteForumTopic"]) {
345
349
  if (ports.isCleanupTargetProtected?.(action.target, action)) {
@@ -157,6 +157,10 @@ export interface TelegramWorkspaceExternalProtectionEvidence {
157
157
  acceptedWork: TelegramWorkspaceProtectionState;
158
158
  deliveryAuthority: TelegramWorkspaceProtectionState;
159
159
  }
160
+ /** Per-binding probe; `withSnapshot` lets one occupancy capture share binding-independent evidence reads. */
161
+ export type TelegramWorkspaceExternalProtectionCapture = ((binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceExternalProtectionEvidence) & {
162
+ withSnapshot?: <T>(capture: () => T) => T;
163
+ };
160
164
  export interface TelegramWorkspaceSlotOccupancySnapshot {
161
165
  bindings: TelegramWorkspaceSlotOccupancy[];
162
166
  reservedSlots: string[];
@@ -549,7 +553,7 @@ export interface TelegramTopicTargetStore {
549
553
  bindingUpdatedAtMs: number;
550
554
  }, isCurrent: () => boolean) => Promise<boolean>;
551
555
  /** Caller must separately prove no external live owner, accepted work, or delivery authority. */
552
- captureWorkspaceSlotOccupancy: (getExternalProtection: (binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceExternalProtectionEvidence, options?: {
556
+ captureWorkspaceSlotOccupancy: (getExternalProtection: TelegramWorkspaceExternalProtectionCapture, options?: {
553
557
  expectedRetirement?: TelegramWorkspaceRetirementIntent;
554
558
  }) => TelegramWorkspaceSlotOccupancySnapshot;
555
559
  hasWorkspaceBinding: (cwd: string, sessionId?: string) => boolean;
@@ -4681,7 +4681,8 @@ export function createTelegramTopicTargetStore(options) {
4681
4681
  },
4682
4682
  captureWorkspaceSlotOccupancy(getExternalProtection, options) {
4683
4683
  const nowMs = getNowMs();
4684
- const bindings = Array.from(workspaceBindings.values()).map((binding) => {
4684
+ const inSnapshot = getExternalProtection.withSnapshot ?? ((capture) => capture());
4685
+ const bindings = inSnapshot(() => Array.from(workspaceBindings.values()).map((binding) => {
4685
4686
  const slot = binding.slot?.toLowerCase() ?? "";
4686
4687
  const locallyProtected = isWorkspaceBindingLocallyProtected(binding, nowMs, {
4687
4688
  requireBindingSlot: true,
@@ -4711,7 +4712,7 @@ export function createTelegramTopicTargetStore(options) {
4711
4712
  : {}),
4712
4713
  protection,
4713
4714
  };
4714
- });
4715
+ }));
4715
4716
  const localReservedSlots = [
4716
4717
  ...Array.from(workspaceClaims.values()).map((claim) => claim.identity.slot),
4717
4718
  ...Array.from(records.values())
@@ -57,6 +57,8 @@ export declare function captureTelegramWorkspaceJournalProtectionSources(input:
57
57
  complete: boolean;
58
58
  createResolver: (path: string) => () => TelegramWorkspaceJournalReader | undefined;
59
59
  };
60
+ /** Snapshot-scoped reuse of binding-independent (shared/discovered) source reads. */
61
+ sharedSourceReads?: Map<string, TelegramWorkspaceJournalProtectionSource>;
60
62
  }): TelegramWorkspaceJournalProtectionCapture;
61
63
  export type TelegramWorkspaceJournalProtectionSource = {
62
64
  kind: "available";
@@ -158,9 +160,11 @@ interface TelegramWorkspaceProtectionObserverDeps {
158
160
  getDeliveryAuthorityProtection?: (binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceProtectionState;
159
161
  }
160
162
  export interface TelegramWorkspaceProtectionObserver {
161
- capture: (binding: TelegramWorkspaceThreadBinding, options?: {
163
+ capture: ((binding: TelegramWorkspaceThreadBinding, options?: {
162
164
  requireBindingProvenance?: boolean;
163
- }) => TelegramWorkspaceExternalProtectionEvidence;
165
+ }) => TelegramWorkspaceExternalProtectionEvidence) & {
166
+ withSnapshot?: <T>(capture: () => T) => T;
167
+ };
164
168
  captureJournalSources: (binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceJournalProtectionCapture;
165
169
  getJournalWriterProtection: (journalBindingKey: string) => TelegramWorkspaceProtectionState;
166
170
  }
@@ -46,27 +46,41 @@ export function captureTelegramWorkspaceJournalProtectionSources(input) {
46
46
  let complete = input.binding.journalBindingsComplete === true ||
47
47
  input.discovery?.complete === true;
48
48
  const capture = (scope, resolve) => {
49
+ const sharedKey = scope.kind === "shared"
50
+ ? "shared"
51
+ : scope.kind === "discovered"
52
+ ? `discovered:${scope.path}`
53
+ : undefined;
54
+ let source = sharedKey === undefined
55
+ ? undefined
56
+ : input.sharedSourceReads?.get(sharedKey);
57
+ if (!source) {
58
+ source = readSource(scope, resolve);
59
+ if (sharedKey !== undefined)
60
+ input.sharedSourceReads?.set(sharedKey, source);
61
+ }
62
+ if (source.kind === "unknown")
63
+ complete = false;
64
+ sources.push(source);
65
+ };
66
+ const readSource = (scope, resolve) => {
49
67
  try {
50
68
  const binding = resolve();
51
- if (!binding) {
52
- complete = false;
53
- sources.push({ kind: "unknown", scope });
54
- return;
55
- }
69
+ if (!binding)
70
+ return { kind: "unknown", scope };
56
71
  const read = binding.readForProtection ?? binding.journal.read;
57
72
  const snapshot = input.withJournalReference
58
73
  ? input.withJournalReference(binding, read)
59
74
  : read();
60
- sources.push({
75
+ return {
61
76
  kind: "available",
62
77
  scope,
63
78
  entries: snapshot.entries,
64
79
  ...(snapshot.exists === undefined ? {} : { exists: snapshot.exists }),
65
- });
80
+ };
66
81
  }
67
82
  catch {
68
- complete = false;
69
- sources.push({ kind: "unknown", scope });
83
+ return { kind: "unknown", scope };
70
84
  }
71
85
  };
72
86
  capture({ kind: "shared" }, input.resolveLeader);
@@ -292,8 +306,38 @@ export function captureTelegramWorkspaceExternalProtection(input) {
292
306
  }
293
307
  /** Shared read-only observer: protection and metadata pruning use the same scoped evidence path. */
294
308
  export function createTelegramWorkspaceProtectionObserver(deps) {
309
+ // One occupancy snapshot probes every binding synchronously; namespace and shared/discovered
310
+ // journal reads are binding-independent, so read them once per snapshot instead of per binding.
311
+ // Never widen this beyond one synchronous capture: post-fence rechecks need fresh evidence.
312
+ let snapshot;
313
+ const inspectNamespace = () => {
314
+ if (!snapshot)
315
+ return deps.inspectJournalNamespace?.();
316
+ if (!snapshot.namespace) {
317
+ try {
318
+ snapshot.namespace = { value: deps.inspectJournalNamespace?.() };
319
+ }
320
+ catch (error) {
321
+ snapshot.namespace = { error };
322
+ }
323
+ }
324
+ if ("error" in snapshot.namespace)
325
+ throw snapshot.namespace.error;
326
+ return snapshot.namespace.value;
327
+ };
328
+ const withSnapshot = (run) => {
329
+ if (snapshot)
330
+ return run();
331
+ snapshot = { reads: new Map() };
332
+ try {
333
+ return run();
334
+ }
335
+ finally {
336
+ snapshot = undefined;
337
+ }
338
+ };
295
339
  const captureJournalSources = (candidate) => {
296
- const namespace = deps.inspectJournalNamespace?.();
340
+ const namespace = inspectNamespace();
297
341
  if (deps.inspectJournalNamespace &&
298
342
  (!namespace ||
299
343
  namespace.sources.filter((source) => source.role === "polling")
@@ -332,9 +376,10 @@ export function createTelegramWorkspaceProtectionObserver(deps) {
332
376
  },
333
377
  }
334
378
  : {}),
379
+ ...(snapshot ? { sharedSourceReads: snapshot.reads } : {}),
335
380
  });
336
381
  };
337
- const capture = function (binding, options) {
382
+ const captureOne = function (binding, options) {
338
383
  return captureTelegramWorkspaceExternalProtection({
339
384
  binding,
340
385
  requireBindingProvenance: options?.requireBindingProvenance,
@@ -384,7 +429,7 @@ export function createTelegramWorkspaceProtectionObserver(deps) {
384
429
  });
385
430
  };
386
431
  return {
387
- capture,
432
+ capture: Object.assign(captureOne, { withSnapshot }),
388
433
  captureJournalSources,
389
434
  getJournalWriterProtection: deps.getJournalWriterProtection ?? (() => "unknown"),
390
435
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.57.1",
3
+ "version": "0.57.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -128,7 +128,7 @@ This operator-approved disposable-session policy is not Thread deletion, receipt
128
128
 
129
129
  ### Follower Registration And Session Readiness
130
130
 
131
- Manual Connect may carry a Pi command-context object distinct from the receiver's canonical session context. Binding preparation uses that canonical object only after both contexts prove the same current, leader-acknowledged session ID and session generation; a distinct object requires explicit active-context and generation ports. Readiness captures the canonical object and rechecks its identity after preparation and on every read. Missing, inactive, foreign or replaced canonical contexts grant no inbound authority. Same-session command-context refresh preserves the prepared worker and held input; generation, session or registration replacement still invalidates readiness. Worker context equality and exact journal/custody checks remain unchanged.
131
+ Manual Connect may carry a Pi command-context object distinct from the receiver's canonical session context. Binding preparation uses that canonical object only after both contexts prove the same current, leader-acknowledged session ID and session generation; a distinct object requires explicit active-context and generation ports. Readiness captures the canonical object and rechecks its identity after preparation and on every read. Missing, inactive, foreign or replaced canonical contexts grant no inbound authority. Same-session command-context refresh preserves the prepared worker and held input; generation, session or registration replacement still invalidates readiness. Worker context equality and exact journal/custody checks remain unchanged. That distinction is a readiness guard only: promotion and every other post-online behavior use the canonical live session context, so a fresh Pi context object for the same session never blocks or changes them, and a replaced session generation or ID revokes them.
132
132
 
133
133
  Same-target registration distinguishes the provisional Workspace claim from the binding actually returned by commit. A reused target may retain its existing slot while changing binding key; before persistence and registration publication, the leader aligns that target's owner record and registration result with the committed slot. The final exact binding/slot/target check and leader epoch/profile/operator fences remain mandatory; drift refuses publication rather than weakening the guard. Re-keying preserves retained historical journal keys and exact session/recipient source tuples, and cannot turn unknown retained coverage into a completeness proof. Independent bindings and their slots/history remain untouched.
134
134
 
@@ -271,6 +271,8 @@ Leader election is liveness-proof-gated and lock-backed. Since 0.54 the leader w
271
271
 
272
272
  Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding. Follower Bot API calls already admitted by the active Pi turn wait for that bounded re-registration and capture its new exact generation before entering transport; they do not fail merely because recovery temporarily cleared local registration, and they never replay after an ambiguous transport commit. After the grace window followers promote only when the exact observed leader lease has become stale or inactive; an unavailable IPC endpoint never authorizes replacing a still-live owner. If the exact carried target is absent from persisted bindings, the leader first runs the same synchronous visibility probe: success recovers it instead of creating another Telegram thread, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. An ambiguous absent-target probe persists only non-routable `probe-required` restoration evidence, so targetless retries, successor follower processes with the same exact Workspace claim, and leader reloads probe that exact target again instead of activating it or provisioning a speculative replacement. A carried slot survives only when that slot remains free. Every successful reuse refreshes the binding timestamp. The leader never restores persisted followers into the live registry speculatively. Absent follower records remain durable restart hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; only fresh authenticated registration creates live routing authority. This preserves real thread bindings through reload and process-absence gaps without allowing historical records or competing pollers to masquerade as live state.
273
273
 
274
+ Participation is a property of live registered state alone: eligibility, deferral, and promotion depend on the acknowledged slot, the authenticated registration generation, and the current session, never on how that instance came online. A fresh Pi context object for the same session carries the same authority, and only a replaced session generation or ID revokes it.
275
+
274
276
  ## Leader/Follower Communication
275
277
 
276
278
  ### Protocol identity and compatibility