@llblab/pi-kit 0.36.0 → 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 (78) hide show
  1. package/BACKLOG.md +0 -4
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +5 -5
  4. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +4 -0
  5. package/node_modules/@llblab/pi-claude-usage/README.md +2 -2
  6. package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +2 -3
  7. package/node_modules/@llblab/pi-claude-usage/package.json +1 -1
  8. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +5 -0
  9. package/node_modules/@llblab/pi-codex-usage/README.md +1 -1
  10. package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +2 -3
  11. package/node_modules/@llblab/pi-codex-usage/package.json +1 -1
  12. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +4 -0
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +5 -4
  14. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  15. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +1 -1
  16. package/node_modules/@llblab/pi-state-flow/docs/usage.md +1 -0
  17. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +5 -4
  18. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  19. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  20. package/node_modules/@llblab/pi-telegram/BACKLOG.md +17 -5
  21. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +23 -0
  22. package/node_modules/@llblab/pi-telegram/README.md +4 -2
  23. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +17 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +12 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +17 -6
  26. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +1 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +1 -0
  28. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +42 -10
  29. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +1 -0
  30. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.js +9 -0
  31. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -1
  32. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +3 -2
  33. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -2
  34. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +6 -3
  35. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +50 -9
  36. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +119 -14
  37. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -0
  38. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +9 -0
  39. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +39 -3
  40. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +144 -16
  41. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +1 -1
  42. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +15 -8
  43. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +14 -1
  44. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +122 -8
  45. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +15 -1
  46. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +22 -6
  47. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +4 -3
  48. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +3 -3
  49. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +1 -0
  50. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +52 -2
  51. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +17 -0
  52. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +25 -0
  53. package/node_modules/@llblab/pi-telegram/dist/package.json +2 -1
  54. package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
  55. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +9 -3
  56. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -0
  57. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  58. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +24 -1
  59. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +16 -0
  60. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -5
  61. package/node_modules/@llblab/pi-telegram/lib/bus.ts +3 -0
  62. package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -11
  63. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +12 -0
  64. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +2 -0
  65. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +3 -2
  66. package/node_modules/@llblab/pi-telegram/lib/pi.ts +2 -0
  67. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +79 -16
  68. package/node_modules/@llblab/pi-telegram/lib/routing.ts +137 -24
  69. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +14 -0
  70. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +173 -17
  71. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +14 -8
  72. package/node_modules/@llblab/pi-telegram/lib/threads.ts +166 -10
  73. package/node_modules/@llblab/pi-telegram/lib/updates.ts +33 -6
  74. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +8 -7
  75. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +56 -2
  76. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +44 -0
  77. package/node_modules/@llblab/pi-telegram/package.json +2 -1
  78. package/package.json +5 -5
@@ -7,6 +7,7 @@ import { createHash, randomUUID } from "node:crypto";
7
7
  import { publishTelegramPrivateFile, readTelegramPrivateFile, TelegramPrivateFileError, withTelegramFileTransaction, } from "./locks.js";
8
8
  import { getTelegramJournalPublicationPaths } from "./paths.js";
9
9
  import { isWireRecord as isObject, hasOnlyWireKeys as onlyKeys, isNonNegativeWireInteger as safeTime, } from "./wire.js";
10
+ import { isTelegramWorkspaceRetirementFence } from "./workspace-admission.js";
10
11
  function targetKey(target) {
11
12
  return `${target.chatId}:${target.threadId}`;
12
13
  }
