@llblab/pi-kit 0.36.1 → 0.36.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 (70) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +4 -0
  4. package/node_modules/@llblab/pi-claude-usage/README.md +2 -2
  5. package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +2 -3
  6. package/node_modules/@llblab/pi-claude-usage/package.json +1 -1
  7. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +5 -0
  8. package/node_modules/@llblab/pi-codex-usage/README.md +1 -1
  9. package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +2 -3
  10. package/node_modules/@llblab/pi-codex-usage/package.json +1 -1
  11. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  12. package/node_modules/@llblab/pi-telegram/BACKLOG.md +17 -5
  13. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +23 -0
  14. package/node_modules/@llblab/pi-telegram/README.md +4 -2
  15. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +17 -1
  16. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +12 -2
  17. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +17 -6
  18. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +1 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +1 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +42 -10
  21. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +1 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.js +9 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +3 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -2
  26. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +6 -3
  27. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +50 -9
  28. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +119 -14
  29. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -0
  30. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +9 -0
  31. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +39 -3
  32. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +144 -16
  33. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +1 -1
  34. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +15 -8
  35. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +14 -1
  36. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +122 -8
  37. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +15 -1
  38. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +22 -6
  39. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +4 -3
  40. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +3 -3
  41. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +1 -0
  42. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +52 -2
  43. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +17 -0
  44. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +25 -0
  45. package/node_modules/@llblab/pi-telegram/dist/package.json +2 -1
  46. package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
  47. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +9 -3
  48. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -0
  49. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  50. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +24 -1
  51. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +16 -0
  52. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -5
  53. package/node_modules/@llblab/pi-telegram/lib/bus.ts +3 -0
  54. package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -11
  55. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +12 -0
  56. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +2 -0
  57. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +3 -2
  58. package/node_modules/@llblab/pi-telegram/lib/pi.ts +2 -0
  59. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +79 -16
  60. package/node_modules/@llblab/pi-telegram/lib/routing.ts +137 -24
  61. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +14 -0
  62. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +173 -17
  63. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +14 -8
  64. package/node_modules/@llblab/pi-telegram/lib/threads.ts +166 -10
  65. package/node_modules/@llblab/pi-telegram/lib/updates.ts +33 -6
  66. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +8 -7
  67. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +56 -2
  68. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +44 -0
  69. package/node_modules/@llblab/pi-telegram/package.json +2 -1
  70. package/package.json +4 -4
@@ -74,6 +74,7 @@ import {
74
74
  } from "./workspace-identity.ts";
