@llblab/pi-kit 0.35.0 → 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 (42) hide show
  1. package/BACKLOG.md +1 -0
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +1 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +6 -1
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +6 -0
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +11 -3
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
  10. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
  11. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
  12. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +40 -45
  13. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
  14. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
  15. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
  17. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
  18. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
  19. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
  20. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
  21. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
  23. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +10 -5
  24. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +19 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +23 -11
  26. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  27. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
  28. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +3 -1
  29. package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
  30. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +13 -2
  31. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
  32. package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
  33. package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -55
  34. package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
  35. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
  36. package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
  37. package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
  38. package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
  39. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +22 -5
  40. package/node_modules/@llblab/pi-telegram/lib/threads.ts +25 -15
  41. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  42. package/package.json +2 -2
@@ -1377,13 +1377,55 @@ export async function handleTelegramAgentEndRuntime(deps) {
1377
1377
  });
1378
1378
  }
1379
1379
  }
1380
- if (turn.guestInlineMessageId && deps.editGuestReply) {
1381
- const experimentText = assistant.errorMessage
1380
+ if (!isDeliveryActive())
1381
+ return;
1382
+ if (turn.guestInlineMessageId) {
1383
+ let experimentText = assistant.errorMessage
1382
1384
  ? "Telegram bridge: Pi failed while processing the request."
1383
1385
  : finalText;
1386
+ const [attachment] = turn.queuedAttachments;
1387
+ if (!assistant.errorMessage && attachment) {
1388
+ try {
1389
+ if (!deps.sendGuestAttachment)
1390
+ throw new Error("Guest attachment delivery is unavailable.");
1391
+ await deps.sendGuestAttachment(turn, attachment, finalText || undefined, { isDeliveryActive });
1392
+ if (isDeliveryActive() && endPlan.shouldDispatchNext)
1393
+ deps.dispatchNextQueuedTelegramTurn();
1394
+ return;
1395
+ }
1396
+ catch (error) {
1397
+ deps.recordRuntimeEvent?.("delivery", error, {
1398
+ phase: "guest-attachment", guestQueryId: turn.guestQueryId,
1399
+ });
1400
+ if (!isDeliveryActive())
1401
+ return;
1402
+ if (isTelegramApiCommitUnknownError(error)) {
1403
+ if (endPlan.shouldDispatchNext)
1404
+ deps.dispatchNextQueuedTelegramTurn();
1405
+ return;
1406
+ }
1407
+ experimentText = "Telegram bridge could not deliver the requested attachment. Please retry in a normal bot chat.";
1408
+ }
1409
+ }
1410
+ else if (!assistant.errorMessage && hasOutboundArtifacts) {
1411
+ experimentText = [finalText, "Telegram Guest Mode cannot replace an inline placeholder with a voice note. Please request MP3 audio or use a normal bot chat."].filter(Boolean).join("\n\n");
1412
+ }
1413
+ if (!deps.editGuestReply) {
1414
+ deps.recordRuntimeEvent?.("delivery", new Error("Guest inline reply editing is unavailable."), {
1415
+ phase: "guest-ack-edit", guestQueryId: turn.guestQueryId,
1416
+ });
1417
+ if (endPlan.shouldDispatchNext)
1418
+ deps.dispatchNextQueuedTelegramTurn();
1419
+ return;
1420
+ }
1384
1421
  if (experimentText) {
1385
1422
  try {
1386
- await deps.editGuestReply(turn.guestInlineMessageId, experimentText);
1423
+ await deps.editGuestReply(turn.guestInlineMessageId, experimentText, {
1424
+ assertAuthority() {
1425
+ if (!isDeliveryActive())
1426
+ throw new Error("Guest reply edit lost authority.");
1427
+ },
1428
+ });
1387
1429
  deps.recordRuntimeEvent?.("guest", new Error("Guest ACK edited the guest answer"), { phase: "guest-ack-edited", guestQueryId: turn.guestQueryId });
1388
1430
  }
1389
1431
  catch (error) {
@@ -1419,7 +1461,7 @@ export async function handleTelegramAgentEndRuntime(deps) {
1419
1461
  const [guestAttachment] = turn.queuedAttachments;
1420
1462
  if (guestAttachment && deps.sendGuestAttachment) {
1421
1463
  try {
1422
- await deps.sendGuestAttachment(turn, guestAttachment, finalText || undefined);
1464
+ await deps.sendGuestAttachment(turn, guestAttachment, finalText || undefined, { isDeliveryActive });
1423
1465
  }
1424
1466
  catch (error) {
1425
1467
  deps.recordRuntimeEvent?.("delivery", error, {
@@ -163,8 +163,12 @@ export declare function createGuestMarkdownReplyEditor(deps: {
163
163
  editGuestInlineMessage: (inlineMessageId: string, content: {
164
164
  richMessage?: TelegramInputRichMessage;
165
165
  text?: string;
166
+ }, options?: {
167
+ assertAuthority?: () => void;
166
168
  }) => Promise<void>;
167
- }): (inlineMessageId: string, markdown: string) => Promise<void>;
169
+ }): (inlineMessageId: string, markdown: string, options?: {
170
+ assertAuthority?: () => void;
171
+ }) => Promise<void>;
168
172
  /**
169
173
  * Guest Mode placeholder rotation: the early ACK is the first placeholder
170
174
  * frame and the runtime steps the remaining frames once per interval, moving
@@ -578,11 +578,11 @@ export function createGuestMarkdownReplySender(deps) {
578
578
  * chat/message pair.
579
579
  */
580
580
  export function createGuestMarkdownReplyEditor(deps) {
581
- return async (inlineMessageId, markdown) => {
581
+ return async (inlineMessageId, markdown, options) => {
582
582
  const [richMarkdown = markdown] = splitTelegramNativeMarkdown(markdown);
583
583
  await deps.editGuestInlineMessage(inlineMessageId, {
584
584
  richMessage: { markdown: richMarkdown },
585
- });
585
+ }, options);
586
586
  };
587
587
  }
588
588
  /**
@@ -211,6 +211,8 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
211
211
  canUsePreDispatchRebind?: (recipient?: Threads.TelegramWorkspaceRestoreRecipient) => boolean;
212
212
  /** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
213
213
  inspectTemporaryThreadSources?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[], ownInputs?: readonly Threads.TelegramTemporaryThreadInput[]) => readonly number[] | undefined;
214
+ /** Explicit cleanup-only recovery; never called by a read-only protection observer. */
215
+ recoverTemporaryThreadDeadQueueOwners?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[], ownInputs: readonly Threads.TelegramTemporaryThreadInput[], isCurrent: () => boolean) => number;
214
216
  /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
215
217
  temporaryThreadCleanupDelayMs?: number;
216
218
  /** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
@@ -4574,6 +4574,100 @@ export function createTelegramInboundRouteRuntime(deps) {
4574
4574
  }
4575
4575
  await promptEnqueue(messages, ctx);
4576
4576
  };
4577
+ /** Local menu acceptance owns a one-shot effect; source removal and cleanup precede its release, never its replay. */
4578
+ const stageTemporaryRerouteMenu = async (pending, command, messages, ctx, assertRecipientCurrent, completion) => {
4579
+ const original = pending.messages[0];
4580
+ const cap = captureTemporaryThreadAuthority(ctx);
4581
+ const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
4582
+ if (!cap?.isCurrent() || !temporary)
4583
+ return undefined;
4584
+ let accept, resume;
4585
+ const accepted = new Promise(resolve => { accept = resolve; });
4586
+ const released = new Promise(resolve => { resume = resolve; });
4587
+ const admission = {
4588
+ assertSourceCurrent() {
4589
+ if (!completion.isCurrent())
4590
+ throw new Error("Rerouted menu source authority changed.");
4591
+ },
4592
+ assertRecipientCurrent,
4593
+ reportCompleted() {
4594
+ const result = completion.reportCompleted();
4595
+ accept(result);
4596
+ return result;
4597
+ },
4598
+ async beforeEffects() {
4599
+ if (!(await released))
4600
+ throw new Error("Rerouted menu execution was not released.");
4601
+ },
4602
+ };
4603
+ const prepare = command.name === "start" ? commandHandler.prepareSelectedStartCommand
4604
+ : command.name === "help" ? commandHandler.prepareSelectedHelpCommand : commandHandler.prepareSelectedMenuCommand;
4605
+ const dispatch = prepare(command, messages, ctx, admission);
4606
+ if (!dispatch)
4607
+ return undefined;
4608
+ const execution = dispatch().catch(error => {
4609
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-execution" });
4610
+ return false;
4611
+ });
4612
+ if (!(await Promise.race([accepted, execution.then(() => false)]))) {
4613
+ resume(false);
4614
+ await execution;
4615
+ return undefined;
4616
+ }
4617
+ return async () => {
4618
+ let allowEffects = false;
4619
+ try {
4620
+ const confirmed = await new Promise(resolve => {
4621
+ let done = false, unsubscribe;
4622
+ const signal = Updates.getTelegramUpdateExecutionFence(original)?.signal;
4623
+ const finish = (result) => {
4624
+ if (done)
4625
+ return;
4626
+ done = true;
4627
+ clearTimeout(timer);
4628
+ unsubscribe?.();
4629
+ signal?.removeEventListener("abort", abort);
4630
+ resolve(result);
4631
+ };
4632
+ const abort = () => finish(false);
4633
+ const observe = () => {
4634
+ if (!cap.isCurrent() || !completion.isCurrent())
4635
+ finish(false);
4636
+ else if (completion.inspectCompletion())
4637
+ finish(true);
4638
+ };
4639
+ const timer = setTimeout(abort, deps.liveRebindCommandCompletionTimeoutMs ?? 8_000);
4640
+ timer.unref?.();
4641
+ unsubscribe = Updates.subscribeTelegramDeferredSourceCompletion(original, observe);
4642
+ signal?.addEventListener("abort", abort, { once: true });
4643
+ if (signal?.aborted)
4644
+ abort();
4645
+ else
4646
+ observe();
4647
+ });
4648
+ if (!confirmed)
4649
+ throw new Error("Rerouted menu source removal is unconfirmed; execution remains suppressed.");
4650
+ assertRecipientCurrent();
4651
+ cancelTemporaryThreadCleanup(temporary.token);
4652
+ try {
4653
+ await runTemporaryThreadCleanup(temporary.token, cap, false);
4654
+ }
4655
+ catch (error) {
4656
+ // Cleanup uncertainty retains its canonical grant, not command execution authority or a retry license.
4657
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-cleanup" });
4658
+ }
4659
+ assertRecipientCurrent();
4660
+ allowEffects = true;
4661
+ }
4662
+ catch (error) {
4663
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-release" });
4664
+ }
4665
+ finally {
4666
+ resume(allowEffects);
4667
+ await execution;
4668
+ }
4669
+ };
4670
+ };
4577
4671
  const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query), retainRecord = false) => {
4578
4672
  assertExecutionCurrent();
4579
4673
  const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
@@ -4888,9 +4982,17 @@ export function createTelegramInboundRouteRuntime(deps) {
4888
4982
  if (!cancelledCurrent())
4889
4983
  return;
4890
4984
  entry = cap.adopt(entry);
4891
- if (!entry ||
4892
- !cap.isCurrent() ||
4893
- isRerouteTargetProtected(target, undefined, entry))
4985
+ if (!entry || !cap.isCurrent())
4986
+ return;
4987
+ if (isRerouteTargetProtected(target, undefined, entry) && deps.recoverTemporaryThreadDeadQueueOwners &&
4988
+ store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary") {
4989
+ const inputs = Threads.getTelegramTemporaryThreadInputs(entry);
4990
+ const removed = deps.recoverTemporaryThreadDeadQueueOwners(target, [cap.journalBindingKey, ...inputs.map(input => input.journalBindingKey)], inputs, cap.isCurrent);
4991
+ if (removed > 0)
4992
+ deps.recordRuntimeEvent?.("routing", "Discarded historical queue receipts from confirmed-dead owners before temporary Thread cleanup.", { phase: "temporary-thread-dead-owner-recovery", removedUpdateCount: removed });
4993
+ }
4994
+ // Recovery is not deletion authority: recapture every ordinary protection after the journal CAS.
4995
+ if (!cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry))
4894
4996
  return;
4895
4997
  const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
4896
4998
  if (!issued || !cap.isCurrent())
@@ -5911,7 +6013,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5911
6013
  await deps.answerCallbackQuery(query.id);
5912
6014
  return true;
5913
6015
  };
5914
- const executeUnboundRerouteCallbackOperation = async (query, ctx) => {
6016
+ const executeUnboundRerouteCallbackOperation = async (query, ctx, afterAdmission) => {
5915
6017
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
5916
6018
  if (!parsed)
5917
6019
  return false;
@@ -6432,7 +6534,21 @@ export function createTelegramInboundRouteRuntime(deps) {
6432
6534
  const localMessages = pending.rerouteCopies
6433
6535
  ? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
6434
6536
  : reroutedMessages;
6435
- await dispatchPendingRerouteMessages(pending, localMessages, ctx);
6537
+ const command = pending.dispatchKind === "command"
6538
+ ? getLiveRebindMenu(localMessages) ?? getLiveRebindHelp(localMessages) : undefined;
6539
+ const completion = pending.rerouteCopies && command
6540
+ ? Updates.prepareTelegramDeferredSourceCompletion(pending.messages[0]) : undefined;
6541
+ if (command && completion) {
6542
+ const release = await stageTemporaryRerouteMenu(pending, command, localMessages, ctx, assertRerouteCurrent, completion);
6543
+ if (!release) {
6544
+ await deps.answerCallbackQuery(query.id, "Command acceptance unconfirmed; not resending");
6545
+ return true;
6546
+ }
6547
+ afterAdmission(release);
6548
+ }
6549
+ else {
6550
+ await dispatchPendingRerouteMessages(pending, localMessages, ctx);
6551
+ }
6436
6552
  assertRerouteCurrent();
6437
6553
  pending.messages = [];
6438
6554
  return completeRoutedReroute();
@@ -6441,14 +6557,22 @@ export function createTelegramInboundRouteRuntime(deps) {
6441
6557
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
6442
6558
  if (!parsed)
6443
6559
  return false;
6444
- return withPendingRerouteSelection(parsed.rerouteId, query, () => {
6445
- if (!deps.runWorkspaceOperation)
6446
- return executeUnboundRerouteCallbackOperation(query, ctx);
6447
- return deps.runWorkspaceOperation({
6448
- operationId: `workspace-reroute:${query.id}`,
6449
- operationKind: "workspace.route-unbound-thread",
6450
- scopes: [{ kind: "profile" }],
6451
- }, () => executeUnboundRerouteCallbackOperation(query, ctx));
6560
+ return withPendingRerouteSelection(parsed.rerouteId, query, async () => {
6561
+ let release;
6562
+ const operation = () => executeUnboundRerouteCallbackOperation(query, ctx, value => { release = value; });
6563
+ try {
6564
+ if (!deps.runWorkspaceOperation)
6565
+ return await operation();
6566
+ return await deps.runWorkspaceOperation({
6567
+ operationId: `workspace-reroute:${query.id}`,
6568
+ operationKind: "workspace.route-unbound-thread",
6569
+ scopes: [{ kind: "profile" }],
6570
+ }, operation);
6571
+ }
6572
+ finally {
6573
+ // Keep the exact source references, but release the non-reentrant Workspace gate before cleanup.
6574
+ await release?.();
6575
+ }
6452
6576
  });
6453
6577
  };
6454
6578
  const handleUnboundRerouteCallback = async (query, ctx) => {
@@ -343,6 +343,12 @@ export interface TelegramAnswerGuestQueryOptions {
343
343
  result?: TelegramGuestCachedMediaResult;
344
344
  }
345
345
  export interface TelegramEditGuestInlineMessageContent {
346
+ /** Cached media only: inline edits cannot upload a new local file. */
347
+ media?: {
348
+ type: "photo" | "document" | "audio";
349
+ media: string;
350
+ caption?: string;
351
+ };
346
352
  text?: string;
347
353
  richMessage?: TelegramInputRichMessage;
348
354
  parseMode?: "HTML";
@@ -465,13 +471,12 @@ export interface TelegramBridgeApiRuntime extends Pick<TelegramApiClient, "call"
465
471
  answerCallbackQuery: (callbackQueryId: string, text?: string, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
466
472
  answerGuestQuery: (guestQueryId: string, text?: string, options?: TelegramAnswerGuestQueryOptions) => Promise<void>;
467
473
  /**
468
- * Temporary Guest Mode ACK experiment: answers the guest query and returns
469
- * the sent inline message id so the final answer can edit that early reply.
470
- * Requires direct transport ownership; Telegram does not document editing
471
- * guest answers, so this exists only to falsify that behavior live.
474
+ * Answers the guest query and retains the sent inline message id for final
475
+ * text or cached-media replacement. Requires direct transport ownership;
476
+ * actual guest-media rendering remains live-client gated.
472
477
  */
473
478
  answerGuestQueryForInlineMessage: (guestQueryId: string, text?: string, options?: TelegramAnswerGuestQueryOptions) => Promise<string | undefined>;
474
- editGuestInlineMessage: (inlineMessageId: string, content: TelegramEditGuestInlineMessageContent) => Promise<void>;
479
+ editGuestInlineMessage: (inlineMessageId: string, content: TelegramEditGuestInlineMessageContent, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
475
480
  deleteMessage: (chatId: number, messageId: number) => Promise<void>;
476
481
  prepareTempDir: () => Promise<number>;
477
482
  }
@@ -1267,14 +1267,31 @@ export function createTelegramBridgeApiRuntime(deps) {
1267
1267
  const sent = await callRecorded("answerGuestQuery", buildTelegramAnswerGuestQueryBody(guestQueryId, text, options));
1268
1268
  return sent?.inline_message_id;
1269
1269
  },
1270
- editGuestInlineMessage: async (inlineMessageId, content) => {
1270
+ editGuestInlineMessage: async (inlineMessageId, content, options) => {
1271
+ if (content.media) {
1272
+ try {
1273
+ const result = await callRecorded("editMessageMedia", {
1274
+ inline_message_id: inlineMessageId,
1275
+ media: content.media,
1276
+ }, { ...options, maxAttempts: 1, retryRateLimit: false });
1277
+ if (result !== true)
1278
+ throw new Error("Missing inline edit acknowledgement.");
1279
+ }
1280
+ catch (error) {
1281
+ // Only a parsed rejection permits replacing this attempt with a failure notice.
1282
+ if (isTelegramApiRequestRejected(error, "editMessageMedia"))
1283
+ throw error;
1284
+ throw new TelegramApiCommitUnknownError("editMessageMedia", error);
1285
+ }
1286
+ return;
1287
+ }
1271
1288
  await callRecorded("editMessageText", {
1272
1289
  inline_message_id: inlineMessageId,
1273
1290
  ...(content.richMessage
1274
1291
  ? { rich_message: content.richMessage }
1275
1292
  : { text: content.text }),
1276
1293
  ...(content.parseMode ? { parse_mode: content.parseMode } : {}),
1277
- });
1294
+ }, options);
1278
1295
  },
1279
1296
  prepareTempDir: () => prepareTelegramTempDir(deps.tempDir, deps.tempFileMaxAgeMs),
1280
1297
  deleteMessage: (chatId, messageId) => callRecorded("deleteMessage", {
@@ -1307,8 +1307,14 @@ function liveRebindOldTargetUnknown(value) {
1307
1307
  function retainLiveRebind(value) {
1308
1308
  return liveRebindActive(value) || liveRebindOldTargetUnknown(value) || value.oldTargetProtected === true;
1309
1309
  }
1310
+ function provisioningProtectionIntents(snapshot) {
1311
+ return [...snapshot.operations, ...(snapshot.liveRebindings ?? []).filter(retainLiveRebind)];
1312
+ }
1313
+ function protectedLiveRebindOldTargetIntents(snapshot) {
1314
+ return (snapshot?.liveRebindings ?? []).filter(value => value.oldTargetProtected === true || liveRebindOldTargetUnknown(value) || value.inputRelease !== undefined && value.cleanup === "issued");
1315
+ }
1310
1316
  function protectedLiveRebindOldTargets(snapshot) {
1311
- return (snapshot?.liveRebindings ?? []).filter(value => value.oldTargetProtected === true || liveRebindOldTargetUnknown(value) || value.inputRelease !== undefined && value.cleanup === "issued").map(value => ({ ...value.request.binding.target }));
1317
+ return protectedLiveRebindOldTargetIntents(snapshot).map(value => ({ ...value.request.binding.target }));
1312
1318
  }
1313
1319
  function pendingWorkspaceRelocationRequests(snapshot) {
1314
1320
  return [
@@ -1362,10 +1368,11 @@ function assertWorkspaceRestoreBindingProtection(file) {
1362
1368
  throw new Error("Protected Workspace Restore owner target changed.");
1363
1369
  }
1364
1370
  }
1365
- for (const old of protectedLiveRebindOldTargets(file.workspaceRestore)) {
1371
+ for (const { request } of protectedLiveRebindOldTargetIntents(file.workspaceRestore)) {
1372
+ const old = request.binding.target;
1366
1373
  if (file.workspaceBindings?.some(value => targetMatches(value.target, old)) ||
1367
1374
  file.threads?.some(value => targetMatches(value.target, old) && ["active", "starting", "pending", "probe-required"].includes(value.status)) ||
1368
- file.pendingProvisions?.some(value => !value.target || targetMatches(value.target, old)) ||
1375
+ file.pendingProvisions?.some(value => conflictsWithWorkspaceRestoreProvision(request, value)) ||
1369
1376
  file.reservations?.some(value => targetMatches(value.target, old)))
1370
1377
  throw new Error("Unknown live cleanup target remains protected.");
1371
1378
  }
@@ -2942,7 +2949,7 @@ export function createTelegramTopicTargetStore(options) {
2942
2949
  !isDeepStrictEqual(file.workspaceRestore?.liveRebindings?.find((intent) => intent.request.operationId === expected.request.operationId), expected))
2943
2950
  throw new Error("Workspace live-rebind observation changed.");
2944
2951
  assertWorkspaceRestoreBindingProtection(file);
2945
- assertRestoreRecoveryProtection(path, file.workspaceRestore.operations, file.pendingProvisions ?? []);
2952
+ assertRestoreRecoveryProtection(path, provisioningProtectionIntents(file.workspaceRestore), file.pendingProvisions ?? []);
2946
2953
  observe({
2947
2954
  threads: file.threads,
2948
2955
  workspaceBindings: file.workspaceBindings,
@@ -2976,7 +2983,7 @@ export function createTelegramTopicTargetStore(options) {
2976
2983
  !isDeepStrictEqual(file.workspaceRestore?.liveRebindings?.find((intent) => intent.request.operationId === request.operationId), expected))
2977
2984
  return true;
2978
2985
  assertWorkspaceRestoreBindingProtection(file);
2979
- assertRestoreRecoveryProtection(path, file.workspaceRestore.operations, file.pendingProvisions ?? []);
2986
+ assertRestoreRecoveryProtection(path, provisioningProtectionIntents(file.workspaceRestore), file.pendingProvisions ?? []);
2980
2987
  const canonical = file.workspaceBindings ?? [], warm = Array.from(workspaceBindings.values());
2981
2988
  const matchesRecipient = (bindings, rows) => {
2982
2989
  const bindingsForKey = bindings.filter((binding) => binding.bindingKey === request.binding.bindingKey);
@@ -3008,7 +3015,9 @@ export function createTelegramTopicTargetStore(options) {
3008
3015
  const protectedTarget = (bindings, rows, reserved, provisions, cleanups) => bindings.some((binding) => targetMatches(binding.target, old)) ||
3009
3016
  rows.some((row) => currentRow(row) && targetMatches(row.target, old)) ||
3010
3017
  reserved.some((value) => targetMatches(value.target, old)) ||
3011
- provisions.some((value) => !value.target || targetMatches(value.target, old)) ||
3018
+ // Use the same binding/slot/owner/target conflicts as rebind admission. An unrelated unlocated
3019
+ // create must not globally veto cleanup of this exact former binding (nor its issued marker).
3020
+ provisions.some((value) => conflictsWithWorkspaceRestoreProvision(request, value)) ||
3012
3021
  cleanups.some((value) => targetMatches(value.target, old));
3013
3022
  if (protectedTarget(canonical, file.threads, file.reservations ?? [], file.pendingProvisions ?? [], file.pendingCleanups ?? []) ||
3014
3023
  protectedTarget(warm, Array.from(records.values()), reservations, pendingProvisions, pendingCleanups) ||
@@ -3153,7 +3162,10 @@ export function createTelegramTopicTargetStore(options) {
3153
3162
  if (bindings.length > 1 ||
3154
3163
  file.threads.some((value) => targetMatches(value.target, target)) ||
3155
3164
  file.reservations?.some((value) => targetMatches(value.target, target)) ||
3156
- file.pendingProvisions?.some((value) => !value.target || targetMatches(value.target, target)) ||
3165
+ // This exact created frame already proves a separately created routing tab (API ACK or authenticated
3166
+ // implicit creation). An unlocated Workspace create is not its owner; keep that intent unresolved,
3167
+ // but veto a concrete target collision. Generic unknown-target/Restore cleanup keeps its own guards.
3168
+ file.pendingProvisions?.some((value) => !!value.target && targetMatches(value.target, target)) ||
3157
3169
  file.pendingCleanups?.some((value) => targetMatches(value.target, target)) ||
3158
3170
  file.workspaceRetirements?.some((value) => targetMatches(value.binding.target, target)) ||
3159
3171
  before.state.operations.some((value) => targetMatches(value.request.target, target)))
@@ -3274,7 +3286,7 @@ export function createTelegramTopicTargetStore(options) {
3274
3286
  }
3275
3287
  assertWorkspaceRestoreBindingProtection(before.file);
3276
3288
  const provisions = parseTopicTargetFile(before.file).pendingProvisions ?? [];
3277
- assertRestoreRecoveryProtection(path, before.state.operations, provisions);
3289
+ assertRestoreRecoveryProtection(path, provisioningProtectionIntents(before.state), provisions);
3278
3290
  const after = { ...before.file, workspaceRestore: next };
3279
3291
  // New temporary targets must be checked against the file they will be published into.
3280
3292
  assertWorkspaceRestoreBindingProtection(parseTopicTargetFile(after));
@@ -3284,7 +3296,7 @@ export function createTelegramTopicTargetStore(options) {
3284
3296
  if (workspaceIO) {
3285
3297
  if (!workspaceFrame || !currentScope() || !isCurrent())
3286
3298
  return false;
3287
- assertRestoreRecoveryProtection(path, [...before.state.operations, ...next.operations], provisions);
3299
+ assertRestoreRecoveryProtection(path, [...provisioningProtectionIntents(before.state), ...provisioningProtectionIntents(next)], provisions);
3288
3300
  committedBefore = before;
3289
3301
  committedAfter = JSON.parse(content);
3290
3302
  workspaceFrame.value = committedAfter;
@@ -3304,7 +3316,7 @@ export function createTelegramTopicTargetStore(options) {
3304
3316
  return;
3305
3317
  if (!isDeepStrictEqual(read().file, before.file))
3306
3318
  throw new Error("Workspace Restore evidence changed before publication.");
3307
- assertRestoreRecoveryProtection(path, [...before.state.operations, ...next.operations], provisions);
3319
+ assertRestoreRecoveryProtection(path, [...provisioningProtectionIntents(before.state), ...provisioningProtectionIntents(next)], provisions);
3308
3320
  if (!currentScope() || !isCurrent())
3309
3321
  return;
3310
3322
  replaceTelegramWorkspaceFile(temporary, path);
@@ -4175,7 +4187,7 @@ export function createTelegramTopicTargetStore(options) {
4175
4187
  upsertPendingProvision(provision) {
4176
4188
  if (pendingWorkspaceRelocationRequests(workspaceRestore).some((request) => conflictsWithWorkspaceRestoreProvision(request, provision)) ||
4177
4189
  conflictsWithTemporaryThread(workspaceRestore, provision.target) ||
4178
- protectedLiveRebindOldTargets(workspaceRestore).some(old => !provision.target || targetMatches(old, provision.target))) {
4190
+ protectedLiveRebindOldTargetIntents(workspaceRestore).some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, provision))) {
4179
4191
  throw new Error("Protected Workspace Restore provisioning conflict.");
4180
4192
  }
4181
4193
  const next = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.57.0",
3
+ "version": "0.57.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -485,6 +485,10 @@ This multi-input lifecycle is implemented in the local candidate, not authority
485
485
 
486
486
  Temporary Reroute publishes the prompt in its selected destination before local queue/command admission or follower IPC. The existing exact whole-group `forwardedInputs` grant precedes this one unretried Bot API request as well as native dispatch; complete distinct positive returned IDs provide display/reply anchors, never source acceptance or settlement. `forwardMessages` preserves albums and requires ascending source IDs, so the warm map correlates each destination ID with its immutable original source update ID rather than a routed zero ID or array position. Acknowledged routing copies are preferred; a proven still-retained All original or real native-Thread original may supply the source instead. Before issuance and after awaits, guards recheck source/executor/operator/context/generation/profile, exact issued group, live recipient record and Workspace session binding; followers also retain their exact registration/protocol ownership. API and IPC callbacks stay local. Reroute passes recipient/session authority plus each original execution fence to the existing socket connect/write/ACK checks, with one guarded transport attempt; a stale post-write ACK cannot settle donor custody or undo accepted recipient work. The input's warm projection uses the confirmed copy ID and clears former-tab reply suppression before carrying the original execution fence; donor journal originals remain untouched. A partial/malformed/lost API reply or revoked authority keeps the issued group nonterminal with no output or native retry, even after restart. A lost native IPC ACK similarly retains donor authority and cannot replay an already accepted recipient input or its display copies. Local receipt disposal and exact whole-group foreign source-removal ACKs still own temporary cleanup; neither a visible copy nor model completion grants deletion. A follower donor ACK may precede recipient receipt disposal, so a fully resolved but protected, unissued tab retains one metadata-only one-second recheck and accepts existing session-delivery drain wakes. Every attempt reacquires profile admission and fresh namespace/binding/source/authority proof; time is not a deletion grant. A fresh runtime drain can reobserve retained completed membership without replaying its input. Issued or unknown deletion never repeats. Restore's held-input sequencing and cleanup schedules are unchanged.
487
487
 
488
+ A canonical temporary frame in phase `created` identifies its own independently created tab: either the exact one-shot `createForumTopic` response was acknowledged, or an owner-authenticated fresh implicit creation was admitted. An unrelated Workspace creation intent without a target therefore does not turn this positively identified tab into an unknown target. That uncertain intent is retained unchanged; this is neither age-based expiry nor permission to discard its recovery evidence. Any concrete provision targeting this tab, binding, owner, reservation, Restore operation, unresolved source or uncertain issued deletion retains its existing veto. `creating` frames never acquire this exemption, and generic unknown-target cleanup keeps its separate conservative rules. Restore old-target protection uses its exact binding/slot/owner/target conflict contract, including late recovered target evidence, rather than treating every unrelated targetless creation as a global veto; see the [Live-Rebind Channel](./multi-instance-bus.md#live-rebind-channel).
489
+
490
+ Fresh local menu Reroute (`/start`, `/help`, `/status`, `/model`, `/thinking`, `/queue`, `/settings`) uses the existing selected-command owner with a release barrier captured during preparation. After acknowledged destination publication, exact worker completion readiness and paired-recipient validation permit one acceptance report, not immediate menu effects. The routing operation finalizes its chooser and releases profile admission while retaining its exact source references. A bounded subscription then requires the worker's exact removal ACK; absence, a thrown removal after commit, timeout or ended source authority is not completion. Cleanup acquires a new profile admission, behind source-completion bookkeeping, and rechecks every ordinary temporary-tab protection. Only then are the accepted menu effects released under the captured recipient/session/target guard. Siblings, live queue custody, unconfirmed deletion and other protection may retain the tab without cancelling the independently accepted menu. An issued deletion never repeats. Lost recipient authority or an unconfirmed source ACK suppresses effects without replay. Sources lacking fresh exact completion readiness, other command kinds, follower delivery and Restore retain their existing paths; neither queue-producing commands nor cold inputs are pre-completed to manufacture cleanup authority. Ordinary follower ACK proves durable custody but does not hold the recipient worker before execution. Restore's held-command RPCs additionally bind relocation, saved input and canonical release authority; they are not a negotiated Reroute release protocol. Mutating local commands complete after their semantic action, and queue-producing commands retain their separately acknowledged receipts. Startup/historical inputs are excluded by the worker's fresh-source readiness predicate. Those contracts cannot borrow the menu-only barrier by changing command classification or weakening source protection.
491
+
488
492
  - `Membership`: A positively source-linked disposable temporary Thread owns no Pi binding or A–Z slot and may contain multiple independent unassigned inputs, each with its own routing controls. Track exact journal source/group membership, including additional inputs, rather than treating the first chooser or a process-local count as the whole Thread. Serialize admission, selection, cancellation, Restore and cleanup through the existing Workspace admission and mutation owners; unreadable, incomplete, selected, accepted, running or unknown-issued evidence blocks deletion.
489
493
  - `Per-input Cancel`: Privately retain the exact original and positively commit source-only cancellation before acknowledging `Routing cancelled` and deleting its one-shot chooser message. Other unassigned inputs retain their choosers and custody; cancelling one input neither delivers nor cancels siblings. No separate Cancel-and-delete action or extra confirmation dialog is required, but the chooser must disclose the conditional whole-tab deletion consequence.
490
494
  - `Last-resolution cleanup`: Only an explicit Cancel or positive completion of an explicitly issued Forward that resolves the final known group may schedule deletion of the entire still-temporary Telegram tab, including all its messages. The operator-approved grace is now zero, superseding the earlier 1-second and 2–3-second designs: deletion is attempted right after the chooser is deleted. For Last Cancel, scheduling follows positively recorded whole-group cancellation and successful retirement of the final chooser, not the initial button tap. The timer establishes earliest eligibility for one proof-gated cleanup attempt, not an exact deletion time: Workspace admission, fresh protection checks and Bot API latency may delay or refuse it. Actual client timing was accepted in the operator's 0.52.0 live smoke. Mirrored native witnesses use shortened injected delays, not a production-default or Telegram-client timing measurement. This grace is presentation timing, not proof of emptiness or non-delivery. New input or a changed binding/authority cancels the scheduled cleanup. Immediately before issuance, reacquire current Workspace admission, recheck exact operator/profile/session/leader/source/target authority, positive disposable-tab provenance, complete source dispositions and all Thread protection, then publish the canonical one-shot temporary cleanup grant. A restored/bound Thread or any unresolved/unverifiable work remains protected. TTL, startup, silence and a zero chooser count do not manufacture this last-Cancel grant. Failed/unknown deletion cannot undo cancellation or cause automatic repeat issuance.
@@ -694,7 +698,7 @@ Assistant delivery guarantees:
694
698
  - Prompt context blocks use compact metadata (`[tag|key:value]`) as the stable inbound contract. `[telegram...]` names the current surface only: owner/current turns use `[telegram]` or `[telegram|thread:<name>]`; guest-mode turns use `[telegram|guest:<group-title-or-peer-username-or-id>]`. In a private Guest Mode turn the paired owner's `from` identity is never the guest: the remote private-chat identity wins, then non-owner caller metadata, with a non-bot replied peer available only as a final identity fallback when stronger conversation evidence is absent; username falls back to the remote display name and numeric id. Reply attribution still belongs independently in `[reply|from:...]`, and a replied bot can never define or replace the current `[telegram|guest:...]` location identity. Source authors for quoted/forwarded material and their files are carried by `[reply|from:<username-or-id>]`, `[forward|from:<username-or-id>]`, and `[attachments|from:<username-or-id>]`, while plain `[attachments]` remains current-turn attachments and is ordered before reply/forward/source context. Media embedded in inbound Telegram `rich_message` blocks is downloaded like ordinary message media and stays attached to its forward-source block instead of being mislabeled as current-user material. Guest-mode turns append a `[guest]` block to their turn text stating the one-reply/limited-window constraint and instructing a fast, concise, self-contained answer; the note travels with the turn text rather than the system prompt.
695
699
  - Quoted rich replies use Telegram `rich_message` blocks as the prompt-context source when available, so `[reply]` context receives rendered plain text instead of raw `InputRichMessage.markdown` fallback text. Replied media runs through the same inbound handlers and voice transcription providers as current-message media, with provenance-scoped `[outputs|from:…]` appended inside the reply block.
696
700
  - Long native Markdown replies are split only at Telegram Rich Message transport limits; oversized fenced code, display-math, and fully wrapped inline-formatting blocks are rewrapped per chunk so persisted Rich Markdown chunks remain structurally valid.
697
- - When Draft previews are enabled, streaming previews pass structurally closed assistant Markdown prefixes through to `sendRichMessageDraft` with ownership checks, voice and guest-turn suppression, and serialized flushes. Telegram Guest Mode allows exactly one answer that cannot be patched afterward, so guest turns never start preview state even while `assistant.draftPreviews` is enabled. Unclosed inline spans, links, fenced code, comments, and display-math blocks are held back until a safe boundary exists. Draft failures are recorded and the failing frame is skipped instead of degrading to raw plain-message previews, because partial Markdown can be invalid while the final message remains valid.
701
+ - When Draft previews are enabled, streaming previews pass structurally closed assistant Markdown prefixes through to `sendRichMessageDraft` with ownership checks, voice and guest-turn suppression, and serialized flushes. Telegram Guest Mode uses one guest answer, normally an early inline placeholder edited at completion, so guest turns never start ordinary preview state even while `assistant.draftPreviews` is enabled. Queued attachments replace the acknowledged placeholder through cached-media edits rather than a second guest answer; supported types, error handling and staging authority follow the [Guest Mode media boundary](./outbound.md#guest-mode-media-boundary). Unclosed inline spans, links, fenced code, comments, and display-math blocks are held back until a safe boundary exists. Draft failures are recorded and the failing frame is skipped instead of degrading to raw plain-message previews, because partial Markdown can be invalid while the final message remains valid.
698
702
  - Preview flushes are single-flight within their generation. Publication and final delivery capture only preceding draft/publication dependencies, never later drafts blocked by that publication, and final delivery performs no post-final draft-clear call. Reset/invalidation detaches old-generation dependencies; previously issued transport requests may still settle, but stale draft, timer and publication results cannot clear or mutate replacement work. Successful final text delivery clears local preview state only while its captured segment, session and transport remain active.
699
703
 
700
704
  UI/compat rendering guarantees:
@@ -311,6 +311,8 @@ Every participating peer must negotiate the required capabilities and retain cur
311
311
 
312
312
  Semantic reports, queued receipts, completion observation and delivery remain separate. Native exact ACK and donor CAS still own source disposition; lost or mismatched evidence retains uncertainty without handler replay, another queue or fallback admission. This section owns the unified command-channel policy; [architecture](./architecture.md#live-rebind-module-map) owns the module map. Existing Workspace Restore receivers/readers and queue-handoff retain their custody contracts; the new loaded root prefers live rebinding rather than using legacy Restore as an uncertain-input fallback. Source-enabled bounded cleanup is not proof of an actual deletion or client acceptance. Supported leader/follower prompts, commands and templates use `saved → binding applied → cleanup decision → input issuance → native admission/execution`. The chooser holds its existing profile admission through the unchanged whole first delay and one fresh cleanup attempt, without nested leader admission or another queue. A negotiated follower reports work through `observe-held` before release; binding readiness is not execution permission. `inputRelease: held/issued/accepted` is independent of cleanup: an issued input is never replayed after a lost admission reply, and a finished cleanup with held input remains active binding/source protection. Confirmed, failed or unknown terminal deletion permits the one native input attempt only on the confirmed binding. Issued and unknown old-target evidence remains canonically protected against reuse and generic cleanup, including response/publication interruption; unknown evidence survives input acceptance and is never capacity-evicted or reissued. If earlier work prevents issuance, input proceeds on the confirmed new target and the existing paced scheduler owns deferred cleanup; no old-target clearance is invented. Detached command producers accept their one input grant independently from exact source ACKs and reply publication; menu/reply authority tolerates only that issued-to-accepted progress, never changed binding, cleanup, recipient or generation. Expanded templates retain their captured content/default lane and Continue its control lane. A session-replacing `/new` producer with earlier protected work records positively unissued cleanup plus durable `oldTargetProtected`; it cannot erase that old-target protection or transfer a delete grant into the successor. Actual replacement still uses the existing post-removal Pi gateway and confirmation, not Restore readiness. Follower command/control completion retains the exact prepared-source/native ACK before donor CAS; queued admission is not completion and cannot dispose the donor. Unsupported producers do not gain arbitrary follower execution. Older peers keep the preceding supported channel. In deferred cleanup, old-tab cleanup waits for the recipient session to be idle, which the moved input itself may keep busy; its paced retries keep a whole first delay so a detached command reply still sees the released, cleanup-free row, but after that a leader's `agent_settled` or drained output barrier wakes a waiting retry at once. Publication/command-work, Activity handlers, per-target companion queues and target/chat API counters capture lifetime-fenced notifications when work starts and emit only after their pending observation clears. The session adapter defers the hint without awaiting cleanup and rechecks its captured context generation, transport/profile and leader epoch/operator authority. Streaming technical events can drain the publication queue without ending the agent run. A delivery hint while SDK idle is false, unavailable or throwing is retained in the existing chain but does not clear its timer or acquire Workspace admission. `agent_settled` remains an unconditional recheck hint; after settlement, actual remaining delivery drains can wake the retry. A running attempt retains one coalesced hint without a concurrent check, then re-enters the same idle filter after releasing its admission. A fresh timer/settlement attempt consumes the previously deferred hint; signals arriving during that await are not lost. Timer retries, including busy-session samples, are unchanged. Terminal outcomes discard hints, including unknown issued deletion. The initial one-second delay, retry/window fallback, follower pacing and negotiated IPC contracts remain unchanged. Each chain also retains its original generation/profile-store/epoch/operator lifetime rather than rebasing to a reused context. The wake only re-samples every gate through fresh admission; it never grants cleanup, clears unconfirmed work, occupies the publication tail or holds Pi settlement for deletion. Windows runs the same strict paths, exercised by the CI matrix; actual Pi/client acceptance and release are not inferred from supplied IPC fixtures.
313
313
 
314
+ Live-rebind old-target cleanup and retained issued/unknown deletion markers use the same provisioning-conflict identity as rebind admission: binding key, slot, legacy owner identity and concrete old/new target. An unrelated intent without a target is retained, but is not a global veto on this exact former binding. The inverse provisioning write checks use the same rule. Fresh provisioning-recovery evidence participates in cleanup observation, publication and captured-recipient API-boundary checks, including after issuance; an exact late target collision or malformed matching recovery still refuses without erasing the intent or retrying a deletion. The ordering remains held input → confirmed new binding → old-tab cleanup decision → input release, not destructive deletion before a replacement binding is established. Native All `/start` regressions cover the successful old-tab deletion/new-tab preservation sequence, an unrelated ambiguous intent, protected busy work and a lost deletion reply. The paired Reroute regression deletes only the exhausted temporary tab and preserves the selected instance's binding. Post-reload operator checks confirmed both idle leader flows: Reroute removes its exhausted temporary tab, while Restore preserves the new tab and removes the old one. This acceptance does not imply every busy-session or follower scenario was exercised.
315
+
314
316
  ### Local IPC endpoint with bounded native paths
315
317
 
316
318
  Leader opens a local Node `net` endpoint only for the start that still owns its lifecycle: a stop that lands while startup is still replaying cleanup or binding the endpoint makes that start release whatever it brought up and never begin polling, and the startup display-stabilization timer arms only for the start that began it. The endpoint is a Unix-domain socket under the agent temp directory on Unix-like platforms, or a deterministic Windows named pipe (`\\.\pipe\pi-telegram-...`) on native Windows. A filesystem-style endpoint supplied by legacy state or a transport harness normalizes deterministically to the same Windows pipe boundary before listen/connect. On Unix, an endpoint that would exceed conservative domain-socket pathname limits maps to a private user-scoped, hash-derived path under the OS temp directory; ordinary agent paths remain under `tmp/pi-telegram`. On Unix, each server listens on a private generation socket and atomically publishes the stable profile path as a relative symlink; delayed shutdown closes only its private path and cannot remove a replacement generation's link. Followers register, heartbeat, and exchange routed events. The transport boundary owns endpoint derivation, socket-vs-pipe detection, bounded operation-aware retry policy, timeout/transient IPC error classification, endpoint reachability probes, and request-scoped transport events. Follower registration uses a longer registration-specific response timeout than ordinary heartbeat/forwarding calls because the leader may need to provision a Telegram thread before it can return the assigned target; timing out that handshake leaves a visible tab with no follower heartbeat. Keep this handshake to the true critical path: create/reuse the target, persist the live binding, and return it. Connected notices and replaced-thread reconciliation cleanup are non-critical and should run after registration so a follower becomes routable before Telegram client/server UI convergence work finishes.
@@ -587,7 +589,7 @@ For repeated unselected All-tab `/start`, a newer durable source supersedes olde
587
589
 
588
590
  The bounded pending reroute owns its original source target and the returned chooser message ID independently from remaining messages. Authenticated callbacks must match the stored chat/chooser and any supplied thread field. `InaccessibleMessage` may omit the thread field; a missing callback message, unknown chooser, conflicting identity, threadless source, or already-owned Restore source fails closed rather than forwarding to the old target. Restart or expiry without that pending identity does not authorize reconstruction from callback data alone. When the original input itself has no thread ID (including an All-tab `/start`), the chooser offers routing only, not Restore; old Restore callbacks explain that a plain message must first be sent in the destination thread. Client tab selection is not inferred from recent topic creation or from the chosen Pi instance.
589
591
 
590
- After dispatch, cleanup retries retain source identity but never redispatch accepted messages. Confirmed typed HTTP 400 `message to delete not found` completes message deletion idempotently; permission and transient failures remain errors. Reroute cleanup rechecks current bindings, live targets, reservations, and pending provisions before close, before delete, and on retry. Other destructive cleanup origins use the shared synchronous target-protection policy: explicit retirement permits only its unchanged departing binding, persisted shutdown intent permits only its original pre-intent binding, and reservation/provision cleanup permits only the corresponding unchanged claim. Protection checks also guard post-API local invalidation, reservation, and disconnect completion. A newly protected target cancels remaining cleanup. Already-issued remote operations cannot be undone by a later local ownership change; checks prevent subsequent effects, not retroactive cancellation.
592
+ After dispatch, cleanup retries retain source identity but never redispatch accepted messages. For a fully resolved, positively unbound temporary tab, explicit cleanup may recover historical v1 queued receipts owned by confirmed-dead processes under profile admission and cross-family journal serialization. Strict complete namespace and exact-reference inspection precede whole unoffered receipt CAS recovery; alive/unverifiable owners, offered receipts, malformed sources and unsupported custody remain protective. Protection reads themselves never recover or repair files, and successful recovery is followed by a fresh ordinary protection check before one deletion grant. Confirmed typed HTTP 400 `message to delete not found` completes message deletion idempotently; permission and transient failures remain errors. Reroute cleanup rechecks current bindings, live targets, reservations, and pending provisions before close, before delete, and on retry. Other destructive cleanup origins use the shared synchronous target-protection policy: explicit retirement permits only its unchanged departing binding, persisted shutdown intent permits only its original pre-intent binding, and reservation/provision cleanup permits only the corresponding unchanged claim. Protection checks also guard post-API local invalidation, reservation, and disconnect completion. A newly protected target cancels remaining cleanup. Already-issued remote operations cannot be undone by a later local ownership change; checks prevent subsequent effects, not retroactive cancellation.
591
593
 
592
594
  Follower Restore uses only the source-bound `leader.workspaceRestore` protocol described under [protocol identity and compatibility](#protocol-identity-and-compatibility). The follower validates the retained operation and exact registration/session authority under read-only canonical snapshot protection; it never persists leader-owned state. Lost apply replies require inspection, not target replacement or another apply grant. Legacy target-replacement requests are rejected before recipient effects.
593
595
 
@@ -80,9 +80,11 @@ Core assistant output accepts only the Markdown or HTML `InputRichMessage` forms
80
80
 
81
81
  ### Guest Mode media boundary
82
82
 
83
- A Guest Mode reply is one `answerGuestQuery` call carrying exactly one `InlineQueryResult`; it is not a normal chat target and cannot receive `sendDocument`/`sendVoice` multipart uploads through sentinel `chatId: 0`. `telegram_attach` therefore admits at most one file during a guest turn and rejects additional files before queue mutation.
83
+ A Guest Mode reply starts with one `answerGuestQuery` call carrying exactly one `InlineQueryResult`; it is not a normal chat target and cannot receive `sendDocument`/`sendVoice` multipart uploads through sentinel `chatId: 0`. The direct owner normally answers immediately with a placeholder and retains its `inline_message_id` for the final edit. `telegram_attach` admits at most one file during a guest turn and rejects additional files before queue mutation.
84
84
 
85
- Telegram accepts public URLs or existing Telegram `file_id` values for inline media results, but pi-telegram does not publish local artifacts to external hosting. A local guest document, photo, MP3 audio, or OGG/OPUS voice therefore uses a temporary upload to the paired owner's bot chat, extraction of the returned `file_id`, one cached-media guest answer, and best-effort deletion of the staging message. That message can briefly appear or notify the owner. One guest query can carry only one media item, and its answer text must fit the media caption limit rather than a separate full Rich Markdown message.
85
+ Local guest documents, PNG/JPEG photos and MP3 audio use a temporary upload to the paired owner's bot chat, extraction of the returned `file_id`, and best-effort deletion of the staging message. With an acknowledged placeholder, the final result replaces that exact inline message through `editMessageMedia`; it never calls `answerGuestQuery` again. Without a placeholder, delivery retains the single cached-media guest-answer path, including OGG/OPUS voice support. The staging message can briefly appear or notify the owner. One reply carries one media item, with final text truncated to the 1024-character caption limit. No external file hosting or upload to `chatId: 0` is used.
86
+
87
+ OGG/OPUS voice attachments cannot replace the acknowledged inline placeholder and are rejected before queue mutation; voice markup produces an explicit explanation instead of silently disappearing. Use MP3 audio or a normal bot chat. Upload failures and confirmed media-edit rejections replace the placeholder with a delivery-error notice where possible. An uncertain media-edit outcome is recorded without retrying or overwriting the possibly delivered media. Session/transport authority is checked after placeholder stop and upload, and at final edit issuance; retired work cannot edit, send fallback text or delete staging messages under successor authority. Actual guest-media rendering remains a live-client acceptance check.
86
88
 
87
89
  Configured text handlers provide `template`. A string is one command; an array is ordered composition. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values. The command-template default timeout applies automatically. Use `template: [...]` for composition; the old local `pipe` alias is removed in 0.13.0. A falsy root `when` skips that handler: inbound and voice selection continue with the next matching handler, while a text transform passes its input through unchanged. A falsy `when` on a composed step, or on any enclosing composition node, skips that step and passes the previous output on. `recover` runs only between failed `retry` attempts of the node that declares it; its output is ignored and a recovery failure stops further retries. `parallel` and `delay` are not executed by these handlers.
88
90