@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
@@ -22,7 +22,14 @@ import type {
22
22
  TelegramWorkspaceDeletionPermit,
23
23
  TelegramWorkspaceDestructiveFence,
24
24
  TelegramWorkspaceRetirementFence,
25
+ TelegramWorkspaceAdmissionLedger,
26
+ TelegramWorkspaceAdmissionOwner,
25
27
  } from "./workspace-admission.ts";
28
+ import { isTelegramWorkspaceRetirementFence } from "./workspace-admission.ts";
29
+ import type { TelegramTopicTargetStore, TelegramWorkspaceThreadBinding } from "./threads.ts";
30
+ import type { TelegramWorkspaceOperationRunner } from "./workspace-retirement.ts";
31
+ import type { TelegramWorkspaceThreadDeletionTransport } from "./telegram-api.ts";
32
+ import type { TelegramProcessLiveness } from "./process-identity.ts";
26
33
 
27
34
  export interface TelegramThreadCleanupBindingSnapshot {
28
35
  cwd: string;
@@ -56,7 +63,8 @@ export interface TelegramThreadCleanupCandidate {
56
63
  sessionId?: string;
57
64
  sessionKey?: string;
58
65
  instanceSlot: string;
59
- slot: string;
66
+ /** Null is explicit absence in reviewed work; it never denotes a reusable letter. */
67
+ slot: string | null;
60
68
  target: { chatId: number; threadId: number };
61
69
  inactiveSinceMs: number;
62
70
  bindingUpdatedAtMs: number;
@@ -116,8 +124,7 @@ export function planTelegramInactiveThreadCleanup(input: {
116
124
  !binding.cwd ||
117
125
  !binding.workspaceKey ||
118
126
  !binding.instanceSlot ||
119
- typeof slot !== "string" ||
120
- !slot ||
127
+ (slot !== undefined && (typeof slot !== "string" || !/^[A-Z]$/u.test(slot))) ||
121
128
  !validSessionIdentity(binding) ||
122
129
  !validTarget(binding.target) ||
123
130
  bindingKeys.has(binding.bindingKey) ||
@@ -152,8 +159,6 @@ export function planTelegramInactiveThreadCleanup(input: {
152
159
  const inactiveSinceMs = binding.inactiveSinceMs;
153
160
  const slot = binding.slot;
154
161
  if (
155
- typeof slot !== "string" ||
156
- !slot ||
157
162
  !Number.isSafeInteger(inactiveSinceMs) ||
158
163
  inactiveSinceMs === undefined ||
159
164
  inactiveSinceMs < 0 ||
@@ -180,7 +185,7 @@ export function planTelegramInactiveThreadCleanup(input: {
180
185
  ? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
181
186
  : {}),
182
187
  instanceSlot: binding.instanceSlot,
183
- slot,
188
+ slot: slot ?? null,
184
189
  target: { ...binding.target },
185
190
  inactiveSinceMs,
186
191
  bindingUpdatedAtMs: binding.updatedAtMs,
@@ -409,8 +414,7 @@ function validateWorkSet(
409
414
  !validSessionIdentity(raw) ||
410
415
  typeof raw.instanceSlot !== "string" ||
411
416
  !raw.instanceSlot ||
412
- typeof raw.slot !== "string" ||
413
- !raw.slot ||
417
+ (raw.slot !== null && (typeof raw.slot !== "string" || !/^[A-Z]$/u.test(raw.slot))) ||
414
418
  !isObject(raw.target) ||
415
419
  !onlyKeys(raw.target, ["chatId", "threadId"]) ||
416
420
  !validTarget(
@@ -509,7 +513,11 @@ function sameCandidates(
509
513
  left: readonly TelegramThreadCleanupCandidate[],
510
514
  right: readonly TelegramThreadCleanupCandidate[],
511
515
  ): boolean {
512
- return JSON.stringify(left) === JSON.stringify(right);
516
+ const keys = ["profileName", "bindingKey", "cwd", "workspaceKey", "sessionId", "sessionKey", "instanceSlot", "slot", "inactiveSinceMs", "bindingUpdatedAtMs"] as const;
517
+ return left.length === right.length && left.every((candidate, index) => {
518
+ const other = right[index]!;
519
+ return keys.every(key => candidate[key] === other[key]) && targetKey(candidate.target) === targetKey(other.target);
520
+ });
513
521
  }
514
522
 
515
523
  function parseTelegramThreadCleanupWorkFile(
@@ -544,6 +552,8 @@ export function createTelegramThreadCleanupWorkStore(options: {
544
552
  runtimeDir?: string;
545
553
  profileName: string;
546
554
  tokenSha256: string;
555
+ /** Exact captured ledger identity; standalone fixtures may use the plain profile identity. */
556
+ admissionProfileKey?: string;
547
557
  maxWorkSets?: number;
548
558
  maxBytes?: number;
549
559
  getNowMs?: () => number;
@@ -680,12 +690,12 @@ export function createTelegramThreadCleanupWorkStore(options: {
680
690
  !entry ||
681
691
  entry.bindingUpdatedAtMs !== input.bindingUpdatedAtMs ||
682
692
  permit.destructiveKind !== "manual-thread-cleanup" ||
683
- permit.profileKey !== entry.profileName ||
693
+ permit.profileKey !== (options.admissionProfileKey ?? entry.profileName) ||
684
694
  permit.bindingKey !== entry.bindingKey ||
685
695
  permit.slot !== entry.slot ||
686
696
  targetKey(permit.target) !== targetKey(entry.target) ||
687
697
  !permit.operationId ||
688
- !permit.retirementIntentId ||
698
+ permit.retirementIntentId !== workSet.operationId ||
689
699
  !safeTime(permit.issuedAtMs)
690
700
  )
691
701
  throw new Error(
@@ -842,12 +852,13 @@ export async function executeTelegramInactiveThreadCleanup(input: {
842
852
 
843
853
  export function createTelegramThreadCleanupPermitRuntime(deps: {
844
854
  ledger: {
855
+ getProfileKey(): string;
845
856
  read(): { fence?: TelegramWorkspaceDestructiveFence };
846
857
  acquireThreadCleanupFence(input: {
847
858
  operationId: string;
848
859
  cleanupWorkSetId: string;
849
860
  bindingKey: string;
850
- slot: string;
861
+ slot: string | null;
851
862
  target: { chatId: number; threadId: number };
852
863
  leaderEpoch: number | string;
853
864
  cleanupRequestedAtMs: number;
@@ -930,7 +941,7 @@ export function createTelegramThreadCleanupPermitRuntime(deps: {
930
941
  const fence = deps.ledger.read().fence;
931
942
  return fence?.operationId === operationId &&
932
943
  fence.destructiveKind === "manual-thread-cleanup" &&
933
- fence.profileKey === candidate.profileName &&
944
+ fence.profileKey === deps.ledger.getProfileKey() &&
934
945
  fence.retirementIntentId === workSetId &&
935
946
  fence.bindingKey === candidate.bindingKey &&
936
947
  fence.slot === candidate.slot &&
@@ -1045,8 +1056,17 @@ export function createTelegramThreadCleanupPermitRuntime(deps: {
1045
1056
  : { kind: "already-issued" };
1046
1057
  },
1047
1058
  async settleDeleted(fence, commit) {
1059
+ const assertAuthority = () => {
1060
+ const owner = deps.getOwner();
1061
+ if (deps.getLeaderEpoch() !== fence.leaderEpoch ||
1062
+ owner.processId !== fence.owner.processId || owner.processBirthId !== fence.owner.processBirthId)
1063
+ throw new Error("Telegram Thread cleanup settlement authority changed.");
1064
+ };
1065
+ assertAuthority();
1048
1066
  const ready = deps.ledger.confirmThreadCleanupAbsence(fence);
1049
- if (!(await commit())) return "commit-pending";
1067
+ const committed = await commit();
1068
+ assertAuthority();
1069
+ if (!committed) return "commit-pending";
1050
1070
  deps.ledger.completeThreadCleanupFence(ready);
1051
1071
  return "completed";
1052
1072
  },
@@ -1069,6 +1089,138 @@ export interface TelegramInactiveThreadCleanupCoordinatorDeps<TBinding> {
1069
1089
  ): Promise<boolean>;
1070
1090
  }
1071
1091
 
1092
+ export interface TelegramInactiveThreadCleanupAuthority {
1093
+ profileName: string;
1094
+ leaderEpoch: number | string;
1095
+ ledger: TelegramWorkspaceAdmissionLedger;
1096
+ isCurrent(): boolean;
1097
+ }
1098
+
1099
+ /** Review owns ordinary admission; confirmed cleanup uses the shared gate and its exclusive manual fence instead. */
1100
+ export function createTelegramInactiveThreadCleanupRuntime(deps: {
1101
+ store: Pick<TelegramTopicTargetStore, "load" | "listWorkspaceBindings" | "listReservations" | "listPendingProvisions" | "listPendingCleanups" | "commitInactiveWorkspaceCleanup">;
1102
+ captureAuthority(): TelegramInactiveThreadCleanupAuthority | undefined;
1103
+ getWorkStore(authority: TelegramInactiveThreadCleanupAuthority): TelegramThreadCleanupWorkStore;
1104
+ getProtection(binding: TelegramWorkspaceThreadBinding): {
1105
+ liveOwner: TelegramThreadCleanupProtectionState;
1106
+ acceptedWork: TelegramThreadCleanupProtectionState;
1107
+ deliveryAuthority: TelegramThreadCleanupProtectionState;
1108
+ };
1109
+ getProcessLiveness(owner: TelegramWorkspaceAdmissionOwner): TelegramProcessLiveness;
1110
+ runExclusive<T>(operation: () => Promise<T>): Promise<T>;
1111
+ runWorkspaceOperation: TelegramWorkspaceOperationRunner;
1112
+ deleteThread: TelegramWorkspaceThreadDeletionTransport;
1113
+ onError?: (error: unknown) => void;
1114
+ }): {
1115
+ review(): Promise<{ count: number; operationId?: string }>;
1116
+ clean(operationId: string): Promise<TelegramInactiveThreadCleanupResult>;
1117
+ } {
1118
+ const capture = () => {
1119
+ const authority = deps.captureAuthority();
1120
+ if (!authority || !authority.isCurrent())
1121
+ throw new Error("Inactive Thread cleanup requires current leader authority.");
1122
+ const ledgerKey = authority.ledger.getProfileKey();
1123
+ const owner = authority.ledger.getOwner();
1124
+ const assertCurrent = () => {
1125
+ if (!authority.isCurrent() || authority.ledger.getProfileKey() !== ledgerKey)
1126
+ throw new Error("Inactive Thread cleanup authority changed.");
1127
+ };
1128
+ const evidence = () => {
1129
+ assertCurrent();
1130
+ return captureTelegramInactiveThreadCleanupEvidence({
1131
+ profileName: authority.profileName,
1132
+ listBindings: deps.store.listWorkspaceBindings,
1133
+ getProtection: deps.getProtection,
1134
+ listReservations: deps.store.listReservations,
1135
+ listPendingProvisions: deps.store.listPendingProvisions,
1136
+ listPendingCleanups: deps.store.listPendingCleanups,
1137
+ });
1138
+ };
1139
+ return { authority, owner, assertCurrent, evidence };
1140
+ };
1141
+ return {
1142
+ async review() {
1143
+ const scope = capture();
1144
+ return createTelegramInactiveThreadCleanupReviewRuntime({
1145
+ getProfileName: () => scope.authority.profileName,
1146
+ listBindings: deps.store.listWorkspaceBindings,
1147
+ getProtection: deps.getProtection,
1148
+ listReservations: deps.store.listReservations,
1149
+ listPendingProvisions: deps.store.listPendingProvisions,
1150
+ listPendingCleanups: deps.store.listPendingCleanups,
1151
+ getWorkStore() { scope.assertCurrent(); return deps.getWorkStore(scope.authority); },
1152
+ runWorkspaceOperation(metadata, operation) {
1153
+ return deps.runWorkspaceOperation(metadata, async () => {
1154
+ scope.assertCurrent();
1155
+ await deps.store.load();
1156
+ scope.assertCurrent();
1157
+ return operation();
1158
+ });
1159
+ },
1160
+ }).review();
1161
+ },
1162
+ async clean(operationId) {
1163
+ const scope = capture();
1164
+ return deps.runExclusive(async () => {
1165
+ scope.assertCurrent();
1166
+ const { authority } = scope;
1167
+ const work = deps.getWorkStore(authority);
1168
+ const exactBinding = (candidate: TelegramThreadCleanupCandidate) => {
1169
+ const fresh = planTelegramInactiveThreadCleanup(scope.evidence()).find(binding => binding.bindingKey === candidate.bindingKey);
1170
+ if (!fresh || !sameCandidates([fresh], [candidate])) return undefined;
1171
+ return deps.store.listWorkspaceBindings().find(binding => binding.bindingKey === fresh.bindingKey);
1172
+ };
1173
+ const loadExactBinding = async (candidate: TelegramThreadCleanupCandidate) => {
1174
+ scope.assertCurrent();
1175
+ await deps.store.load();
1176
+ scope.assertCurrent();
1177
+ return exactBinding(candidate);
1178
+ };
1179
+ const permitRuntime = createTelegramThreadCleanupPermitRuntime({
1180
+ ledger: authority.ledger,
1181
+ getLeaderEpoch: () => authority.isCurrent() ? authority.leaderEpoch : undefined,
1182
+ getProfileName: () => authority.profileName,
1183
+ getOwner: () => scope.owner,
1184
+ canAdoptFence: fence => authority.isCurrent() && deps.getProcessLiveness(fence.owner) === "dead",
1185
+ revalidateUnderFence: async candidate => !!(await loadExactBinding(candidate)),
1186
+ });
1187
+ return cleanReviewedInactiveThreads({
1188
+ store: work, operationId, permitRuntime,
1189
+ resolveFullBinding: loadExactBinding,
1190
+ async deleteWithPermit(permit, candidate) {
1191
+ try {
1192
+ await deps.deleteThread(() => {
1193
+ scope.assertCurrent();
1194
+ const fence = authority.ledger.read().fence;
1195
+ if (!fence || !isTelegramWorkspaceRetirementFence(fence) ||
1196
+ fence.destructiveKind !== "manual-thread-cleanup" || fence.phase !== "deletion-issued" ||
1197
+ fence.operationId !== permit.operationId || fence.retirementIntentId !== operationId ||
1198
+ fence.profileKey !== permit.profileKey || fence.bindingKey !== candidate.bindingKey ||
1199
+ fence.slot !== candidate.slot || fence.deletionIssuedAtMs !== permit.issuedAtMs ||
1200
+ fence.leaderEpoch !== authority.leaderEpoch || fence.owner.processId !== scope.owner.processId ||
1201
+ fence.owner.processBirthId !== scope.owner.processBirthId ||
1202
+ targetKey(fence.target) !== targetKey(permit.target) || !exactBinding(candidate))
1203
+ throw new Error("Inactive Thread cleanup deletion permit is stale.");
1204
+ return { ...permit.target };
1205
+ });
1206
+ scope.assertCurrent();
1207
+ } catch (error) {
1208
+ deps.onError?.(error);
1209
+ throw error;
1210
+ }
1211
+ },
1212
+ async commitBinding(candidate) {
1213
+ scope.assertCurrent();
1214
+ await deps.store.load();
1215
+ scope.assertCurrent();
1216
+ return deps.store.commitInactiveWorkspaceCleanup(candidate, authority.isCurrent);
1217
+ },
1218
+ });
1219
+ });
1220
+ },
1221
+ };
1222
+ }
1223
+
1072
1224
  export function createTelegramInactiveThreadCleanupSettingsPort<TBinding>(
1073
1225
  deps: TelegramInactiveThreadCleanupCoordinatorDeps<TBinding>,
1074
1226
  ): (operationId: string) => Promise<TelegramInactiveThreadCleanupResult> {
@@ -1105,9 +1257,13 @@ export async function cleanReviewedInactiveThreads<TBinding>(
1105
1257
  entry,
1106
1258
  input.operationId,
1107
1259
  );
1108
- if (ready)
1109
- await input.permitRuntime.settleDeleted(ready, async () => true);
1110
- deleted += 1;
1260
+ if (ready) {
1261
+ const settlement = await input.permitRuntime.settleDeleted(ready, async () => true);
1262
+ if (settlement === "completed") deleted += 1;
1263
+ else outcomeUnknown += 1;
1264
+ } else if (input.permitRuntime.diagnoseRecovery(entry, input.operationId) !== "none") {
1265
+ outcomeUnknown += 1;
1266
+ } else deleted += 1;
1111
1267
  continue;
1112
1268
  }
1113
1269
  if (entry.state === "outcome-unknown") {
@@ -174,8 +174,12 @@ export function resolveTelegramWorkspaceDisplayNames(
174
174
  : undefined;
175
175
  if (manualName) {
176
176
  labels.set(binding.bindingKey, boundedLabel(manualName));
177
+ } else if (!slot) {
178
+ // A displaced binding retains its generated identity, not the letter now owned elsewhere.
179
+ const name = binding.threadName ? labelText(binding.threadName) : undefined;
180
+ if (name) labels.set(binding.bindingKey, boundedLabel(name));
177
181
  } else if (mode === "letters") {
178
- if (slot) labels.set(binding.bindingKey, slot);
182
+ labels.set(binding.bindingKey, slot);
179
183
  } else if (mode === "names") {
180
184
  const name = binding.threadName ? labelText(binding.threadName) : slot;
181
185
  if (name) labels.set(binding.bindingKey, boundedLabel(name));
@@ -191,12 +195,11 @@ export function resolveTelegramWorkspaceDisplayNames(
191
195
  bindings.filter((candidate) => candidate.cwd === binding.cwd)
192
196
  .length > 1
193
197
  : (liveDirectoryCounts.get(binding.cwd) ?? 0) > 1;
194
- if (showSuffix && !slot) continue;
195
198
  const suffix = !showSuffix
196
199
  ? ""
197
200
  : mode === "directory-title"
198
201
  ? ` ${slot}`
199
- : `_${slot!.toLowerCase()}`;
202
+ : `_${slot.toLowerCase()}`;
200
203
  labels.set(binding.bindingKey, boundedLabel(base, suffix));
201
204
  }
202
205
  }
@@ -378,9 +381,9 @@ export function createTelegramThreadDisplaySettingsRuntime(deps: {
378
381
  /** Caller owns triggering and reporting; no timer or background retry is created. */
379
382
  export function createTelegramThreadDisplayReconciler(
380
383
  deps: TelegramThreadDisplayReconcilerDeps,
381
- ): { reconcile(): Promise<{ changed: number }> } {
384
+ ): { reconcile(bindingKey?: string): Promise<{ changed: number }> } {
382
385
  let tail: Promise<unknown> = Promise.resolve();
383
- const apply = async (): Promise<{ changed: number }> => {
386
+ const apply = async (bindingKey?: string): Promise<{ changed: number }> => {
384
387
  const epoch = deps.getLeaderEpoch();
385
388
  const profile = deps.getProfileKey();
386
389
  const mode = deps.getMode();
@@ -406,6 +409,7 @@ export function createTelegramThreadDisplayReconciler(
406
409
  );
407
410
  let changed = 0;
408
411
  for (const binding of bindings) {
412
+ if (bindingKey !== undefined && binding.bindingKey !== bindingKey) continue;
409
413
  const isBindingCurrent = deps.captureBindingAuthority(binding);
410
414
  if (!isBindingCurrent) continue;
411
415
  const title = titles.get(binding.bindingKey);
@@ -426,7 +430,7 @@ export function createTelegramThreadDisplayReconciler(
426
430
  };
427
431
  assertBinding(binding);
428
432
  if ((binding.displayTitle ?? binding.threadName) === title) continue;
429
- await deps.callApi(
433
+ const acknowledged = await deps.callApi<boolean>(
430
434
  "editForumTopic",
431
435
  {
432
436
  chat_id: binding.target.chatId,
@@ -436,6 +440,8 @@ export function createTelegramThreadDisplayReconciler(
436
440
  { maxAttempts: 1 },
437
441
  );
438
442
  assertBinding(binding);
443
+ if (acknowledged !== true)
444
+ throw new Error("Telegram Thread display update was not acknowledged.");
439
445
  if (!deps.store.setWorkspaceDisplayTitle(binding, title)) {
440
446
  throw new Error(
441
447
  "Telegram Thread display binding changed before title commit.",
@@ -452,8 +458,8 @@ export function createTelegramThreadDisplayReconciler(
452
458
  return { changed };
453
459
  };
454
460
  return {
455
- reconcile() {
456
- const run = tail.then(apply);
461
+ reconcile(bindingKey) {
462
+ const run = tail.then(() => apply(bindingKey));
457
463
  tail = run.catch(() => undefined);
458
464
  return run;
459
465
  },
@@ -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 =
@@ -10657,12 +10657,13 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
10657
10657
  const typedUpdate = update as TUpdate;
10658
10658
  const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
10659
10659
  // 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;
10660
+ const projectTo = (destination: TelegramTarget & { threadId: number }): TUpdate => ({ ...prepared,
10661
+ ...(prepared.message ? { message: prepared.message.message_thread_id === destination.threadId ? { ...prepared.message }
10662
+ : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
10663
+ ...(prepared.edited_message ? { edited_message: prepared.edited_message.message_thread_id === destination.threadId ? { ...prepared.edited_message }
10664
+ : withoutTelegramTurnReplyAnchor({ ...prepared.edited_message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
10665
+ } as TUpdate);
10666
+ const routed = target ? projectTo(target) : prepared;
10666
10667
  try {
10667
10668
  return await executeUpdate(
10668
10669
  routed,
@@ -10673,6 +10674,8 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
10673
10674
  if (await deps.settleTerminalExecutionFailure?.(error)) {
10674
10675
  return { kind: "complete" };
10675
10676
  }
10677
+ // A refused reply proves only that send was not issued, not that the handler made no prior effects.
10678
+ // Leave failure settlement to the worker's retry policy; never re-enter the handler to recover delivery here.
10676
10679
  throw error;
10677
10680
  }
10678
10681
  },