75
75
  import {
76
76
  planTelegramWorkspaceSlotAllocation,
77
+ selectTelegramWorkspaceReusableSlot,
77
78
  TELEGRAM_WORKSPACE_SLOTS,
78
79
  TelegramWorkspaceSlotUnavailableError,
79
80
  type TelegramWorkspaceSlotOccupancy,
@@ -873,7 +874,7 @@ export interface TelegramTopicTargetStore {
873
874
  sessionId?: string;
874
875
  sessionKey?: string;
875
876
  instanceSlot: string;
876
- slot: string;
877
+ slot: string | null;
877
878
  bindingKey: string;
878
879
  target: { chatId: number; threadId: number };
879
880
  inactiveSinceMs: number;
@@ -886,6 +887,19 @@ export interface TelegramTopicTargetStore {
886
887
  getExternalProtection: TelegramWorkspaceExternalProtectionCapture,
887
888
  options?: { expectedRetirement?: TelegramWorkspaceRetirementIntent },
888
889
  ) => TelegramWorkspaceSlotOccupancySnapshot;
890
+ /**
891
+ * Fallback for a full A-Z pool: strips the display letter from the least recently used
892
+ * binding (and its current Thread record) and persists, so a fresh allocation can take it.
893
+ * Metadata only: the Thread, journals and accepted work stay untouched, and a binding or
894
+ * letter held by a claim, reservation, provision, cleanup or retirement is never chosen.
895
+ */
896
+ reuseLeastRecentlyUsedWorkspaceSlot: (
897
+ isCurrent: () => boolean,
898
+ ) => Promise<
899
+ | { kind: "free" }
900
+ | { kind: "reused"; bindingKey: string; slot: string }
901
+ | undefined
902
+ >;
889
903
  hasWorkspaceBinding: (cwd: string, sessionId?: string) => boolean;
890
904
  setWorkspaceDisplayTitle: (
891
905
  expected: TelegramWorkspaceThreadBinding,
@@ -3556,8 +3570,6 @@ export function createTelegramTopicTargetStore(
3556
3570
  >,
3557
3571
  nowMs: number,
3558
3572
  options: {
3559
- /** Slot equality only counts when the binding itself holds a slot. */
3560
- requireBindingSlot?: boolean;
3561
3573
  isRetirementExcluded?: (
3562
3574
  intent: TelegramWorkspaceRetirementIntent,
3563
3575
  index: number,
@@ -3565,7 +3577,7 @@ export function createTelegramTopicTargetStore(
3565
3577
  } = {},
3566
3578
  ): boolean => {
3567
3579
  const slotMatches = (slot: string | undefined): boolean =>
3568
- (!options.requireBindingSlot || !!binding.slot) && slot === binding.slot;
3580
+ !!binding.slot && slot === binding.slot;
3569
3581
  const targetOrSlotMatches = (candidate: {
3570
3582
  target?: TelegramTarget;
3571
3583
  slot?: string;
@@ -7272,7 +7284,7 @@ export function createTelegramTopicTargetStore(
7272
7284
  (!cleanupSnapshot.cwd ||
7273
7285
  !cleanupSnapshot.workspaceKey ||
7274
7286
  !cleanupSnapshot.instanceSlot ||
7275
- !cleanupSnapshot.slot ||
7287
+ !(cleanupSnapshot.slot === null || (typeof cleanupSnapshot.slot === "string" && /^[A-Z]$/u.test(cleanupSnapshot.slot))) ||
7276
7288
  !cleanupSnapshot.bindingKey ||
7277
7289
  ((cleanupSnapshot.sessionId !== undefined ||
7278
7290
  cleanupSnapshot.sessionKey !== undefined) &&
@@ -7287,7 +7299,7 @@ export function createTelegramTopicTargetStore(
7287
7299
  return false;
7288
7300
  if (
7289
7301
  !cleanupSnapshot &&
7290
- (!normalized?.slot || normalized.inactiveSinceMs === undefined)
7302
+ (!normalized || normalized.inactiveSinceMs === undefined)
7291
7303
  )
7292
7304
  return false;
7293
7305
  const mapKey = getWorkspaceBindingMapKey(
@@ -7301,7 +7313,7 @@ export function createTelegramTopicTargetStore(
7301
7313
  binding.sessionId === cleanupSessionId &&
7302
7314
  binding.sessionKey === cleanupSessionKey &&
7303
7315
  binding.instanceSlot === cleanupSnapshot.instanceSlot &&
7304
- binding.slot === cleanupSnapshot.slot &&
7316
+ (binding.slot ?? null) === cleanupSnapshot.slot &&
7305
7317
  binding.bindingKey === cleanupSnapshot.bindingKey &&
7306
7318
  targetMatches(binding.target, cleanupSnapshot.target) &&
7307
7319
  binding.inactiveSinceMs === cleanupSnapshot.inactiveSinceMs &&
@@ -7420,13 +7432,13 @@ export function createTelegramTopicTargetStore(
7420
7432
  const nowMs = getNowMs();
7421
7433
  const inSnapshot =
7422
7434
  getExternalProtection.withSnapshot ?? ((capture) => capture());
7423
- const bindings = inSnapshot(() => Array.from(workspaceBindings.values()).map((binding) => {
7424
- const slot = binding.slot?.toLowerCase() ?? "";
7435
+ // A slotless binding (legacy or reassigned) holds no letter, so it can neither block planning nor be retired for capacity.
7436
+ const bindings = inSnapshot(() => Array.from(workspaceBindings.values()).filter((binding) => binding.slot).map((binding) => {
7437
+ const slot = binding.slot!.toLowerCase();
7425
7438
  const locallyProtected = isWorkspaceBindingLocallyProtected(
7426
7439
  binding,
7427
7440
  nowMs,
7428
7441
  {
7429
- requireBindingSlot: true,
7430
7442
  isRetirementExcluded: (intent) =>
7431
7443
  isDeepStrictEqual(intent, options?.expectedRetirement),
7432
7444
  },
@@ -7491,6 +7503,150 @@ export function createTelegramTopicTargetStore(
7491
7503
  : [...localReservedSlots, "invalid"];
7492
7504
  return { bindings, reservedSlots };
7493
7505
  },
7506
+ async reuseLeastRecentlyUsedWorkspaceSlot(isCurrent) {
7507
+ if (workspaceRetirementCommitInFlight || !isCurrent()) return undefined;
7508
+ const external = captureExternalReservedSlots();
7509
+ if (!external) return undefined;
7510
+ const nowMs = getNowMs();
7511
+ const currentRecords = Array.from(records.values()).filter(
7512
+ ThreadReconciler.isCurrentThreadRecord,
7513
+ );
7514
+ const held = new Set<string>(
7515
+ [
7516
+ ...external,
7517
+ ...Array.from(workspaceClaims.values()).map(
7518
+ (claim) => claim.identity.slot,
7519
+ ),
7520
+ ...reservations
7521
+ .filter(
7522
+ (reservation) =>
7523
+ reservation.expiresAtMs === undefined ||
7524
+ reservation.expiresAtMs > nowMs,
7525
+ )
7526
+ .map((reservation) => reservation.slot),
7527
+ ...pendingProvisions
7528
+ .filter((provision) =>
7529
+ isPendingProvisionLiveOrTargeted(provision, nowMs),
7530
+ )
7531
+ .map((provision) => provision.slot),
7532
+ ].filter((slot): slot is string => !!slot),
7533
+ );
7534
+ const occupied = new Set<string>([
7535
+ ...held,
7536
+ ...Array.from(workspaceBindings.values()).flatMap((binding) =>
7537
+ binding.slot ? [binding.slot] : [],
7538
+ ),
7539
+ ...currentRecords.flatMap((record) => (record.slot ? [record.slot] : [])),
7540
+ ]);
7541
+ if (
7542
+ Array.from(TELEGRAM_WORKSPACE_SLOTS, (slot) => slot.toUpperCase()).some(
7543
+ (slot) => !occupied.has(slot),
7544
+ )
7545
+ )
7546
+ return { kind: "free" };
7547
+ const claimedKeys = new Set(
7548
+ Array.from(workspaceClaims.values()).map(
7549
+ (claim) => claim.identity.bindingKey,
7550
+ ),
7551
+ );
7552
+ const candidates = Array.from(workspaceBindings.values()).flatMap(
7553
+ (binding) => {
7554
+ const slot = binding.slot;
7555
+ if (
7556
+ !slot ||
7557
+ !/^[A-Z]$/u.test(slot) ||
7558
+ held.has(slot) ||
7559
+ claimedKeys.has(binding.bindingKey) ||
7560
+ hasWorkspaceRetirementConflict({
7561
+ bindingKey: binding.bindingKey,
7562
+ target: binding.target,
7563
+ slot,
7564
+ }) ||
7565
+ pendingCleanups.some((intent) =>
7566
+ targetMatches(intent.target, binding.target),
7567
+ ) ||
7568
+ // Another Thread already holding the letter is unresolved duplication, not reusable metadata.
7569
+ currentRecords.some(
7570
+ (record) =>
7571
+ record.slot === slot && !targetMatches(record.target, binding.target),
7572
+ )
7573
+ )
7574
+ return [];
7575
+ const live = currentRecords.some((record) =>
7576
+ targetMatches(record.target, binding.target),
7577
+ );
7578
+ return [
7579
+ {
7580
+ bindingKey: binding.bindingKey,
7581
+ slot,
7582
+ live,
7583
+ lastUsedMs:
7584
+ !live && binding.inactiveSinceMs !== undefined
7585
+ ? binding.inactiveSinceMs
7586
+ : binding.updatedAtMs,
7587
+ },
7588
+ ];
7589
+ },
7590
+ );
7591
+ const selected = selectTelegramWorkspaceReusableSlot(candidates);
7592
+ if (!selected) return undefined;
7593
+ const mapKey = Array.from(workspaceBindings.entries()).find(
7594
+ ([, binding]) => binding.bindingKey === selected.bindingKey,
7595
+ )?.[0];
7596
+ const binding = mapKey ? workspaceBindings.get(mapKey) : undefined;
7597
+ if (!mapKey || !binding) return undefined;
7598
+ const affected = currentRecords.filter(
7599
+ (record) =>
7600
+ record.slot === selected.slot && targetMatches(record.target, binding.target),
7601
+ );
7602
+ const previousBinding = cloneWorkspaceBinding(binding);
7603
+ const previousRecords = affected.map((record) => ({
7604
+ key: getRecordOwnerKey(record),
7605
+ record: cloneRecord(record),
7606
+ identity: identities.get(getRecordOwnerKey(record)),
7607
+ }));
7608
+ const { slot: _slot, ...slotless } = previousBinding;
7609
+ workspaceRetirementCommitInFlight = true;
7610
+ workspaceBindings.set(mapKey, slotless);
7611
+ for (const record of affected) {
7612
+ delete record.slot;
7613
+ const key = getRecordOwnerKey(record);
7614
+ const identity = identities.get(key);
7615
+ if (identity?.slot === selected.slot) {
7616
+ const { slot: _identitySlot, ...rest } = identity;
7617
+ identities.set(key, rest);
7618
+ }
7619
+ }
7620
+ markDirty();
7621
+ const restoreInMemory = () => {
7622
+ workspaceBindings.set(mapKey, cloneWorkspaceBinding(previousBinding));
7623
+ for (const previous of previousRecords) {
7624
+ records.set(previous.key, cloneRecord(previous.record));
7625
+ if (previous.identity) identities.set(previous.key, previous.identity);
7626
+ }
7627
+ markDirty();
7628
+ };
7629
+ try {
7630
+ if (!isCurrent() || !(await persistSnapshot())) {
7631
+ restoreInMemory();
7632
+ return undefined;
7633
+ }
7634
+ return {
7635
+ kind: "reused" as const,
7636
+ bindingKey: selected.bindingKey,
7637
+ slot: selected.slot,
7638
+ };
7639
+ } catch (error) {
7640
+ try {
7641
+ await loadFromDisk();
7642
+ } catch {
7643
+ restoreInMemory();
7644
+ }
7645
+ throw error;
7646
+ } finally {
7647
+ workspaceRetirementCommitInFlight = false;
7648
+ }
7649
+ },
7494
7650
  hasWorkspaceBinding(cwd, sessionId) {
7495
7651
  const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
7496
7652
  const normalizedSessionId =
@@ -10495,6 +10495,7 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
10495
10495
  | "getRecipientBindingKey"
10496
10496
  | "hasAuthority"
10497
10497
  | "prepareUpdateForExecution"
10498
+ | "recoverRejectedRecipientTarget"
10498
10499
  >;
10499
10500
  leader: {
10500
10501
  resolveBinding: () =>
@@ -10507,6 +10508,9 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
10507
10508
  isRegistered: () => boolean;
10508
10509
  getGeneration: () => string | undefined;
10509
10510
  prepareUpdateForExecution: (update: TUpdate) => TUpdate;
10511
+ recoverRejectedRecipientTarget?: NonNullable<
10512
+ TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>["recoverRejectedRecipientTarget"]
10513
+ >;
10510
10514
  /** Session succession before worker creation, under the exact generation-fenced binding. */
10511
10515
  prepareBinding?: (
10512
10516
  binding: TelegramUpdateAdmissionLifecycleJournalBinding & {
@@ -10531,6 +10535,13 @@ export type TelegramUpdateAdmissionWorkerRuntimeDeps<
10531
10535
  execution?: TelegramUpdateExecutionFence,
10532
10536
  ) => Promise<void>;
10533
10537
  prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
10538
+ /**
10539
+ * Recipient-only recovery for a journaled message whose own Thread changed (for example after its Thread was re-created):
10540
+ * classifies a leader refusal of a reply to the original Thread and returns that Thread plus the owner's current one.
10541
+ */
10542
+ recoverRejectedRecipientTarget?: (error: unknown) =>
10543
+ | { rejected: { chatId: number; threadId: number }; current: TelegramTarget & { threadId: number } }
10544
+ | undefined;
10534
10545
  registry?: TelegramUpdateHandlerRegistry;
10535
10546
  };
10536
10547
 
@@ -10657,12 +10668,14 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
10657
10668
  const typedUpdate = update as TUpdate;
10658
10669
  const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
10659
10670
  // Journal/source evidence remains the original; only the warm released handler projection changes destination.
10660
- const routed = target ? { ...prepared,
10661
- ...(prepared.message ? { message: prepared.message.message_thread_id === target.threadId ? { ...prepared.message }
10662
- : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: target.threadId, reply_to_message: undefined }) } : {}),
10663
- ...(prepared.edited_message ? { edited_message: prepared.edited_message.message_thread_id === target.threadId ? { ...prepared.edited_message }
10664
- : withoutTelegramTurnReplyAnchor({ ...prepared.edited_message, message_thread_id: target.threadId, reply_to_message: undefined }) } : {}),
10665
- } as TUpdate : prepared;
10671
+ // Journal/source evidence remains the original; only the warm released handler projection changes destination.
10672
+ const projectTo = (destination: TelegramTarget & { threadId: number }): TUpdate => ({ ...prepared,
10673
+ ...(prepared.message ? { message: prepared.message.message_thread_id === destination.threadId ? { ...prepared.message }
10674
+ : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
10675
+ ...(prepared.edited_message ? { edited_message: prepared.edited_message.message_thread_id === destination.threadId ? { ...prepared.edited_message }
10676
+ : withoutTelegramTurnReplyAnchor({ ...prepared.edited_message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
10677
+ } as TUpdate);
10678
+ const routed = target ? projectTo(target) : prepared;
10666
10679
  try {
10667
10680
  return await executeUpdate(
10668
10681
  routed,
@@ -10673,6 +10686,17 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
10673
10686
  if (await deps.settleTerminalExecutionFailure?.(error)) {
10674
10687
  return { kind: "complete" };
10675
10688
  }
10689
+ // The leader refused a reply to a Thread this recipient no longer owns (re-created after the input was journaled).
10690
+ // Re-run once against its current Thread; any other failure, or a second refusal, keeps the ordinary retry state.
10691
+ const recovery = !target ? deps.recoverRejectedRecipientTarget?.(error) : undefined;
10692
+ const message = prepared.message ?? prepared.edited_message;
10693
+ if (recovery && message && message.message_thread_id === recovery.rejected.threadId &&
10694
+ message.chat?.id === recovery.rejected.chatId && recovery.current.chatId === recovery.rejected.chatId &&
10695
+ recovery.current.threadId !== recovery.rejected.threadId) {
10696
+ deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "recipient-target-recovery", updateId: update.update_id,
10697
+ chatId: recovery.current.chatId, staleThreadId: recovery.rejected.threadId, threadId: recovery.current.threadId });
10698
+ return await executeUpdate(projectTo(recovery.current), ctx, signal);
10699
+ }
10676
10700
  throw error;
10677
10701
  }
10678
10702
  },
@@ -10753,6 +10777,9 @@ export function createTelegramUpdateAdmissionLifecycleAssembly<
10753
10777
  spendHistoricalInput: undefined,
10754
10778
  onHeldSourcesPrepared: undefined,
10755
10779
  prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
10780
+ ...(deps.follower.recoverRejectedRecipientTarget
10781
+ ? { recoverRejectedRecipientTarget: deps.follower.recoverRejectedRecipientTarget }
10782
+ : {}),
10756
10783
  });
10757
10784
  },
10758
10785
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -187,7 +187,8 @@ interface TelegramWorkspaceRetirementFenceBase {
187
187
  retirementIntentId: string;
188
188
  profileKey: string;
189
189
  bindingKey: string;
190
- slot: string;
190
+ /** Null is admitted only for explicit manual cleanup of a slotless binding. */
191
+ slot: string | null;
191
192
  target: TelegramTarget & { threadId: number };
192
193
  leaderEpoch: number | string;
193
194
  retirementRequestedAtMs: number;
@@ -228,7 +229,7 @@ export interface TelegramWorkspaceDeletionPermit {
228
229
  readonly retirementIntentId: string;
229
230
  readonly profileKey: string;
230
231
  readonly bindingKey: string;
231
- readonly slot: string;
232
+ readonly slot: string | null;
232
233
  readonly target: TelegramTarget & { threadId: number };
233
234
  readonly leaderEpoch: number | string;
234
235
  readonly issuedAtMs: number;
@@ -376,7 +377,7 @@ export interface TelegramWorkspaceAdmissionLedger {
376
377
  operationId: string;
377
378
  cleanupWorkSetId: string;
378
379
  bindingKey: string;
379
- slot: string;
380
+ slot: string | null;
380
381
  target: TelegramTarget & { threadId: number };
381
382
  leaderEpoch: number | string;
382
383
  cleanupRequestedAtMs: number;
@@ -538,8 +539,8 @@ function normalizeFence(
538
539
  !isBoundedText(value.retirementIntentId) ||
539
540
  value.profileKey !== profileKey ||
540
541
  !isBoundedText(value.bindingKey) ||
541
- typeof value.slot !== "string" ||
542
- !/^[A-Z]$/u.test(value.slot) ||
542
+ !((typeof value.slot === "string" && /^[A-Z]$/u.test(value.slot)) ||
543
+ (value.destructiveKind === "manual-thread-cleanup" && value.slot === null)) ||
543
544
  !target ||
544
545
  !isLeaderEpoch(value.leaderEpoch) ||
545
546
  !isSafeTimestamp(value.retirementRequestedAtMs) ||
@@ -1869,7 +1870,7 @@ export function createTelegramWorkspaceAdmissionLedger(
1869
1870
  operationId: string;
1870
1871
  retirementIntentId: string;
1871
1872
  bindingKey: string;
1872
- slot: string;
1873
+ slot: string | null;
1873
1874
  target: TelegramTarget & { threadId: number };
1874
1875
  leaderEpoch: number | string;
1875
1876
  retirementRequestedAtMs: number;
@@ -2307,7 +2308,7 @@ export function createTelegramWorkspaceAdmissionLedger(
2307
2308
  }),
2308
2309
  listReservedSlots: () => {
2309
2310
  const fence = read().fence;
2310
- return fence && isTelegramWorkspaceRetirementFence(fence)
2311
+ return fence && isTelegramWorkspaceRetirementFence(fence) && fence.slot !== null
2311
2312
  ? [fence.slot]
2312
2313
  : [];
2313
2314
  },
@@ -1764,6 +1764,8 @@ export async function runTelegramWorkspaceRetirementLifecycle(input: {
1764
1764
  owner,
1765
1765
  leaderEpoch: epoch,
1766
1766
  });
1767
+ if (fence.slot === null)
1768
+ return { kind: "retained", reason: "stale-intent" };
1767
1769
  input.admission.completeRetirementFence(fence);
1768
1770
  return {
1769
1771
  kind: "retired",
@@ -1892,6 +1894,7 @@ export function createTelegramWorkspaceSlotRotation(
1892
1894
  binding: TelegramWorkspaceThreadBinding,
1893
1895
  ) => TelegramWorkspaceExternalProtectionEvidence;
1894
1896
  recordEvent: (message: string, details: Record<string, unknown>) => void;
1897
+ onSlotReused?: (bindingKey: string, isCurrent: () => boolean) => Promise<void>;
1895
1898
  },
1896
1899
  ): TelegramWorkspaceCapacityRunner {
1897
1900
  const requests = createTelegramWorkspaceOperationGate();
@@ -1906,7 +1909,7 @@ export function createTelegramWorkspaceSlotRotation(
1906
1909
  return requests.runExclusive(async () => {
1907
1910
  if (!isCurrent())
1908
1911
  throw new Error("Telegram Workspace allocation lost leader authority.");
1909
- const rotate = async () => {
1912
+ const rotate = async (options?: { reuseWhenProtected?: boolean }) => {
1910
1913
  const result = await input.runExclusive(async () => {
1911
1914
  if (!isCurrent())
1912
1915
  throw new Error(
@@ -1959,6 +1962,57 @@ export function createTelegramWorkspaceSlotRotation(
1959
1962
  },
1960
1963
  });
1961
1964
  });
1965
+ if (
1966
+ options?.reuseWhenProtected &&
1967
+ result.kind === "blocked" &&
1968
+ result.stage === "preparation" &&
1969
+ result.reason === "protected-capacity"
1970
+ ) {
1971
+ // Every letter is protected, so deleting a Thread is off the table: reassign the
1972
+ // least recently used letter as metadata and keep its Thread, journals and work.
1973
+ const reused = await input.runExclusive(async () => {
1974
+ if (!isCurrent())
1975
+ throw new Error(
1976
+ "Telegram Workspace slot reuse lost leader authority.",
1977
+ );
1978
+ await input.store.load();
1979
+ if (!isCurrent())
1980
+ throw new Error(
1981
+ "Telegram Workspace slot reuse lost leader authority.",
1982
+ );
1983
+ return input.store.reuseLeastRecentlyUsedWorkspaceSlot(isCurrent);
1984
+ });
1985
+ if (!isCurrent())
1986
+ throw new Error("Telegram Workspace rotation lost leader authority.");
1987
+ if (reused) {
1988
+ if (reused.kind === "reused") {
1989
+ // The store commit is final. Await display repair before the next allocation,
1990
+ // but never roll back or repeat a published reuse after a transport failure.
1991
+ try {
1992
+ await input.onSlotReused?.(reused.bindingKey, isCurrent);
1993
+ } catch (error) {
1994
+ try {
1995
+ input.recordEvent("Telegram Workspace slot display update failed.", {
1996
+ slot: reused.slot,
1997
+ error: error instanceof Error ? error.message : String(error),
1998
+ });
1999
+ } catch {
2000
+ /* A diagnostic failure grants no rollback or another display attempt. */
2001
+ }
2002
+ }
2003
+ if (!isCurrent())
2004
+ throw new Error("Telegram Workspace rotation lost leader authority.");
2005
+ try {
2006
+ input.recordEvent("Telegram Workspace slot reused.", {
2007
+ slot: reused.slot,
2008
+ });
2009
+ } catch {
2010
+ /* Diagnostics cannot turn a published reassignment into another attempt. */
2011
+ }
2012
+ }
2013
+ return { kind: "reused" as const };
2014
+ }
2015
+ }
1962
2016
  if (
1963
2017
  result.kind !== "retired" &&
1964
2018
  result.kind !== "not-needed" &&
@@ -2104,7 +2158,7 @@ export function createTelegramWorkspaceSlotRotation(
2104
2158
  }
2105
2159
  }
2106
2160
  }
2107
- await rotate();
2161
+ await rotate({ reuseWhenProtected: true });
2108
2162
  return operation();
2109
2163
  }
2110
2164
  });
@@ -27,6 +27,50 @@ export type TelegramWorkspaceSlotAllocation =
27
27
  | { kind: "reclaim"; candidate: TelegramWorkspaceSlotOccupancy }
28
28
  | { kind: "blocked"; reason: "invalid-state" | "protected-capacity" };
29
29
 
30
+ /** A binding whose letter may be reassigned without touching its Thread, journals or accepted work. */
31
+ export interface TelegramWorkspaceReusableSlot {
32
+ bindingKey: string;
33
+ /** Uppercase display letter currently held by the binding. */
34
+ slot: string;
35
+ /** A live owner counts as most recently used, whatever its stored clocks say. */
36
+ live: boolean;
37
+ /** Last known use: inactivity start for detached bindings, otherwise the last binding update. */
38
+ lastUsedMs: number;
39
+ }
40
+
41
+ /**
42
+ * Metadata-only fallback for a full A-Z pool: pick the least recently used letter,
43
+ * preferring detached bindings over live ones. Unlike pressure retirement this
44
+ * ignores accepted-work protection because reuse deletes nothing.
45
+ * Returns undefined for an empty or malformed candidate set.
46
+ */
47
+ export function selectTelegramWorkspaceReusableSlot(
48
+ candidates: readonly TelegramWorkspaceReusableSlot[],
49
+ ): TelegramWorkspaceReusableSlot | undefined {
50
+ const slots = new Set<string>();
51
+ const keys = new Set<string>();
52
+ for (const candidate of candidates) {
53
+ if (
54
+ !/^[A-Z]$/u.test(candidate.slot) ||
55
+ !candidate.bindingKey ||
56
+ !Number.isSafeInteger(candidate.lastUsedMs) ||
57
+ candidate.lastUsedMs < 0 ||
58
+ slots.has(candidate.slot) ||
59
+ keys.has(candidate.bindingKey)
60
+ )
61
+ return undefined;
62
+ slots.add(candidate.slot);
63
+ keys.add(candidate.bindingKey);
64
+ }
65
+ const [selected] = [...candidates].sort(
66
+ (left, right) =>
67
+ Number(left.live) - Number(right.live) ||
68
+ left.lastUsedMs - right.lastUsedMs ||
69
+ left.slot.localeCompare(right.slot),
70
+ );
71
+ return selected ? { ...selected } : undefined;
72
+ }
73
+
30
74
  function isSlot(slot: string): boolean {
31
75
  return /^[a-z]$/u.test(slot);
32
76
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.57.3",
3
+ "version": "0.57.7",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "scripts": {
30
30
  "test": "node --experimental-strip-types --import ./tests/fixtures/canonical-tmpdir.ts --test --test-concurrency=4 --test-reporter=dot tests/*.test.ts",
31
+ "test:low-concurrency": "node --experimental-strip-types --import ./tests/fixtures/canonical-tmpdir.ts --test --test-concurrency=2 --test-reporter=dot tests/*.test.ts",
31
32
  "test:verbose": "node --experimental-strip-types --import ./tests/fixtures/canonical-tmpdir.ts --test --test-reporter=spec tests/*.test.ts",
32
33
  "typecheck": "tsc --noEmit",
33
34
  "build": "node scripts/build-dist.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.36.1",
3
+ "version": "0.36.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -45,12 +45,12 @@
45
45
  ],
46
46
  "dependencies": {
47
47
  "@llblab/pi-actors": "0.55.0",
48
- "@llblab/pi-claude-usage": "0.2.2",
48
+ "@llblab/pi-claude-usage": "0.2.3",
49
49
  "@llblab/pi-clean-room": "0.3.0",
50
- "@llblab/pi-codex-usage": "0.13.1",
50
+ "@llblab/pi-codex-usage": "0.13.2",
51
51
  "@llblab/pi-grow-loop": "0.9.0",
52
52
  "@llblab/pi-state-flow": "0.26.5",
53
- "@llblab/pi-telegram": "0.57.3",
53
+ "@llblab/pi-telegram": "0.57.7",
54
54
  "@llblab/skills": "1.15.0"
55
55
  },
56
56
  "bundledDependencies": [