@llblab/pi-kit 0.34.3 → 0.35.1

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 (50) hide show
  1. package/BACKLOG.md +2 -0
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +3 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -3
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +6 -1
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  7. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +12 -8
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +39 -23
  12. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
  13. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
  14. package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +6 -7
  15. package/node_modules/@llblab/pi-telegram/dist/lib/config.js +6 -8
  16. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +40 -45
  17. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
  18. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
  21. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
  22. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
  23. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
  26. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
  27. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +10 -5
  28. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +19 -2
  29. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +2 -0
  30. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +41 -14
  31. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  32. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
  33. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +5 -3
  34. package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
  35. package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -3
  36. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +14 -8
  37. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
  38. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +45 -24
  39. package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
  40. package/node_modules/@llblab/pi-telegram/lib/config.ts +11 -17
  41. package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -55
  42. package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
  43. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
  44. package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
  45. package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
  46. package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
  47. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +22 -5
  48. package/node_modules/@llblab/pi-telegram/lib/threads.ts +54 -18
  49. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  50. package/package.json +2 -2
@@ -1568,6 +1568,13 @@ export interface TelegramInboundRouteRuntimeDeps<
1568
1568
  requiredJournalBindingKeys: readonly string[],
1569
1569
  ownInputs?: readonly Threads.TelegramTemporaryThreadInput[],
1570
1570
  ) => readonly number[] | undefined;
1571
+ /** Explicit cleanup-only recovery; never called by a read-only protection observer. */
1572
+ recoverTemporaryThreadDeadQueueOwners?: (
1573
+ target: Queue.TelegramQueueTarget,
1574
+ requiredJournalBindingKeys: readonly string[],
1575
+ ownInputs: readonly Threads.TelegramTemporaryThreadInput[],
1576
+ isCurrent: () => boolean,
1577
+ ) => number;
1571
1578
  /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
1572
1579
  temporaryThreadCleanupDelayMs?: number;
1573
1580
  /** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
@@ -6837,6 +6844,93 @@ export function createTelegramInboundRouteRuntime<
6837
6844
  }
6838
6845
  await promptEnqueue(messages, ctx);
6839
6846
  };
6847
+ /** Local menu acceptance owns a one-shot effect; source removal and cleanup precede its release, never its replay. */
6848
+ const stageTemporaryRerouteMenu = async (
6849
+ pending: PendingUnboundReroute,
6850
+ command: Commands.ParsedTelegramCommand,
6851
+ messages: TMessage[],
6852
+ ctx: TContext,
6853
+ assertRecipientCurrent: () => void,
6854
+ completion: NonNullable<ReturnType<typeof Updates.prepareTelegramDeferredSourceCompletion>>,
6855
+ ): Promise<(() => Promise<void>) | undefined> => {
6856
+ const original = pending.messages[0];
6857
+ const cap = captureTemporaryThreadAuthority(ctx);
6858
+ const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
6859
+ if (!cap?.isCurrent() || !temporary) return undefined;
6860
+ let accept!: (value: boolean) => void, resume!: (value: boolean) => void;
6861
+ const accepted = new Promise<boolean>(resolve => { accept = resolve; });
6862
+ const released = new Promise<boolean>(resolve => { resume = resolve; });
6863
+ const admission: Commands.TelegramSelectedCommandAdmission = {
6864
+ assertSourceCurrent() {
6865
+ if (!completion.isCurrent()) throw new Error("Rerouted menu source authority changed.");
6866
+ },
6867
+ assertRecipientCurrent,
6868
+ reportCompleted() {
6869
+ const result = completion.reportCompleted();
6870
+ accept(result);
6871
+ return result;
6872
+ },
6873
+ async beforeEffects() {
6874
+ if (!(await released)) throw new Error("Rerouted menu execution was not released.");
6875
+ },
6876
+ };
6877
+ const prepare = command.name === "start" ? commandHandler.prepareSelectedStartCommand
6878
+ : command.name === "help" ? commandHandler.prepareSelectedHelpCommand : commandHandler.prepareSelectedMenuCommand;
6879
+ const dispatch = prepare(command, messages, ctx, admission);
6880
+ if (!dispatch) return undefined;
6881
+ const execution = dispatch().catch(error => {
6882
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-execution" });
6883
+ return false;
6884
+ });
6885
+ if (!(await Promise.race([accepted, execution.then(() => false)]))) {
6886
+ resume(false);
6887
+ await execution;
6888
+ return undefined;
6889
+ }
6890
+ return async () => {
6891
+ let allowEffects = false;
6892
+ try {
6893
+ const confirmed = await new Promise<boolean>(resolve => {
6894
+ let done = false, unsubscribe: (() => void) | undefined;
6895
+ const signal = Updates.getTelegramUpdateExecutionFence(original)?.signal;
6896
+ const finish = (result: boolean) => {
6897
+ if (done) return;
6898
+ done = true;
6899
+ clearTimeout(timer);
6900
+ unsubscribe?.();
6901
+ signal?.removeEventListener("abort", abort);
6902
+ resolve(result);
6903
+ };
6904
+ const abort = () => finish(false);
6905
+ const observe = () => {
6906
+ if (!cap.isCurrent() || !completion.isCurrent()) finish(false);
6907
+ else if (completion.inspectCompletion()) finish(true);
6908
+ };
6909
+ const timer = setTimeout(abort, deps.liveRebindCommandCompletionTimeoutMs ?? 8_000);
6910
+ timer.unref?.();
6911
+ unsubscribe = Updates.subscribeTelegramDeferredSourceCompletion(original, observe);
6912
+ signal?.addEventListener("abort", abort, { once: true });
6913
+ if (signal?.aborted) abort(); else observe();
6914
+ });
6915
+ if (!confirmed) throw new Error("Rerouted menu source removal is unconfirmed; execution remains suppressed.");
6916
+ assertRecipientCurrent();
6917
+ cancelTemporaryThreadCleanup(temporary.token);
6918
+ try {
6919
+ await runTemporaryThreadCleanup(temporary.token, cap, false);
6920
+ } catch (error) {
6921
+ // Cleanup uncertainty retains its canonical grant, not command execution authority or a retry license.
6922
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-cleanup" });
6923
+ }
6924
+ assertRecipientCurrent();
6925
+ allowEffects = true;
6926
+ } catch (error) {
6927
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-release" });
6928
+ } finally {
6929
+ resume(allowEffects);
6930
+ await execution;
6931
+ }
6932
+ };
6933
+ };
6840
6934
  const finalizePendingReroute = async (
6841
6935
  rerouteId: string,
6842
6936
  pending: PendingUnboundReroute,
@@ -7258,12 +7352,20 @@ export function createTelegramInboundRouteRuntime<
7258
7352
  });
