@llblab/pi-kit 0.36.1 → 0.36.3

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 (77) hide show
  1. package/CHANGELOG.md +10 -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 +43 -5
  13. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +45 -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 +23 -11
  17. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +24 -6
  18. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +2 -1
  19. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +7 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +7 -4
  21. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +22 -4
  22. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +52 -10
  23. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +1 -0
  24. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.js +9 -0
  25. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -1
  26. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +1 -1
  27. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +3 -2
  28. package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +4 -4
  29. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -2
  30. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +6 -3
  31. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +50 -9
  32. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +3 -2
  33. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +120 -14
  34. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +13 -0
  35. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +12 -0
  36. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +39 -3
  37. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +144 -16
  38. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +1 -1
  39. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +15 -8
  40. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +14 -1
  41. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +122 -8
  42. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +9 -6
  43. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +4 -3
  44. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +3 -3
  45. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +1 -0
  46. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +52 -2
  47. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +17 -0
  48. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +25 -0
  49. package/node_modules/@llblab/pi-telegram/dist/package.json +2 -1
  50. package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
  51. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +23 -3
  52. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -0
  53. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  54. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +24 -1
  55. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +30 -13
  56. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +25 -8
  57. package/node_modules/@llblab/pi-telegram/lib/bus.ts +11 -0
  58. package/node_modules/@llblab/pi-telegram/lib/commands.ts +34 -8
  59. package/node_modules/@llblab/pi-telegram/lib/extension.ts +54 -11
  60. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +12 -0
  61. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +2 -0
  62. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +4 -3
  63. package/node_modules/@llblab/pi-telegram/lib/menu.ts +4 -4
  64. package/node_modules/@llblab/pi-telegram/lib/pi.ts +2 -0
  65. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +79 -16
  66. package/node_modules/@llblab/pi-telegram/lib/routing.ts +142 -26
  67. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +18 -0
  68. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +173 -17
  69. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +14 -8
  70. package/node_modules/@llblab/pi-telegram/lib/threads.ts +166 -10
  71. package/node_modules/@llblab/pi-telegram/lib/updates.ts +9 -6
  72. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +8 -7
  73. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +56 -2
  74. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +44 -0
  75. package/node_modules/@llblab/pi-telegram/package.json +2 -1
  76. package/node_modules/@llblab/pi-telegram/scripts/run-macos-tests.mjs +43 -0
  77. package/package.json +4 -4
@@ -505,6 +505,19 @@ export declare class TelegramApiStaleTargetError extends Error {
505
505
  threadId: number;
506
506
  });
507
507
  }
508
+ /** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
509
+ export declare class TelegramApiFollowerNotAllowedError extends Error {
510
+ readonly requestTarget: {
511
+ chatId: number;
512
+ threadId: number;
513
+ } | undefined;
514
+ /** Present only after a structured pre-execution refusal matches the requested API method. */
515
+ readonly rejectedRequestMethod: string | undefined;
516
+ constructor(message: string, requestTarget?: {
517
+ chatId: number;
518
+ threadId: number;
519
+ }, rejectedRequestMethod?: string);
520
+ }
508
521
  export declare function getTelegramApiErrorRequestTarget(error: unknown): {
509
522
  chatId: number;
510
523
  threadId: number;
@@ -338,6 +338,18 @@ export class TelegramApiStaleTargetError extends Error {
338
338
  this.requestTarget = { ...requestTarget };
339
339
  }
340
340
  }