@@ -42,8 +43,7 @@ export function planTelegramInactiveThreadCleanup(input) {
42
43
  !binding.cwd ||
43
44
  !binding.workspaceKey ||
44
45
  !binding.instanceSlot ||
45
- typeof slot !== "string" ||
46
- !slot ||
46
+ (slot !== undefined && (typeof slot !== "string" || !/^[A-Z]$/u.test(slot))) ||
47
47
  !validSessionIdentity(binding) ||
48
48
  !validTarget(binding.target) ||
49
49
  bindingKeys.has(binding.bindingKey) ||
@@ -72,9 +72,7 @@ export function planTelegramInactiveThreadCleanup(input) {
72
72
  for (const binding of input.bindings) {
73
73
  const inactiveSinceMs = binding.inactiveSinceMs;
74
74
  const slot = binding.slot;
75
- if (typeof slot !== "string" ||
76
- !slot ||
77
- !Number.isSafeInteger(inactiveSinceMs) ||
75
+ if (!Number.isSafeInteger(inactiveSinceMs) ||
78
76
  inactiveSinceMs === undefined ||
79
77
  inactiveSinceMs < 0 ||
80
78
  !Number.isSafeInteger(binding.updatedAtMs) ||
@@ -97,7 +95,7 @@ export function planTelegramInactiveThreadCleanup(input) {
97
95
  ? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
98
96
  : {}),
99
97
  instanceSlot: binding.instanceSlot,
100
- slot,
98
+ slot: slot ?? null,
101
99
  target: { ...binding.target },
102
100
  inactiveSinceMs,
103
101
  bindingUpdatedAtMs: binding.updatedAtMs,
@@ -228,8 +226,7 @@ function validateWorkSet(value, profileName) {
228
226
  !validSessionIdentity(raw) ||
229
227
  typeof raw.instanceSlot !== "string" ||
230
228
  !raw.instanceSlot ||
231
- typeof raw.slot !== "string" ||
232
- !raw.slot ||
229
+ (raw.slot !== null && (typeof raw.slot !== "string" || !/^[A-Z]$/u.test(raw.slot))) ||
233
230
  !isObject(raw.target) ||
234
231
  !onlyKeys(raw.target, ["chatId", "threadId"]) ||
235
232
  !validTarget(raw.target) ||
@@ -311,7 +308,11 @@ function validateWorkSet(value, profileName) {
311
308
  return { operationId: value.operationId, createdAtMs, entries };
312
309
  }
313
310
  function sameCandidates(left, right) {
314
- return JSON.stringify(left) === JSON.stringify(right);
311
+ const keys = ["profileName", "bindingKey", "cwd", "workspaceKey", "sessionId", "sessionKey", "instanceSlot", "slot", "inactiveSinceMs", "bindingUpdatedAtMs"];
312
+ return left.length === right.length && left.every((candidate, index) => {
313
+ const other = right[index];
314
+ return keys.every(key => candidate[key] === other[key]) && targetKey(candidate.target) === targetKey(other.target);
315
+ });
315
316
  }
316
317
  function parseTelegramThreadCleanupWorkFile(value, profileName, tokenSha256) {
317
318
  if (!isObject(value) ||
@@ -411,12 +412,12 @@ export function createTelegramThreadCleanupWorkStore(options) {
411
412
  !entry ||
412
413
  entry.bindingUpdatedAtMs !== input.bindingUpdatedAtMs ||
413
414
  permit.destructiveKind !== "manual-thread-cleanup" ||
414
- permit.profileKey !== entry.profileName ||
415
+ permit.profileKey !== (options.admissionProfileKey ?? entry.profileName) ||
415
416
  permit.bindingKey !== entry.bindingKey ||
416
417
  permit.slot !== entry.slot ||
417
418
  targetKey(permit.target) !== targetKey(entry.target) ||
418
419
  !permit.operationId ||
419
- !permit.retirementIntentId ||
420
+ permit.retirementIntentId !== workSet.operationId ||
420
421
  !safeTime(permit.issuedAtMs))
421
422
  throw new Error("Telegram Thread cleanup entry or deletion permit is stale or mismatched.");
422
423
  if (entry.state !== "prepared") {
@@ -522,7 +523,7 @@ export function createTelegramThreadCleanupPermitRuntime(deps) {
522
523
  const fence = deps.ledger.read().fence;
523
524
  return fence?.operationId === operationId &&
524
525
  fence.destructiveKind === "manual-thread-cleanup" &&
525
- fence.profileKey === candidate.profileName &&
526
+ fence.profileKey === deps.ledger.getProfileKey() &&
526
527
  fence.retirementIntentId === workSetId &&
527
528
  fence.bindingKey === candidate.bindingKey &&
528
529
  fence.slot === candidate.slot &&
@@ -629,14 +630,132 @@ export function createTelegramThreadCleanupPermitRuntime(deps) {
629
630
  : { kind: "already-issued" };
630
631
  },
631
632
  async settleDeleted(fence, commit) {
633
+ const assertAuthority = () => {
634
+ const owner = deps.getOwner();
635
+ if (deps.getLeaderEpoch() !== fence.leaderEpoch ||
636
+ owner.processId !== fence.owner.processId || owner.processBirthId !== fence.owner.processBirthId)
637
+ throw new Error("Telegram Thread cleanup settlement authority changed.");
638
+ };
639
+ assertAuthority();
632
640
  const ready = deps.ledger.confirmThreadCleanupAbsence(fence);
633
- if (!(await commit()))
641
+ const committed = await commit();
642
+ assertAuthority();
643
+ if (!committed)
634
644
  return "commit-pending";
635
645
  deps.ledger.completeThreadCleanupFence(ready);
636
646
  return "completed";
637
647
  },
638
648
  };
639
649
  }
650
+ /** Review owns ordinary admission; confirmed cleanup uses the shared gate and its exclusive manual fence instead. */
651
+ export function createTelegramInactiveThreadCleanupRuntime(deps) {
652
+ const capture = () => {
653
+ const authority = deps.captureAuthority();
654
+ if (!authority || !authority.isCurrent())
655
+ throw new Error("Inactive Thread cleanup requires current leader authority.");
656
+ const ledgerKey = authority.ledger.getProfileKey();
657
+ const owner = authority.ledger.getOwner();
658
+ const assertCurrent = () => {
659
+ if (!authority.isCurrent() || authority.ledger.getProfileKey() !== ledgerKey)
660
+ throw new Error("Inactive Thread cleanup authority changed.");
661
+ };
662
+ const evidence = () => {
663
+ assertCurrent();
664
+ return captureTelegramInactiveThreadCleanupEvidence({
665
+ profileName: authority.profileName,
666
+ listBindings: deps.store.listWorkspaceBindings,
667
+ getProtection: deps.getProtection,
668
+ listReservations: deps.store.listReservations,
669
+ listPendingProvisions: deps.store.listPendingProvisions,
670
+ listPendingCleanups: deps.store.listPendingCleanups,
671
+ });
672
+ };
673
+ return { authority, owner, assertCurrent, evidence };
674
+ };
675
+ return {
676
+ async review() {
677
+ const scope = capture();
678
+ return createTelegramInactiveThreadCleanupReviewRuntime({
679
+ getProfileName: () => scope.authority.profileName,
680
+ listBindings: deps.store.listWorkspaceBindings,
681
+ getProtection: deps.getProtection,
682
+ listReservations: deps.store.listReservations,
683
+ listPendingProvisions: deps.store.listPendingProvisions,
684
+ listPendingCleanups: deps.store.listPendingCleanups,
685
+ getWorkStore() { scope.assertCurrent(); return deps.getWorkStore(scope.authority); },
686
+ runWorkspaceOperation(metadata, operation) {
687
+ return deps.runWorkspaceOperation(metadata, async () => {
688
+ scope.assertCurrent();
689
+ await deps.store.load();
690
+ scope.assertCurrent();
691
+ return operation();
692
+ });
693
+ },
694
+ }).review();
695
+ },
696
+ async clean(operationId) {
697
+ const scope = capture();
698
+ return deps.runExclusive(async () => {
699
+ scope.assertCurrent();
700
+ const { authority } = scope;
701
+ const work = deps.getWorkStore(authority);
702
+ const exactBinding = (candidate) => {
703
+ const fresh = planTelegramInactiveThreadCleanup(scope.evidence()).find(binding => binding.bindingKey === candidate.bindingKey);
704
+ if (!fresh || !sameCandidates([fresh], [candidate]))
705
+ return undefined;
706
+ return deps.store.listWorkspaceBindings().find(binding => binding.bindingKey === fresh.bindingKey);
707
+ };
708
+ const loadExactBinding = async (candidate) => {
709
+ scope.assertCurrent();
710
+ await deps.store.load();
711
+ scope.assertCurrent();
712
+ return exactBinding(candidate);
713
+ };
714
+ const permitRuntime = createTelegramThreadCleanupPermitRuntime({
715
+ ledger: authority.ledger,
716
+ getLeaderEpoch: () => authority.isCurrent() ? authority.leaderEpoch : undefined,
717
+ getProfileName: () => authority.profileName,
718
+ getOwner: () => scope.owner,
719
+ canAdoptFence: fence => authority.isCurrent() && deps.getProcessLiveness(fence.owner) === "dead",
720
+ revalidateUnderFence: async (candidate) => !!(await loadExactBinding(candidate)),
721
+ });
722
+ return cleanReviewedInactiveThreads({
723
+ store: work, operationId, permitRuntime,
724
+ resolveFullBinding: loadExactBinding,
725
+ async deleteWithPermit(permit, candidate) {
726
+ try {
727
+ await deps.deleteThread(() => {
728
+ scope.assertCurrent();
729
+ const fence = authority.ledger.read().fence;
730
+ if (!fence || !isTelegramWorkspaceRetirementFence(fence) ||
731
+ fence.destructiveKind !== "manual-thread-cleanup" || fence.phase !== "deletion-issued" ||
732
+ fence.operationId !== permit.operationId || fence.retirementIntentId !== operationId ||
733
+ fence.profileKey !== permit.profileKey || fence.bindingKey !== candidate.bindingKey ||
734
+ fence.slot !== candidate.slot || fence.deletionIssuedAtMs !== permit.issuedAtMs ||
735
+ fence.leaderEpoch !== authority.leaderEpoch || fence.owner.processId !== scope.owner.processId ||
736
+ fence.owner.processBirthId !== scope.owner.processBirthId ||
737
+ targetKey(fence.target) !== targetKey(permit.target) || !exactBinding(candidate))
738
+ throw new Error("Inactive Thread cleanup deletion permit is stale.");
739
+ return { ...permit.target };
740
+ });
741
+ scope.assertCurrent();
742
+ }
743
+ catch (error) {
744
+ deps.onError?.(error);
745
+ throw error;
746
+ }
747
+ },
748
+ async commitBinding(candidate) {
749
+ scope.assertCurrent();
750
+ await deps.store.load();
751
+ scope.assertCurrent();
752
+ return deps.store.commitInactiveWorkspaceCleanup(candidate, authority.isCurrent);
753
+ },
754
+ });
755
+ });
756
+ },
757
+ };
758
+ }
640
759
  export function createTelegramInactiveThreadCleanupSettingsPort(deps) {
641
760
  return (operationId) => cleanReviewedInactiveThreads({ ...deps, operationId });
642
761
  }
@@ -654,9 +773,18 @@ export async function cleanReviewedInactiveThreads(input) {
654
773
  for (const entry of workSet.entries) {
655
774
  if (entry.state === "deleted") {
656
775
  const ready = input.permitRuntime.findCommitReady(entry, input.operationId);
657
- if (ready)
658
- await input.permitRuntime.settleDeleted(ready, async () => true);
659
- deleted += 1;
776
+ if (ready) {
777
+ const settlement = await input.permitRuntime.settleDeleted(ready, async () => true);
778
+ if (settlement === "completed")
779
+ deleted += 1;
780
+ else
781
+ outcomeUnknown += 1;
782
+ }
783
+ else if (input.permitRuntime.diagnoseRecovery(entry, input.operationId) !== "none") {
784
+ outcomeUnknown += 1;
785
+ }
786
+ else
787
+ deleted += 1;
660
788
  continue;
661
789
  }
662
790
  if (entry.state === "outcome-unknown") {
@@ -62,7 +62,7 @@ export declare function createTelegramThreadDisplaySettingsRuntime(deps: {
62
62
  };
63
63
  /** Caller owns triggering and reporting; no timer or background retry is created. */
64
64
  export declare function createTelegramThreadDisplayReconciler(deps: TelegramThreadDisplayReconcilerDeps): {
65
- reconcile(): Promise<{
65
+ reconcile(bindingKey?: string): Promise<{
66
66
  changed: number;
67
67
  }>;
68
68
  };
@@ -123,9 +123,14 @@ export function resolveTelegramWorkspaceDisplayNames(bindings, mode, liveBinding
123
123
  if (manualName) {
124
124
  labels.set(binding.bindingKey, boundedLabel(manualName));
125
125
  }
126
+ else if (!slot) {
127
+ // A displaced binding retains its generated identity, not the letter now owned elsewhere.
128
+ const name = binding.threadName ? labelText(binding.threadName) : undefined;
129
+ if (name)
130
+ labels.set(binding.bindingKey, boundedLabel(name));
131
+ }
126
132
  else if (mode === "letters") {
127
- if (slot)
128
- labels.set(binding.bindingKey, slot);
133
+ labels.set(binding.bindingKey, slot);
129
134
  }
130
135
  else if (mode === "names") {
131
136
  const name = binding.threadName ? labelText(binding.threadName) : slot;
@@ -142,8 +147,6 @@ export function resolveTelegramWorkspaceDisplayNames(bindings, mode, liveBinding
142
147
  bindings.filter((candidate) => candidate.cwd === binding.cwd)
143
148
  .length > 1
144
149
  : (liveDirectoryCounts.get(binding.cwd) ?? 0) > 1;
145
- if (showSuffix && !slot)
146
- continue;
147
150
  const suffix = !showSuffix
148
151
  ? ""
149
152
  : mode === "directory-title"
@@ -239,7 +242,7 @@ export function createTelegramThreadDisplaySettingsRuntime(deps) {
239
242
  /** Caller owns triggering and reporting; no timer or background retry is created. */
240
243
  export function createTelegramThreadDisplayReconciler(deps) {
241
244
  let tail = Promise.resolve();
242
- const apply = async () => {
245
+ const apply = async (bindingKey) => {
243
246
  const epoch = deps.getLeaderEpoch();
244
247
  const profile = deps.getProfileKey();
245
248
  const mode = deps.getMode();
@@ -257,6 +260,8 @@ export function createTelegramThreadDisplayReconciler(deps) {
257
260
  const titles = resolveTelegramWorkspaceDisplayNames(bindings, mode, liveBindingKeys);
258
261
  let changed = 0;
259
262
  for (const binding of bindings) {
263
+ if (bindingKey !== undefined && binding.bindingKey !== bindingKey)
264
+ continue;
260
265
  const isBindingCurrent = deps.captureBindingAuthority(binding);
261
266
  if (!isBindingCurrent)
262
267
  continue;
@@ -275,12 +280,14 @@ export function createTelegramThreadDisplayReconciler(deps) {
275
280
  assertBinding(binding);
276
281
  if ((binding.displayTitle ?? binding.threadName) === title)
277
282
  continue;
278
- await deps.callApi("editForumTopic", {
283
+ const acknowledged = await deps.callApi("editForumTopic", {
279
284
  chat_id: binding.target.chatId,
280
285
  message_thread_id: binding.target.threadId,
281
286
  name: title,
282
287
  }, { maxAttempts: 1 });
283
288
  assertBinding(binding);
289
+ if (acknowledged !== true)
290
+ throw new Error("Telegram Thread display update was not acknowledged.");
284
291
  if (!deps.store.setWorkspaceDisplayTitle(binding, title)) {
285
292
  throw new Error("Telegram Thread display binding changed before title commit.");
286
293
  }
@@ -296,8 +303,8 @@ export function createTelegramThreadDisplayReconciler(deps) {
296
303
  return { changed };
297
304
  };
298
305
  return {
299
- reconcile() {
300
- const run = tail.then(apply);
306
+ reconcile(bindingKey) {
307
+ const run = tail.then(() => apply(bindingKey));
301
308
  tail = run.catch(() => undefined);
302
309
  return run;
303
310
  },
@@ -543,7 +543,7 @@ export interface TelegramTopicTargetStore {
543
543
  sessionId?: string;
544
544
  sessionKey?: string;
545
545
  instanceSlot: string;
546
- slot: string;
546
+ slot: string | null;
547
547
  bindingKey: string;
548
548
  target: {
549
549
  chatId: number;
@@ -556,6 +556,19 @@ export interface TelegramTopicTargetStore {
556
556
  captureWorkspaceSlotOccupancy: (getExternalProtection: TelegramWorkspaceExternalProtectionCapture, options?: {
557
557
  expectedRetirement?: TelegramWorkspaceRetirementIntent;
558
558
  }) => TelegramWorkspaceSlotOccupancySnapshot;
559
+ /**
560
+ * Fallback for a full A-Z pool: strips the display letter from the least recently used
561
+ * binding (and its current Thread record) and persists, so a fresh allocation can take it.
562
+ * Metadata only: the Thread, journals and accepted work stay untouched, and a binding or
563
+ * letter held by a claim, reservation, provision, cleanup or retirement is never chosen.
564
+ */
565
+ reuseLeastRecentlyUsedWorkspaceSlot: (isCurrent: () => boolean) => Promise<{
566
+ kind: "free";
567
+ } | {
568
+ kind: "reused";
569
+ bindingKey: string;
570
+ slot: string;
571
+ } | undefined>;
559
572
  hasWorkspaceBinding: (cwd: string, sessionId?: string) => boolean;
560
573
  setWorkspaceDisplayTitle: (expected: TelegramWorkspaceThreadBinding, title: string) => boolean;
561
574
  markWorkspaceBindingInactiveByTarget: (target: TelegramTarget, inactiveSinceMs?: number) => boolean;
@@ -17,7 +17,7 @@ import { resolveTelegramProfileTempFilePath } from "./paths.js";
17
17
  import { createTelegramRuntimeProjectionStore } from "./status.js";
18
18
  import * as ThreadReconciler from "./thread-reconciler.js";
19
19
  import { createTelegramSessionKey, createTelegramWorkspaceBindingIdentityWithKey, createTelegramWorkspaceDirectoryKey, normalizeTelegramSessionId, normalizeTelegramWorkspacePath, TELEGRAM_WORKSPACE_KEY_MAX_LENGTH, } from "./workspace-identity.js";
20
- import { planTelegramWorkspaceSlotAllocation, TELEGRAM_WORKSPACE_SLOTS, TelegramWorkspaceSlotUnavailableError, } from "./workspace-slots.js";
20
+ import { planTelegramWorkspaceSlotAllocation, selectTelegramWorkspaceReusableSlot, TELEGRAM_WORKSPACE_SLOTS, TelegramWorkspaceSlotUnavailableError, } from "./workspace-slots.js";
21
21
  export { createTelegramWorkspaceBindingIdentity, createTelegramWorkspaceDirectoryKey, normalizeTelegramSessionId, normalizeTelegramWorkspacePath, } from "./workspace-identity.js";
22
22
  const TELEGRAM_THREAD_RESERVATION_TTL_MS = 15 * 60 * 1000;
23
23
  function getNextMonotonicSlot(records, reservations, pendingProvisions, nowMs, lastSlot) {
@@ -1871,7 +1871,7 @@ export function createTelegramTopicTargetStore(options) {
1871
1871
  };
1872
1872
  /** Current records, claims, reservations, provisions, cleanups or other retirements still holding a binding's Thread or slot. */
1873
1873
  const isWorkspaceBindingLocallyProtected = (binding, nowMs, options = {}) => {
1874
- const slotMatches = (slot) => (!options.requireBindingSlot || !!binding.slot) && slot === binding.slot;
1874
+ const slotMatches = (slot) => !!binding.slot && slot === binding.slot;
1875
1875
  const targetOrSlotMatches = (candidate) => (!!candidate.target && targetMatches(candidate.target, binding.target)) ||
1876
1876
  slotMatches(candidate.slot);
1877
1877
  return (Array.from(records.values()).some((record) => ThreadReconciler.isCurrentThreadRecord(record) &&
@@ -4572,7 +4572,7 @@ export function createTelegramTopicTargetStore(options) {
4572
4572
  (!cleanupSnapshot.cwd ||
4573
4573
  !cleanupSnapshot.workspaceKey ||
4574
4574
  !cleanupSnapshot.instanceSlot ||
4575
- !cleanupSnapshot.slot ||
4575
+ !(cleanupSnapshot.slot === null || (typeof cleanupSnapshot.slot === "string" && /^[A-Z]$/u.test(cleanupSnapshot.slot))) ||
4576
4576
  !cleanupSnapshot.bindingKey ||
4577
4577
  ((cleanupSnapshot.sessionId !== undefined ||
4578
4578
  cleanupSnapshot.sessionKey !== undefined) &&
@@ -4585,7 +4585,7 @@ export function createTelegramTopicTargetStore(options) {
4585
4585
  cleanupSnapshot.target.threadId <= 0))
4586
4586
  return false;
4587
4587
  if (!cleanupSnapshot &&
4588
- (!normalized?.slot || normalized.inactiveSinceMs === undefined))
4588
+ (!normalized || normalized.inactiveSinceMs === undefined))
4589
4589
  return false;
4590
4590
  const mapKey = getWorkspaceBindingMapKey((cleanupSnapshot ?? normalized));
4591
4591
  const binding = workspaceBindings.get(mapKey);
@@ -4597,7 +4597,7 @@ export function createTelegramTopicTargetStore(options) {
4597
4597
  binding.sessionId === cleanupSessionId &&
4598
4598
  binding.sessionKey === cleanupSessionKey &&
4599
4599
  binding.instanceSlot === cleanupSnapshot.instanceSlot &&
4600
- binding.slot === cleanupSnapshot.slot &&
4600
+ (binding.slot ?? null) === cleanupSnapshot.slot &&
4601
4601
  binding.bindingKey === cleanupSnapshot.bindingKey &&
4602
4602
  targetMatches(binding.target, cleanupSnapshot.target) &&
4603
4603
  binding.inactiveSinceMs === cleanupSnapshot.inactiveSinceMs &&
@@ -4710,10 +4710,10 @@ export function createTelegramTopicTargetStore(options) {
4710
4710
  captureWorkspaceSlotOccupancy(getExternalProtection, options) {
4711
4711
  const nowMs = getNowMs();
4712
4712
  const inSnapshot = getExternalProtection.withSnapshot ?? ((capture) => capture());
4713
- const bindings = inSnapshot(() => Array.from(workspaceBindings.values()).map((binding) => {
4714
- const slot = binding.slot?.toLowerCase() ?? "";
4713
+ // A slotless binding (legacy or reassigned) holds no letter, so it can neither block planning nor be retired for capacity.
4714
+ const bindings = inSnapshot(() => Array.from(workspaceBindings.values()).filter((binding) => binding.slot).map((binding) => {
4715
+ const slot = binding.slot.toLowerCase();
4715
4716
  const locallyProtected = isWorkspaceBindingLocallyProtected(binding, nowMs, {
4716
- requireBindingSlot: true,
4717
4717
  isRetirementExcluded: (intent) => isDeepStrictEqual(intent, options?.expectedRetirement),
4718
4718
  });
4719
4719
  const externalProtection = getExternalProtection(cloneWorkspaceBinding(binding));
@@ -4765,6 +4765,120 @@ export function createTelegramTopicTargetStore(options) {
4765
4765
  : [...localReservedSlots, "invalid"];
4766
4766
  return { bindings, reservedSlots };
4767
4767
  },
4768
+ async reuseLeastRecentlyUsedWorkspaceSlot(isCurrent) {
4769
+ if (workspaceRetirementCommitInFlight || !isCurrent())
4770
+ return undefined;
4771
+ const external = captureExternalReservedSlots();
4772
+ if (!external)
4773
+ return undefined;
4774
+ const nowMs = getNowMs();
4775
+ const currentRecords = Array.from(records.values()).filter(ThreadReconciler.isCurrentThreadRecord);
4776
+ const held = new Set([
4777
+ ...external,
4778
+ ...Array.from(workspaceClaims.values()).map((claim) => claim.identity.slot),
4779
+ ...reservations
4780
+ .filter((reservation) => reservation.expiresAtMs === undefined ||
4781
+ reservation.expiresAtMs > nowMs)
4782
+ .map((reservation) => reservation.slot),
4783
+ ...pendingProvisions
4784
+ .filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
4785
+ .map((provision) => provision.slot),
4786
+ ].filter((slot) => !!slot));
4787
+ const occupied = new Set([
4788
+ ...held,
4789
+ ...Array.from(workspaceBindings.values()).flatMap((binding) => binding.slot ? [binding.slot] : []),
4790
+ ...currentRecords.flatMap((record) => (record.slot ? [record.slot] : [])),
4791
+ ]);
4792
+ if (Array.from(TELEGRAM_WORKSPACE_SLOTS, (slot) => slot.toUpperCase()).some((slot) => !occupied.has(slot)))
4793
+ return { kind: "free" };
4794
+ const claimedKeys = new Set(Array.from(workspaceClaims.values()).map((claim) => claim.identity.bindingKey));
4795
+ const candidates = Array.from(workspaceBindings.values()).flatMap((binding) => {
4796
+ const slot = binding.slot;
4797
+ if (!slot ||
4798
+ !/^[A-Z]$/u.test(slot) ||
4799
+ held.has(slot) ||
4800
+ claimedKeys.has(binding.bindingKey) ||
4801
+ hasWorkspaceRetirementConflict({
4802
+ bindingKey: binding.bindingKey,
4803
+ target: binding.target,
4804
+ slot,
4805
+ }) ||
4806
+ pendingCleanups.some((intent) => targetMatches(intent.target, binding.target)) ||
4807
+ // Another Thread already holding the letter is unresolved duplication, not reusable metadata.
4808
+ currentRecords.some((record) => record.slot === slot && !targetMatches(record.target, binding.target)))
4809
+ return [];
4810
+ const live = currentRecords.some((record) => targetMatches(record.target, binding.target));
4811
+ return [
4812
+ {
4813
+ bindingKey: binding.bindingKey,
4814
+ slot,
4815
+ live,
4816
+ lastUsedMs: !live && binding.inactiveSinceMs !== undefined
4817
+ ? binding.inactiveSinceMs
4818
+ : binding.updatedAtMs,
4819
+ },
4820
+ ];
4821
+ });
4822
+ const selected = selectTelegramWorkspaceReusableSlot(candidates);
4823
+ if (!selected)
4824
+ return undefined;
4825
+ const mapKey = Array.from(workspaceBindings.entries()).find(([, binding]) => binding.bindingKey === selected.bindingKey)?.[0];
4826
+ const binding = mapKey ? workspaceBindings.get(mapKey) : undefined;
4827
+ if (!mapKey || !binding)
4828
+ return undefined;
4829
+ const affected = currentRecords.filter((record) => record.slot === selected.slot && targetMatches(record.target, binding.target));
4830
+ const previousBinding = cloneWorkspaceBinding(binding);
4831
+ const previousRecords = affected.map((record) => ({
4832
+ key: getRecordOwnerKey(record),
4833
+ record: cloneRecord(record),
4834
+ identity: identities.get(getRecordOwnerKey(record)),
4835
+ }));
4836
+ const { slot: _slot, ...slotless } = previousBinding;
4837
+ workspaceRetirementCommitInFlight = true;
4838
+ workspaceBindings.set(mapKey, slotless);
4839
+ for (const record of affected) {
4840
+ delete record.slot;
4841
+ const key = getRecordOwnerKey(record);
4842
+ const identity = identities.get(key);
4843
+ if (identity?.slot === selected.slot) {
4844
+ const { slot: _identitySlot, ...rest } = identity;
4845
+ identities.set(key, rest);
4846
+ }
4847
+ }
4848
+ markDirty();
4849
+ const restoreInMemory = () => {
4850
+ workspaceBindings.set(mapKey, cloneWorkspaceBinding(previousBinding));
4851
+ for (const previous of previousRecords) {
4852
+ records.set(previous.key, cloneRecord(previous.record));
4853
+ if (previous.identity)
4854
+ identities.set(previous.key, previous.identity);
4855
+ }
4856
+ markDirty();
4857
+ };
4858
+ try {
4859
+ if (!isCurrent() || !(await persistSnapshot())) {
4860
+ restoreInMemory();
4861
+ return undefined;
4862
+ }
4863
+ return {
4864
+ kind: "reused",
4865
+ bindingKey: selected.bindingKey,
4866
+ slot: selected.slot,
4867
+ };
4868
+ }
4869
+ catch (error) {
4870
+ try {
4871
+ await loadFromDisk();
4872
+ }
4873
+ catch {
4874
+ restoreInMemory();
4875
+ }
4876
+ throw error;
4877
+ }
4878
+ finally {
4879
+ workspaceRetirementCommitInFlight = false;
4880
+ }
4881
+ },
4768
4882
  hasWorkspaceBinding(cwd, sessionId) {
4769
4883
  const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
4770
4884
  const normalizedSessionId = sessionId === undefined
@@ -1487,7 +1487,7 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate extends Te
1487
1487
  runtimeBinding: TelegramUpdateAdmissionRuntimeBinding<TContext>;
1488
1488
  inputCustodyBus?: TelegramInputCustodyBusBindingRuntime<TContext>;
1489
1489
  acquireSourceReference?: (role: "leader" | "follower", binding: TelegramUpdateAdmissionLifecycleJournalBinding) => () => void;
1490
- worker: Omit<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>, "journal" | "getJournalBindingKey" | "getRecipientBindingKey" | "hasAuthority" | "prepareUpdateForExecution">;
1490
+ worker: Omit<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>, "journal" | "getJournalBindingKey" | "getRecipientBindingKey" | "hasAuthority" | "prepareUpdateForExecution" | "recoverRejectedRecipientTarget">;
1491
1491
  leader: {
1492
1492
  resolveBinding: () => TelegramUpdateAdmissionLifecycleJournalBinding | undefined;
1493
1493
  hasAuthority: (ctx: TContext) => boolean;
@@ -1497,6 +1497,7 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate extends Te
1497
1497
  isRegistered: () => boolean;
1498
1498
  getGeneration: () => string | undefined;
1499
1499
  prepareUpdateForExecution: (update: TUpdate) => TUpdate;
1500
+ recoverRejectedRecipientTarget?: NonNullable<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>["recoverRejectedRecipientTarget"]>;
1500
1501
  /** Session succession before worker creation, under the exact generation-fenced binding. */
1501
1502
  prepareBinding?: (binding: TelegramUpdateAdmissionLifecycleJournalBinding & {
1502
1503
  hasAuthority?: () => boolean;
@@ -1508,6 +1509,19 @@ export type TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate extends TelegramJou
1508
1509
  inputCustody?: TelegramCustodyExecutionJournal;
1509
1510
  defaultHandle: (update: TUpdate, ctx: TContext, execution?: TelegramUpdateExecutionFence) => Promise<void>;
1510
1511
  prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
1512
+ /**
1513
+ * Recipient-only recovery for a journaled message whose own Thread changed (for example after its Thread was re-created):
1514
+ * classifies a leader refusal of a reply to the original Thread and returns that Thread plus the owner's current one.
1515
+ */
1516
+ recoverRejectedRecipientTarget?: (error: unknown) => {
1517
+ rejected: {
1518
+ chatId: number;
1519
+ threadId: number;
1520
+ };
1521
+ current: TelegramTarget & {
1522
+ threadId: number;
1523
+ };
1524
+ } | undefined;
1511
1525
  registry?: TelegramUpdateHandlerRegistry;
1512
1526
  };
1513
1527
  /** Compose source-bound routing and late grouped settlement under one worker. */
@@ -6359,12 +6359,14 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
6359
6359
  const typedUpdate = update;
6360
6360
  const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
6361
6361
  // Journal/source evidence remains the original; only the warm released handler projection changes destination.
6362
- const routed = target ? { ...prepared,
6363
- ...(prepared.message ? { message: prepared.message.message_thread_id === target.threadId ? { ...prepared.message }
6364
- : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: target.threadId, reply_to_message: undefined }) } : {}),
6365
- ...(prepared.edited_message ? { edited_message: prepared.edited_message.message_thread_id === target.threadId ? { ...prepared.edited_message }
6366
- : withoutTelegramTurnReplyAnchor({ ...prepared.edited_message, message_thread_id: target.threadId, reply_to_message: undefined }) } : {}),
6367
- } : prepared;
6362
+ // Journal/source evidence remains the original; only the warm released handler projection changes destination.
6363
+ const projectTo = (destination) => ({ ...prepared,
6364
+ ...(prepared.message ? { message: prepared.message.message_thread_id === destination.threadId ? { ...prepared.message }
6365
+ : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
6366
+ ...(prepared.edited_message ? { edited_message: prepared.edited_message.message_thread_id === destination.threadId ? { ...prepared.edited_message }
6367
+ : withoutTelegramTurnReplyAnchor({ ...prepared.edited_message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
6368
+ });
6369
+ const routed = target ? projectTo(target) : prepared;
6368
6370
  try {
6369
6371
  return await executeUpdate(routed, ctx, signal);
6370
6372
  }
@@ -6372,6 +6374,17 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
6372
6374
  if (await deps.settleTerminalExecutionFailure?.(error)) {
6373
6375
  return { kind: "complete" };
6374
6376
  }
6377
+ // The leader refused a reply to a Thread this recipient no longer owns (re-created after the input was journaled).
6378
+ // Re-run once against its current Thread; any other failure, or a second refusal, keeps the ordinary retry state.
6379
+ const recovery = !target ? deps.recoverRejectedRecipientTarget?.(error) : undefined;
6380
+ const message = prepared.message ?? prepared.edited_message;
6381
+ if (recovery && message && message.message_thread_id === recovery.rejected.threadId &&
6382
+ message.chat?.id === recovery.rejected.chatId && recovery.current.chatId === recovery.rejected.chatId &&
6383
+ recovery.current.threadId !== recovery.rejected.threadId) {
6384
+ deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "recipient-target-recovery", updateId: update.update_id,
6385
+ chatId: recovery.current.chatId, staleThreadId: recovery.rejected.threadId, threadId: recovery.current.threadId });
6386
+ return await executeUpdate(projectTo(recovery.current), ctx, signal);
6387
+ }
6375
6388
  throw error;
6376
6389
  }
6377
6390
  },
@@ -6444,6 +6457,9 @@ export function createTelegramUpdateAdmissionLifecycleAssembly(deps) {
6444
6457
  spendHistoricalInput: undefined,
6445
6458
  onHeldSourcesPrepared: undefined,
6446
6459
  prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
6460
+ ...(deps.follower.recoverRejectedRecipientTarget
6461
+ ? { recoverRejectedRecipientTarget: deps.follower.recoverRejectedRecipientTarget }
6462
+ : {}),
6447
6463
  });
6448
6464
  },
6449
6465
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -61,7 +61,8 @@ interface TelegramWorkspaceRetirementFenceBase {
61
61
  retirementIntentId: string;
62
62
  profileKey: string;
63
63
  bindingKey: string;
64
- slot: string;
64
+ /** Null is admitted only for explicit manual cleanup of a slotless binding. */
65
+ slot: string | null;
65
66
  target: TelegramTarget & {
66
67
  threadId: number;
67
68
  };
@@ -93,7 +94,7 @@ export interface TelegramWorkspaceDeletionPermit {
93
94
  readonly retirementIntentId: string;
94
95
  readonly profileKey: string;
95
96
  readonly bindingKey: string;
96
- readonly slot: string;
97
+ readonly slot: string | null;
97
98
  readonly target: TelegramTarget & {
98
99
  threadId: number;
99
100
  };
@@ -205,7 +206,7 @@ export interface TelegramWorkspaceAdmissionLedger {
205
206
  operationId: string;
206
207
  cleanupWorkSetId: string;
207
208
  bindingKey: string;
208
- slot: string;
209
+ slot: string | null;
209
210
  target: TelegramTarget & {
210
211
  threadId: number;
211
212
  };