7259
7353
  if (!cancelledCurrent()) return;
7260
7354
  entry = cap.adopt(entry);
7261
- if (
7262
- !entry ||
7263
- !cap.isCurrent() ||
7264
- isRerouteTargetProtected(target, undefined, entry)
7265
- )
7266
- return;
7355
+ if (!entry || !cap.isCurrent()) return;
7356
+ if (isRerouteTargetProtected(target, undefined, entry) && deps.recoverTemporaryThreadDeadQueueOwners &&
7357
+ store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary") {
7358
+ const inputs = Threads.getTelegramTemporaryThreadInputs(entry);
7359
+ const removed = deps.recoverTemporaryThreadDeadQueueOwners(
7360
+ target, [cap.journalBindingKey, ...inputs.map(input => input.journalBindingKey)],
7361
+ inputs, cap.isCurrent,
7362
+ );
7363
+ if (removed > 0) deps.recordRuntimeEvent?.("routing",
7364
+ "Discarded historical queue receipts from confirmed-dead owners before temporary Thread cleanup.",
7365
+ { phase: "temporary-thread-dead-owner-recovery", removedUpdateCount: removed });
7366
+ }
7367
+ // Recovery is not deletion authority: recapture every ordinary protection after the journal CAS.
7368
+ if (!cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry)) return;
7267
7369
  const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
7268
7370
  if (!issued || !cap.isCurrent()) return;
7269
7371
  entry = issued.entry;