341
+ /** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
342
+ export class TelegramApiFollowerNotAllowedError extends Error {
343
+ requestTarget;
344
+ /** Present only after a structured pre-execution refusal matches the requested API method. */
345
+ rejectedRequestMethod;
346
+ constructor(message, requestTarget, rejectedRequestMethod) {
347
+ super(message);
348
+ this.name = "TelegramApiFollowerNotAllowedError";
349
+ this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
350
+ this.rejectedRequestMethod = rejectedRequestMethod;
351
+ }
352
+ }
341
353
  export function getTelegramApiErrorRequestTarget(error) {
342
354
  const target = error instanceof TelegramApiHttpError ||
343
355
  error instanceof TelegramApiStaleTargetError
@@ -3,7 +3,11 @@
3
3
  * Zones: telegram threads, workspace lifecycle
4
4
  * Owns fail-closed cleanup eligibility projection without persistence or Bot API effects
5
5
  */
6
- import type { TelegramWorkspaceDeletionPermit, TelegramWorkspaceDestructiveFence, TelegramWorkspaceRetirementFence } from "./workspace-admission.ts";
6
+ import type { TelegramWorkspaceDeletionPermit, TelegramWorkspaceDestructiveFence, TelegramWorkspaceRetirementFence, TelegramWorkspaceAdmissionLedger, TelegramWorkspaceAdmissionOwner } from "./workspace-admission.ts";
7
+ import type { TelegramTopicTargetStore, TelegramWorkspaceThreadBinding } from "./threads.ts";
8
+ import type { TelegramWorkspaceOperationRunner } from "./workspace-retirement.ts";
9
+ import type { TelegramWorkspaceThreadDeletionTransport } from "./telegram-api.ts";
10
+ import type { TelegramProcessLiveness } from "./process-identity.ts";
7
11
  export interface TelegramThreadCleanupBindingSnapshot {
8
12
  cwd: string;
9
13
  workspaceKey: string;
@@ -38,7 +42,8 @@ export interface TelegramThreadCleanupCandidate {
38
42
  sessionId?: string;
39
43
  sessionKey?: string;
40
44
  instanceSlot: string;
41
- slot: string;
45
+ /** Null is explicit absence in reviewed work; it never denotes a reusable letter. */
46
+ slot: string | null;
42
47
  target: {
43
48
  chatId: number;
44
49
  threadId: number;
@@ -155,6 +160,8 @@ export declare function createTelegramThreadCleanupWorkStore(options: {
155
160
  runtimeDir?: string;
156
161
  profileName: string;
157
162
  tokenSha256: string;
163
+ /** Exact captured ledger identity; standalone fixtures may use the plain profile identity. */
164
+ admissionProfileKey?: string;
158
165
  maxWorkSets?: number;
159
166
  maxBytes?: number;
160
167
  getNowMs?: () => number;
@@ -185,6 +192,7 @@ export declare function executeTelegramInactiveThreadCleanup(input: {
185
192
  }>;
186
193
  export declare function createTelegramThreadCleanupPermitRuntime(deps: {
187
194
  ledger: {
195
+ getProfileKey(): string;
188
196
  read(): {
189
197
  fence?: TelegramWorkspaceDestructiveFence;
190
198
  };
@@ -192,7 +200,7 @@ export declare function createTelegramThreadCleanupPermitRuntime(deps: {
192
200
  operationId: string;
193
201
  cleanupWorkSetId: string;
194
202
  bindingKey: string;
195
- slot: string;
203
+ slot: string | null;
196
204
  target: {
197
205
  chatId: number;
198
206
  threadId: number;
@@ -256,6 +264,34 @@ export interface TelegramInactiveThreadCleanupCoordinatorDeps<TBinding> {
256
264
  deleteWithPermit(permit: TelegramThreadCleanupDeletionPermit, candidate: TelegramThreadCleanupCandidate): Promise<void>;
257
265
  commitBinding(candidate: TelegramThreadCleanupCandidate, currentBinding?: TBinding): Promise<boolean>;
258
266
  }
267
+ export interface TelegramInactiveThreadCleanupAuthority {
268
+ profileName: string;
269
+ leaderEpoch: number | string;
270
+ ledger: TelegramWorkspaceAdmissionLedger;
271
+ isCurrent(): boolean;
272
+ }
273
+ /** Review owns ordinary admission; confirmed cleanup uses the shared gate and its exclusive manual fence instead. */
274
+ export declare function createTelegramInactiveThreadCleanupRuntime(deps: {
275
+ store: Pick<TelegramTopicTargetStore, "load" | "listWorkspaceBindings" | "listReservations" | "listPendingProvisions" | "listPendingCleanups" | "commitInactiveWorkspaceCleanup">;
276
+ captureAuthority(): TelegramInactiveThreadCleanupAuthority | undefined;
277
+ getWorkStore(authority: TelegramInactiveThreadCleanupAuthority): TelegramThreadCleanupWorkStore;
278
+ getProtection(binding: TelegramWorkspaceThreadBinding): {
279
+ liveOwner: TelegramThreadCleanupProtectionState;
280
+ acceptedWork: TelegramThreadCleanupProtectionState;
281
+ deliveryAuthority: TelegramThreadCleanupProtectionState;
282
+ };
283
+ getProcessLiveness(owner: TelegramWorkspaceAdmissionOwner): TelegramProcessLiveness;
284
+ runExclusive<T>(operation: () => Promise<T>): Promise<T>;
285
+ runWorkspaceOperation: TelegramWorkspaceOperationRunner;
286
+ deleteThread: TelegramWorkspaceThreadDeletionTransport;
287
+ onError?: (error: unknown) => void;
288
+ }): {
289
+ review(): Promise<{
290
+ count: number;
291
+ operationId?: string;
292
+ }>;
293
+ clean(operationId: string): Promise<TelegramInactiveThreadCleanupResult>;
294
+ };
259
295
  export declare function createTelegramInactiveThreadCleanupSettingsPort<TBinding>(deps: TelegramInactiveThreadCleanupCoordinatorDeps<TBinding>): (operationId: string) => Promise<TelegramInactiveThreadCleanupResult>;
260
296
  export interface TelegramInactiveThreadCleanupResult {
261
297
  deleted: number;
@@ -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