@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/lib/polling.ts CHANGED
@@ -1180,8 +1180,16 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
1180
1180
  ctx: TContext,
1181
1181
  owner: TOwner,
1182
1182
  ): Promise<boolean | undefined> => {
1183
- if (!(await refreshFollowerState())) return undefined;
1184
- if (!deps.hasRememberedWorkspaceBinding?.(ctx)) return undefined;
1183
+ if (!(await refreshFollowerState())) {
1184
+ deps.recordEvent("bus", "Telegram follower auto-connect skipped: Threaded Mode is not enabled.",
1185
+ { phase: "follower-auto-connect-skip", reason: "thread-mode-unavailable" });
1186
+ return undefined;
1187
+ }
1188
+ if (!deps.hasRememberedWorkspaceBinding?.(ctx)) {
1189
+ deps.recordEvent("bus", "Telegram follower auto-connect skipped: no binding for this session.",
1190
+ { phase: "follower-auto-connect-skip", reason: "session-binding-unavailable" });
1191
+ return undefined;
1192
+ }
1185
1193
  return deps.restoreFollowerWithLeader?.(ctx, owner);
1186
1194
  };
1187
1195
  return {
package/lib/status.ts CHANGED
@@ -1,9 +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
6
 
7
+ /** UI copy is allowlisted; raw exception text belongs only in redacted diagnostics. */
8
+ export function formatTelegramConnectionFailure(error: unknown): string {
9
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
10
+ const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
11
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
12
+ if (status === 401) return "Telegram token rejected. Run /telegram-setup.";
13
+ if (status === 403) return "Telegram access denied. Check /telegram-status --debug.";
14
+ if ((typeof code === "string" && ["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "ECONNRESET"].includes(code)) ||
15
+ /network unavailable|fetch failed/i.test(message)) {
16
+ return "Telegram network unavailable. Retry /telegram-connect.";
17
+ }
18
+ if (/Workspace slots?.*(?:unavailable|exhausted)|no free.*slot/i.test(message)) {
19
+ return "No Telegram slot available. Check /telegram-status --debug.";
20
+ }
21
+ if (code === "incompatible-protocol" || /protocol.*(?:incompatible|mismatch)|incompatible.*protocol/i.test(message)) {
22
+ return "Telegram instances are incompatible. Update them together.";
23
+ }
24
+ if (/unsupported.*(?:journal|version)|(?:journal|version).*unsupported/i.test(message)) {
25
+ return "Telegram state version unsupported. Use a compatible runtime.";
26
+ }
27
+ if (/follower registration failed|active in another Pi instance/i.test(message)) {
28
+ return "Telegram leader is active but unavailable. Check /telegram-status --debug.";
29
+ }
30
+ if (/unfinished Thread creation does not match/i.test(message)) {
31
+ return "Telegram Thread creation is unresolved. Check /telegram-status --debug.";
32
+ }
33
+ return "Telegram connection failed. Check /telegram-status --debug.";
34
+ }
35
+
7
36
  const TELEGRAM_STATUS_DEFAULT_PROFILE_NAME = "default";
8
37
 
9
38
  export type TelegramStatusQueueLane = "control" | "priority" | "default";
package/lib/sync.ts CHANGED
@@ -773,6 +773,7 @@ export async function ensureTelegramLeaderThreadBinding(
773
773
  : undefined;
774
774
  const legacyWorkspaceBinding =
775
775
  workspaceIdentity?.instanceSlot === "a" &&
776
+ deps.sessionId === undefined &&
776
777
  !persistedWorkspaceBinding &&
777
778
  typeof legacyLeaderRecord?.target.threadId === "number"
778
779
  ? {
@@ -885,10 +886,9 @@ export async function ensureTelegramLeaderThreadBinding(
885
886
  (record.status === "active" || record.status === "starting")
886
887
  );
887
888
  });
888
- // Short-circuit: when the instance already has an active thread and we are not
889
- // force-freshing, reuse it without re-provisioning. A thread belongs to the
890
- // live instance binding, not to one transient Pi session lifecycle.
891
- if (!deps.forceFreshUnnamed && priorTargets.length > 0) {
889
+ // Legacy callers without session identity may reuse the active instance target.
890
+ // Session-aware callers must resolve their exact Workspace binding instead.
891
+ if (deps.sessionId === undefined && !deps.forceFreshUnnamed && priorTargets.length > 0) {
892
892
  const record = priorTargets[0];
893
893
  deps.recordEvent(
894
894
  "telegram",
package/lib/threads.ts CHANGED
@@ -229,6 +229,7 @@ export interface TelegramThreadPendingProvision {
229
229
  owner: "leader" | "manual-follower";
230
230
  instanceId: string;
231
231
  profileKey?: string;
232
+ workspaceBindingKey?: string;
232
233
  status?: "in-flight" | "ambiguous";
233
234
  threadName?: string;
234
235
  displayTitle?: string;
@@ -371,6 +372,12 @@ export interface TelegramSessionReplacementIntent {
371
372
  threadName?: string;
372
373
  createdAtMs: number;
373
374
  expiresAtMs: number;
375
+ /**
376
+ * Present only when the leader published the intent for a registered
377
+ * follower. Successor re-key and settlement must come from this exact
378
+ * follower runtime or its authenticated same-process session handoff.
379
+ */
380
+ sourceInstanceId?: string;
374
381
  }
375
382
 
376
383
  export interface TelegramTopicTargetFile {
@@ -1329,7 +1336,7 @@ function cloneSessionReplacementIntent(
1329
1336
  return { ...intent, target: { ...intent.target } };
1330
1337
  }
1331
1338
 
1332
- function normalizeSessionReplacementIntent(
1339
+ export function normalizeTelegramSessionReplacementIntent(
1333
1340
  value: unknown,
1334
1341
  ): TelegramSessionReplacementIntent | undefined {
1335
1342
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
@@ -1346,7 +1353,10 @@ function normalizeSessionReplacementIntent(
1346
1353
  typeof record.messageId !== "number" || !Number.isSafeInteger(record.messageId) ||
1347
1354
  typeof record.createdAtMs !== "number" || !Number.isSafeInteger(record.createdAtMs) ||
1348
1355
  typeof record.expiresAtMs !== "number" || !Number.isSafeInteger(record.expiresAtMs) ||
1349
- record.expiresAtMs <= record.createdAtMs
1356
+ record.expiresAtMs <= record.createdAtMs ||
1357
+ (record.sourceInstanceId !== undefined &&
1358
+ (typeof record.sourceInstanceId !== "string" || !record.sourceInstanceId ||
1359
+ record.sourceInstanceId.length > 256))
1350
1360
  ) return undefined;
1351
1361
  const continuity = record.continuity === "workspace-thread" ||
1352
1362
  record.continuity === "classic-chat"
@@ -1370,6 +1380,8 @@ function normalizeSessionReplacementIntent(
1370
1380
  ...(typeof record.threadName === "string" ? { threadName: record.threadName } : {}),
1371
1381
  createdAtMs: record.createdAtMs,
1372
1382
  expiresAtMs: record.expiresAtMs,
1383
+ ...(typeof record.sourceInstanceId === "string"
1384
+ ? { sourceInstanceId: record.sourceInstanceId } : {}),
1373
1385
  };
1374
1386
  }
1375
1387
 
@@ -1517,6 +1529,9 @@ function normalizePendingProvision(
1517
1529
  ...(record.status === "in-flight" || record.status === "ambiguous"
1518
1530
  ? { status: record.status }
1519
1531
  : {}),
1532
+ ...(typeof record.workspaceBindingKey === "string"
1533
+ ? { workspaceBindingKey: record.workspaceBindingKey }
1534
+ : {}),
1520
1535
  ...(typeof record.threadName === "string"
1521
1536
  ? { threadName: record.threadName }
1522
1537
  : {}),
@@ -1680,7 +1695,7 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
1680
1695
  return normalized ? [normalized] : [];
1681
1696
  })
1682
1697
  : [],
1683
- sessionReplacement: normalizeSessionReplacementIntent(file.sessionReplacement),
1698
+ sessionReplacement: normalizeTelegramSessionReplacementIntent(file.sessionReplacement),
1684
1699
  reservations: Array.isArray(file.reservations)
1685
1700
  ? file.reservations.flatMap((reservation) => {
1686
1701
  const normalized = normalizeReservation(reservation);
@@ -2510,7 +2525,7 @@ export function createTelegramTopicTargetStore(
2510
2525
  : undefined;
2511
2526
  },
2512
2527
  async commitSessionReplacementIntent(intent, isCurrent) {
2513
- const next = normalizeSessionReplacementIntent(intent);
2528
+ const next = normalizeTelegramSessionReplacementIntent(intent);
2514
2529
  if (!next || !isCurrent()) return false;
2515
2530
  await loadFromDisk();
2516
2531
  if (!isCurrent()) return false;
@@ -2925,7 +2940,10 @@ export function createTelegramTopicTargetStore(
2925
2940
  replacement.expiresAtMs > getNowMs() &&
2926
2941
  replacement.profileName === (getTelegramProfile() ?? "default") &&
2927
2942
  replacement.cwd === normalizedCwd &&
2928
- replacement.sourceSessionId !== normalizedSessionId
2943
+ replacement.sourceSessionId !== normalizedSessionId &&
2944
+ (replacement.sourceInstanceId === undefined ||
2945
+ replacement.sourceInstanceId === instanceId ||
2946
+ replacement.sourceInstanceId === previousInstanceId)
2929
2947
  ) {
2930
2948
  const sourceEntry = Array.from(workspaceBindings.entries()).find(([, binding]) =>
2931
2949
  binding.cwd === normalizedCwd &&
@@ -4905,6 +4923,13 @@ export function createTelegramTopicTargetProvisioner(
4905
4923
  pending.profileKey === request.profileKey ||
4906
4924
  pending.instanceId === request.instanceId,
4907
4925
  );
4926
+ const pendingTarget = pendingForRequest?.target;
4927
+ if (pendingForRequest && request.workspaceBindingKey &&
4928
+ pendingForRequest.workspaceBindingKey !== request.workspaceBindingKey &&
4929
+ !(pendingTarget && deps.store.listWorkspaceBindings().some((binding) =>
4930
+ binding.bindingKey === request.workspaceBindingKey && targetMatches(binding.target, pendingTarget)))) {
4931
+ throw new Error("Telegram unfinished Thread creation does not match this session binding.");
4932
+ }
4908
4933
  if (pendingForRequest?.target) {
4909
4934
  const target = pendingForRequest.target;
4910
4935
  const observation = deps.store.listSyncObservations().find((entry) =>
@@ -4919,8 +4944,13 @@ export function createTelegramTopicTargetProvisioner(
4919
4944
  pendingForRequest = undefined;
4920
4945
  }
4921
4946
  }
4947
+ const matchesWorkspace = (target: TelegramTarget): boolean =>
4948
+ !request.workspaceBindingKey ||
4949
+ deps.store.listWorkspaceBindings().some((binding) =>
4950
+ binding.bindingKey === request.workspaceBindingKey && targetMatches(binding.target, target)) ||
4951
+ (!!pendingForRequest?.target && targetMatches(pendingForRequest.target, target));
4922
4952
  const existing = deps.store.getByProfileKey(request.profileKey);
4923
- if (existing && isCurrentThreadRecord(existing)) {
4953
+ if (existing && isCurrentThreadRecord(existing) && matchesWorkspace(existing.target)) {
4924
4954
  const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
4925
4955
  if (!slot) {
4926
4956
  throw new TelegramWorkspaceSlotUnavailableError();
@@ -5003,7 +5033,7 @@ export function createTelegramTopicTargetProvisioner(
5003
5033
  const activeForInstance = deps.store.getActiveByInstanceId(
5004
5034
  request.instanceId,
5005
5035
  );
5006
- if (activeForInstance) {
5036
+ if (activeForInstance && matchesWorkspace(activeForInstance.target)) {
5007
5037
  if (!activeForInstance.slot) {
5008
5038
  throw new Error("Telegram Workspace slot reservation is unavailable.");
5009
5039
  }
@@ -5041,7 +5071,7 @@ export function createTelegramTopicTargetProvisioner(
5041
5071
  getNextTelegramThreadNamePaletteSlot(deps.store.list(), undefined) ??
5042
5072
  request.preferredSlot;
5043
5073
  const slot =
5044
- existing?.slot ??
5074
+ (existing && matchesWorkspace(existing.target) ? existing.slot : undefined) ??
5045
5075
  deps.store.allocateSlot(
5046
5076
  request.profileKey,
5047
5077
  isManualFollowerRequest
@@ -5050,6 +5080,7 @@ export function createTelegramTopicTargetProvisioner(
5050
5080
  (candidateThreadName ? undefined : identity?.slot) ??
5051
5081
  preferredNameSlot),
5052
5082
  request.workspaceBindingKey,
5083
+ { excludeCurrentRecord: !!existing && !matchesWorkspace(existing.target) },
5053
5084
  );
5054
5085
  if (!slot) {
5055
5086
  throw new TelegramWorkspaceSlotUnavailableError();
@@ -5093,6 +5124,7 @@ export function createTelegramTopicTargetProvisioner(
5093
5124
  owner: pendingOwner,
5094
5125
  instanceId: request.instanceId,
5095
5126
  profileKey: request.profileKey,
5127
+ ...(request.workspaceBindingKey ? { workspaceBindingKey: request.workspaceBindingKey } : {}),
5096
5128
  threadName: requestThreadName,
5097
5129
  ...(displayTitle ? { displayTitle } : {}),
5098
5130
  slot,
@@ -884,6 +884,7 @@ export async function executeTelegramWorkspaceRetirement(input: {
884
884
  | "listWorkspaceRetirementIntents"
885
885
  | "commitWorkspaceRetirement"
886
886
  | "removeWorkspaceRetirementIntent"
887
+ | "markStaleByTarget"
887
888
  | "persist"
888
889
  >;
889
890
  admission: Pick<
@@ -971,6 +972,12 @@ export async function executeTelegramWorkspaceRetirement(input: {
971
972
  }
972
973
  return { kind: "retained", reason: "stale-intent" };
973
974
  }
975
+ if (fence?.phase === "commit-ready") {
976
+ // Exact confirmed absence retires any same-target routing record that
977
+ // survived the deletion attempt. The fence prevents replacement target
978
+ // publication while this stale local projection is removed.
979
+ input.store.markStaleByTarget(binding.target, "deleted");
980
+ }
974
981
  const eligible = () => input.store.captureWorkspaceSlotOccupancy(
975
982
  input.getExternalProtection,
976
983
  { expectedRetirement: input.intent },
@@ -1040,6 +1047,9 @@ export async function executeTelegramWorkspaceRetirement(input: {
1040
1047
  fence = input.admission.confirmRetirementAbsence(fence);
1041
1048
  }
1042
1049
  if (!isCurrent()) return { kind: "retained", reason: "authority-changed" };
1050
+ if (fence.phase === "commit-ready") {
1051
+ input.store.markStaleByTarget(binding.target, "deleted");
1052
+ }
1043
1053
  if (!eligible()) return { kind: "retained", reason: "protection-changed" };
1044
1054
  if (!await input.store.commitWorkspaceRetirement(input.intent, isCurrent)) {
1045
1055
  return { kind: "retained", reason: "commit-rejected" };
@@ -1212,7 +1222,7 @@ export type TelegramWorkspaceRetirementLifecycleResult =
1212
1222
  export async function runTelegramWorkspaceRetirementLifecycle(input: {
1213
1223
  store: TelegramWorkspaceRetirementPreparationDeps["store"] & Pick<
1214
1224
  TelegramTopicTargetStore,
1215
- "replaceWorkspaceRetirementIntent" | "commitWorkspaceRetirement"
1225
+ "replaceWorkspaceRetirementIntent" | "commitWorkspaceRetirement" | "markStaleByTarget"
1216
1226
  >;
1217
1227
  getExternalProtection: (
1218
1228
  binding: TelegramWorkspaceThreadBinding,
@@ -1257,19 +1267,42 @@ export async function runTelegramWorkspaceRetirementLifecycle(input: {
1257
1267
  });
1258
1268
  }
1259
1269
  if (intent) {
1260
- const adoption = await adoptTelegramWorkspaceRetirementIntent({
1261
- store: input.store,
1262
- intent,
1263
- getExternalProtection: input.getExternalProtection,
1264
- getLeaderEpoch: input.getLeaderEpoch,
1265
- getProfileKey: input.getProfileKey,
1266
- isCurrent: input.isCurrent,
1267
- runExclusive: input.runExclusive,
1268
- });
1269
- if (adoption.kind !== "adopted") {
1270
- return { kind: "blocked", stage: "adoption", reason: adoption.reason };
1270
+ if (retainedFence && isTelegramWorkspaceRetirementFence(retainedFence) &&
1271
+ retainedFence.phase === "commit-ready" &&
1272
+ matchesTelegramWorkspaceRetirementFence(retainedFence, intent)) {
1273
+ const resumed = await input.runExclusive(async () => {
1274
+ const epoch = input.getLeaderEpoch();
1275
+ const profileKey = input.getProfileKey();
1276
+ const isCurrent = () => epoch !== undefined && input.getLeaderEpoch() === epoch &&
1277
+ input.getProfileKey() === profileKey && input.isCurrent?.() !== false;
1278
+ if (!isCurrent() || intent!.profileKey !== profileKey) return undefined;
1279
+ const intents = input.store.listWorkspaceRetirementIntents();
1280
+ const binding = input.store.listWorkspaceBindings().find(candidate =>
1281
+ candidate.bindingKey === intent!.binding.bindingKey);
1282
+ if (intents.length !== 1 || !isDeepStrictEqual(intents[0], intent) ||
1283
+ !binding || !isDeepStrictEqual(binding, intent.binding)) return undefined;
1284
+ if (intent!.leaderEpoch === epoch) return intent;
1285
+ const replacement = { ...intent!, leaderEpoch: epoch! };
1286
+ return await input.store.replaceWorkspaceRetirementIntent(intent!, replacement, isCurrent)
1287
+ ? replacement : undefined;
1288
+ });
1289
+ if (!resumed) return { kind: "blocked", stage: "adoption", reason: "commit-rejected" };
1290
+ intent = resumed;
1291
+ } else {
1292
+ const adoption = await adoptTelegramWorkspaceRetirementIntent({
1293
+ store: input.store,
1294
+ intent,
1295
+ getExternalProtection: input.getExternalProtection,
1296
+ getLeaderEpoch: input.getLeaderEpoch,
1297
+ getProfileKey: input.getProfileKey,
1298
+ isCurrent: input.isCurrent,
1299
+ runExclusive: input.runExclusive,
1300
+ });
1301
+ if (adoption.kind !== "adopted") {
1302
+ return { kind: "blocked", stage: "adoption", reason: adoption.reason };
1303
+ }
1304
+ intent = adoption.intent;
1271
1305
  }
1272
- intent = adoption.intent;
1273
1306
  } else {
1274
1307
  const preparation = await prepareTelegramWorkspaceRetirement(
1275
1308
  {
package/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"