@@ -8658,6 +8760,7 @@ export function createTelegramInboundRouteRuntime<
8658
8760
  const executeUnboundRerouteCallbackOperation = async (
8659
8761
  query: TCallbackQuery,
8660
8762
  ctx: TContext,
8763
+ afterAdmission: (release: () => Promise<void>) => void,
8661
8764
  ): Promise<boolean> => {
8662
8765
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
8663
8766
  if (!parsed) return false;
@@ -9349,7 +9452,20 @@ export function createTelegramInboundRouteRuntime<
9349
9452
  const localMessages = pending.rerouteCopies
9350
9453
  ? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
9351
9454
  : reroutedMessages;
9352
- await dispatchPendingRerouteMessages(pending, localMessages, ctx);
9455
+ const command = pending.dispatchKind === "command"
9456
+ ? getLiveRebindMenu(localMessages) ?? getLiveRebindHelp(localMessages) : undefined;
9457
+ const completion = pending.rerouteCopies && command
9458
+ ? Updates.prepareTelegramDeferredSourceCompletion(pending.messages[0]) : undefined;
9459
+ if (command && completion) {
9460
+ const release = await stageTemporaryRerouteMenu(pending, command, localMessages, ctx, assertRerouteCurrent, completion);
9461
+ if (!release) {
9462
+ await deps.answerCallbackQuery(query.id, "Command acceptance unconfirmed; not resending");
9463
+ return true;
9464
+ }
9465
+ afterAdmission(release);
9466
+ } else {
9467
+ await dispatchPendingRerouteMessages(pending, localMessages, ctx);
9468
+ }
9353
9469
  assertRerouteCurrent();
9354
9470
  pending.messages = [];
9355
9471
  return completeRoutedReroute();
@@ -9360,17 +9476,23 @@ export function createTelegramInboundRouteRuntime<
9360
9476
  ): Promise<boolean> => {
9361
9477
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
9362
9478
  if (!parsed) return false;
9363
- return withPendingRerouteSelection(parsed.rerouteId, query, () => {
9364
- if (!deps.runWorkspaceOperation)
9365
- return executeUnboundRerouteCallbackOperation(query, ctx);
9366
- return deps.runWorkspaceOperation(
9367
- {
9368
- operationId: `workspace-reroute:${query.id}`,
9369
- operationKind: "workspace.route-unbound-thread",
9370
- scopes: [{ kind: "profile" }],
9371
- },
9372
- () => executeUnboundRerouteCallbackOperation(query, ctx),
9373
- );
9479
+ return withPendingRerouteSelection(parsed.rerouteId, query, async () => {
9480
+ let release: (() => Promise<void>) | undefined;
9481
+ const operation = () => executeUnboundRerouteCallbackOperation(query, ctx, value => { release = value; });
9482
+ try {
9483
+ if (!deps.runWorkspaceOperation) return await operation();
9484
+ return await deps.runWorkspaceOperation(
9485
+ {
9486
+ operationId: `workspace-reroute:${query.id}`,
9487
+ operationKind: "workspace.route-unbound-thread",
9488
+ scopes: [{ kind: "profile" }],
9489
+ },
9490
+ operation,
9491
+ );
9492
+ } finally {
9493
+ // Keep the exact source references, but release the non-reentrant Workspace gate before cleanup.
9494
+ await release?.();
9495
+ }
9374
9496
  });
9375
9497
  };
9376
9498
  const handleUnboundRerouteCallback = async (
@@ -473,6 +473,8 @@ export interface TelegramAnswerGuestQueryOptions {
473
473
  }
474
474
 
475
475
  export interface TelegramEditGuestInlineMessageContent {
476
+ /** Cached media only: inline edits cannot upload a new local file. */
477
+ media?: { type: "photo" | "document" | "audio"; media: string; caption?: string };
476
478
  text?: string;
477
479
  richMessage?: TelegramInputRichMessage;
478
480
  parseMode?: "HTML";
@@ -917,10 +919,9 @@ export interface TelegramBridgeApiRuntime extends Pick<
917
919
  options?: TelegramAnswerGuestQueryOptions,
918
920
  ) => Promise<void>;
919
921
  /**
920
- * Temporary Guest Mode ACK experiment: answers the guest query and returns
921
- * the sent inline message id so the final answer can edit that early reply.
922
- * Requires direct transport ownership; Telegram does not document editing
923
- * guest answers, so this exists only to falsify that behavior live.
922
+ * Answers the guest query and retains the sent inline message id for final
923
+ * text or cached-media replacement. Requires direct transport ownership;
924
+ * actual guest-media rendering remains live-client gated.
924
925
  */
925
926
  answerGuestQueryForInlineMessage: (
926
927
  guestQueryId: string,
@@ -930,6 +931,7 @@ export interface TelegramBridgeApiRuntime extends Pick<
930
931
  editGuestInlineMessage: (
931
932
  inlineMessageId: string,
932
933
  content: TelegramEditGuestInlineMessageContent,
934
+ options?: Pick<TelegramApiCallOptions, "assertAuthority">,
933
935
  ) => Promise<void>;
934
936
  deleteMessage: (chatId: number, messageId: number) => Promise<void>;
935
937
  prepareTempDir: () => Promise<number>;
@@ -2404,14 +2406,29 @@ export function createTelegramBridgeApiRuntime(
2404
2406
  editGuestInlineMessage: async (
2405
2407
  inlineMessageId: string,
2406
2408
  content: TelegramEditGuestInlineMessageContent,
2409
+ options?: Pick<TelegramApiCallOptions, "assertAuthority">,
2407
2410
  ) => {
2411
+ if (content.media) {
2412
+ try {
2413
+ const result = await callRecorded("editMessageMedia", {
2414
+ inline_message_id: inlineMessageId,
2415
+ media: content.media,
2416
+ }, { ...options, maxAttempts: 1, retryRateLimit: false });
2417
+ if (result !== true) throw new Error("Missing inline edit acknowledgement.");
2418
+ } catch (error) {
2419
+ // Only a parsed rejection permits replacing this attempt with a failure notice.
2420
+ if (isTelegramApiRequestRejected(error, "editMessageMedia")) throw error;
2421
+ throw new TelegramApiCommitUnknownError("editMessageMedia", error);
2422
+ }
2423
+ return;
2424
+ }
2408
2425
  await callRecorded("editMessageText", {
2409
2426
  inline_message_id: inlineMessageId,
2410
2427
  ...(content.richMessage
2411
2428
  ? { rich_message: content.richMessage }
2412
2429
  : { text: content.text }),
2413
2430
  ...(content.parseMode ? { parse_mode: content.parseMode } : {}),
2414
- });
2431
+ }, options);
2415
2432
  },
2416
2433
  prepareTempDir: () =>
2417
2434
  prepareTelegramTempDir(deps.tempDir, deps.tempFileMaxAgeMs),
@@ -904,6 +904,8 @@ export interface TelegramTopicTargetStore {
904
904
  existingBindingOnly?: boolean;
905
905
  sessionId?: string;
906
906
  provisionProfileKey?: string;
907
+ /** Explicit same-process session handoff from `previousInstanceId`; never inferred. */
908
+ sessionHandoff?: boolean;
907
909
  onCapacityUnavailable?: () => void;
908
910
  },
909
911
  ) => TelegramWorkspaceBindingIdentity | undefined;
@@ -2721,10 +2723,16 @@ function liveRebindOldTargetUnknown(value: TelegramWorkspaceLiveRebindIntent): b
2721
2723
  function retainLiveRebind(value: TelegramWorkspaceLiveRebindIntent): boolean {
2722
2724
  return liveRebindActive(value) || liveRebindOldTargetUnknown(value) || value.oldTargetProtected === true;
2723
2725
  }
2724
- function protectedLiveRebindOldTargets(snapshot: TelegramWorkspaceRestoreSnapshot | undefined): TelegramTarget[] {
2726
+ function provisioningProtectionIntents(snapshot: TelegramWorkspaceRestoreSnapshot): readonly Pick<TelegramWorkspaceRestoreIntent, "request">[] {
2727
+ return [...snapshot.operations, ...(snapshot.liveRebindings ?? []).filter(retainLiveRebind)];
2728
+ }
2729
+ function protectedLiveRebindOldTargetIntents(snapshot: TelegramWorkspaceRestoreSnapshot | undefined): TelegramWorkspaceLiveRebindIntent[] {
2725
2730
  return (snapshot?.liveRebindings ?? []).filter(value =>
2726
2731
  value.oldTargetProtected === true || liveRebindOldTargetUnknown(value) || value.inputRelease !== undefined && value.cleanup === "issued",
2727
- ).map(value => ({ ...value.request.binding.target }));
2732
+ );
2733
+ }
2734
+ function protectedLiveRebindOldTargets(snapshot: TelegramWorkspaceRestoreSnapshot | undefined): TelegramTarget[] {
2735
+ return protectedLiveRebindOldTargetIntents(snapshot).map(value => ({ ...value.request.binding.target }));
2728
2736
  }
2729
2737
  function pendingWorkspaceRelocationRequests(
2730
2738
  snapshot: TelegramWorkspaceRestoreSnapshot | undefined,
@@ -2819,10 +2827,11 @@ function assertWorkspaceRestoreBindingProtection(
2819
2827
  throw new Error("Protected Workspace Restore owner target changed.");
2820
2828
  }
2821
2829
  }
2822
- for (const old of protectedLiveRebindOldTargets(file.workspaceRestore)) {
2830
+ for (const { request } of protectedLiveRebindOldTargetIntents(file.workspaceRestore)) {
2831
+ const old = request.binding.target;
2823
2832
  if (file.workspaceBindings?.some(value => targetMatches(value.target, old)) ||
2824
2833
  file.threads?.some(value => targetMatches(value.target, old) && ["active", "starting", "pending", "probe-required"].includes(value.status)) ||
2825
- file.pendingProvisions?.some(value => !value.target || targetMatches(value.target, old)) ||
2834
+ file.pendingProvisions?.some(value => conflictsWithWorkspaceRestoreProvision(request, value)) ||
2826
2835
  file.reservations?.some(value => targetMatches(value.target, old)))
2827
2836
  throw new Error("Unknown live cleanup target remains protected.");
2828
2837
  }
@@ -3838,7 +3847,7 @@ export function createTelegramTopicTargetStore(
3838
3847
  };
3839
3848
  const assertRestoreRecoveryProtection = (
3840
3849
  path: string,
3841
- operations: readonly TelegramWorkspaceRestoreIntent[],
3850
+ operations: readonly Pick<TelegramWorkspaceRestoreIntent, "request">[],
3842
3851
  provisions: readonly TelegramThreadPendingProvision[],
3843
3852
  requireKnownTargets = false,
3844
3853
  recoveryEvidence?: TelegramProvisionRecoveryFile,
@@ -5059,7 +5068,7 @@ export function createTelegramTopicTargetStore(
5059
5068
  assertWorkspaceRestoreBindingProtection(file);
5060
5069
  assertRestoreRecoveryProtection(
5061
5070
  path,
5062
- file.workspaceRestore!.operations,
5071
+ provisioningProtectionIntents(file.workspaceRestore!),
5063
5072
  file.pendingProvisions ?? [],
5064
5073
  );
5065
5074
  observe({
@@ -5115,7 +5124,7 @@ export function createTelegramTopicTargetStore(
5115
5124
  assertWorkspaceRestoreBindingProtection(file);
5116
5125
  assertRestoreRecoveryProtection(
5117
5126
  path,
5118
- file.workspaceRestore!.operations,
5127
+ provisioningProtectionIntents(file.workspaceRestore!),
5119
5128
  file.pendingProvisions ?? [],
5120
5129
  );
5121
5130
  const canonical = file.workspaceBindings ?? [],
@@ -5173,9 +5182,9 @@ export function createTelegramTopicTargetStore(
5173
5182
  (row) => currentRow(row) && targetMatches(row.target, old),
5174
5183
  ) ||
5175
5184
  reserved.some((value) => targetMatches(value.target, old)) ||
5176
- provisions.some(
5177
- (value) => !value.target || targetMatches(value.target, old),
5178
- ) ||
5185
+ // Use the same binding/slot/owner/target conflicts as rebind admission. An unrelated unlocated
5186
+ // create must not globally veto cleanup of this exact former binding (nor its issued marker).
5187
+ provisions.some((value) => conflictsWithWorkspaceRestoreProvision(request, value)) ||
5179
5188
  cleanups.some((value) => targetMatches(value.target, old));
5180
5189
  if (
5181
5190
  protectedTarget(
@@ -5388,8 +5397,11 @@ export function createTelegramTopicTargetStore(
5388
5397
  file.reservations?.some((value) =>
5389
5398
  targetMatches(value.target, target),
5390
5399
  ) ||
5400
+ // This exact created frame already proves a separately created routing tab (API ACK or authenticated
5401
+ // implicit creation). An unlocated Workspace create is not its owner; keep that intent unresolved,
5402
+ // but veto a concrete target collision. Generic unknown-target/Restore cleanup keeps its own guards.
5391
5403
  file.pendingProvisions?.some(
5392
- (value) => !value.target || targetMatches(value.target, target),
5404
+ (value) => !!value.target && targetMatches(value.target, target),
5393
5405
  ) ||
5394
5406
  file.pendingCleanups?.some((value) =>
5395
5407
  targetMatches(value.target, target),
@@ -5555,7 +5567,7 @@ export function createTelegramTopicTargetStore(
5555
5567
  parseTopicTargetFile(before.file).pendingProvisions ?? [];
5556
5568
  assertRestoreRecoveryProtection(
5557
5569
  path,
5558
- before.state.operations,
5570
+ provisioningProtectionIntents(before.state),
5559
5571
  provisions,
5560
5572
  );
5561
5573
  const after = { ...before.file, workspaceRestore: next };
@@ -5571,7 +5583,7 @@ export function createTelegramTopicTargetStore(
5571
5583
  return false;
5572
5584
  assertRestoreRecoveryProtection(
5573
5585
  path,
5574
- [...before.state.operations, ...next.operations],
5586
+ [...provisioningProtectionIntents(before.state), ...provisioningProtectionIntents(next)],
5575
5587
  provisions,
5576
5588
  );
5577
5589
  committedBefore = before;
@@ -5596,7 +5608,7 @@ export function createTelegramTopicTargetStore(
5596
5608
  );
5597
5609
  assertRestoreRecoveryProtection(
5598
5610
  path,
5599
- [...before.state.operations, ...next.operations],
5611
+ [...provisioningProtectionIntents(before.state), ...provisioningProtectionIntents(next)],
5600
5612
  provisions,
5601
5613
  );
5602
5614
  if (!currentScope() || !isCurrent()) return;
@@ -6773,7 +6785,7 @@ export function createTelegramTopicTargetStore(
6773
6785
  conflictsWithWorkspaceRestoreProvision(request, provision),
6774
6786
  ) ||
6775
6787
  conflictsWithTemporaryThread(workspaceRestore, provision.target) ||
6776
- protectedLiveRebindOldTargets(workspaceRestore).some(old => !provision.target || targetMatches(old, provision.target))
6788
+ protectedLiveRebindOldTargetIntents(workspaceRestore).some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, provision))
6777
6789
  ) {
6778
6790
  throw new Error("Protected Workspace Restore provisioning conflict.");
6779
6791
  }
@@ -7634,11 +7646,35 @@ export function createTelegramTopicTargetStore(
7634
7646
  (options?.provisionProfileKey !== undefined && value.profileKey === options.provisionProfileKey)));
7635
7647
  if (ownedProvisions.length > 1) return undefined;
7636
7648
  const ownProvision = ownedProvisions[0];
7637
- const retainedTarget = ownProvision?.target ?? binding?.target ?? legacyRecord?.target;
7638
- const retainedSlot = ownProvision?.slot ?? binding?.slot ?? legacyRecord?.slot;
7649
+ // A same-cwd session handoff keeps its predecessor's Thread slot; the Workspace commit re-keys that binding.
7650
+ const handoffBindings =
7651
+ binding ||
7652
+ ownProvision ||
7653
+ !options?.sessionHandoff ||
7654
+ !previousInstanceId ||
7655
+ !isSameTelegramProcessInstance(previousInstanceId, instanceId)
7656
+ ? []
7657
+ : Array.from(workspaceBindings.values()).filter(
7658
+ (candidate) =>
7659
+ candidate.cwd === identity.cwd &&
7660
+ candidate.sessionId !== identity.sessionId &&
7661
+ Array.from(records.values()).some(
7662
+ (record) =>
7663
+ record.instanceId === previousInstanceId &&
7664
+ record.status === "active" &&
7665
+ targetMatches(record.target, candidate.target),
7666
+ ),
7667
+ );
7668
+ const handoffBinding =
7669
+ handoffBindings.length === 1 ? handoffBindings[0] : undefined;
7670
+ const retainedTarget = ownProvision?.target ?? binding?.target ?? handoffBinding?.target ?? legacyRecord?.target;
7671
+ const retainedSlot = ownProvision?.slot ?? binding?.slot ?? handoffBinding?.slot ?? legacyRecord?.slot;
7639
7672
  const otherBindings = Array.from(workspaceBindings.values())
7640
7673
  .filter(
7641
- (other) => other.bindingKey !== identity.bindingKey && other.slot,
7674
+ (other) =>
7675
+ other.bindingKey !== identity.bindingKey &&
7676
+ other.bindingKey !== handoffBinding?.bindingKey &&
7677
+ other.slot,
7642
7678
  )
7643
7679
  .map((other) => ({
7644
7680
  bindingKey: other.bindingKey,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.56.4",
3
+ "version": "0.57.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.34.3",
3
+ "version": "0.35.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -47,7 +47,7 @@
47
47
  "@llblab/pi-codex-usage": "0.13.1",
48
48
  "@llblab/pi-grow-loop": "0.9.0",
49
49
  "@llblab/pi-state-flow": "0.26.4",
50
- "@llblab/pi-telegram": "0.56.4",
50
+ "@llblab/pi-telegram": "0.57.1",
51
51
  "@llblab/skills": "1.15.0"
52
52
  },
53
53
  "bundledDependencies": [