@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
@@ -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", {
@@ -561,6 +561,8 @@ export interface TelegramTopicTargetStore {
561
561
  existingBindingOnly?: boolean;
562
562
  sessionId?: string;
563
563
  provisionProfileKey?: string;
564
+ /** Explicit same-process session handoff from `previousInstanceId`; never inferred. */
565
+ sessionHandoff?: boolean;
564
566
  onCapacityUnavailable?: () => void;
565
567
  }) => TelegramWorkspaceBindingIdentity | undefined;
566
568
  releaseWorkspaceClaim: (instanceId: string) => boolean;
@@ -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 = {
@@ -4892,10 +4904,25 @@ export function createTelegramTopicTargetStore(options) {
4892
4904
  if (ownedProvisions.length > 1)
4893
4905
  return undefined;
4894
4906
  const ownProvision = ownedProvisions[0];
4895
- const retainedTarget = ownProvision?.target ?? binding?.target ?? legacyRecord?.target;
4896
- const retainedSlot = ownProvision?.slot ?? binding?.slot ?? legacyRecord?.slot;
4907
+ // A same-cwd session handoff keeps its predecessor's Thread slot; the Workspace commit re-keys that binding.
4908
+ const handoffBindings = binding ||
4909
+ ownProvision ||
4910
+ !options?.sessionHandoff ||
4911
+ !previousInstanceId ||
4912
+ !isSameTelegramProcessInstance(previousInstanceId, instanceId)
4913
+ ? []
4914
+ : Array.from(workspaceBindings.values()).filter((candidate) => candidate.cwd === identity.cwd &&
4915
+ candidate.sessionId !== identity.sessionId &&
4916
+ Array.from(records.values()).some((record) => record.instanceId === previousInstanceId &&
4917
+ record.status === "active" &&
4918
+ targetMatches(record.target, candidate.target)));
4919
+ const handoffBinding = handoffBindings.length === 1 ? handoffBindings[0] : undefined;
4920
+ const retainedTarget = ownProvision?.target ?? binding?.target ?? handoffBinding?.target ?? legacyRecord?.target;
4921
+ const retainedSlot = ownProvision?.slot ?? binding?.slot ?? handoffBinding?.slot ?? legacyRecord?.slot;
4897
4922
  const otherBindings = Array.from(workspaceBindings.values())
4898
- .filter((other) => other.bindingKey !== identity.bindingKey && other.slot)
4923
+ .filter((other) => other.bindingKey !== identity.bindingKey &&
4924
+ other.bindingKey !== handoffBinding?.bindingKey &&
4925
+ other.slot)
4899
4926
  .map((other) => ({
4900
4927
  bindingKey: other.bindingKey,
4901
4928
  slot: other.slot.toLowerCase(),
@@ -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"
@@ -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:
@@ -216,7 +216,7 @@ Production retirement requires a durable profile-scoped reader/writer ledger own
216
216
  - Retirement intent preparation/adoption/execution owns its exact gate-and-ledger protocol. The allocation wrapper invokes it outside ordinary registration/provisioning admission, after a typed capacity failure or to reconcile a retained retirement. Status projection and polling/routing bot-mode writes change only diagnostic or capability metadata; they neither create nor remove Thread/Workspace authority and serialize through the store's local persistence queue.
217
217
  - Production callers of reservation, provision/cleanup intent, target-record, Workspace-binding, and display mutators are contained by the owners above. The generic store remains policy-free for isolated tests and domain composition; calling a primitive directly is not production retirement authority.
218
218
  - Workspace identity remains the selected bot profile plus normalized exact full `cwd`; directory basenames are presentation, never routing keys. Each concurrent binding receives one profile-wide unique lowercase slot from `a` through `z`, persisted on the wire/store as its uppercase equivalent, independent of directory and leader/follower role. This replaces the two competing displayed allocation identities; immutable legacy `instanceSlot` and `bindingKey` remain recovery keys, not another displayed pool.
219
- - Automatic display mode is a bot-profile setting shared by Telegram Thread titles and Pi TUI status. The selector offers `letters`, `names`, `directory-snake`, then `directory-title`; absent or invalid values resolve to `letters`. Letters show `A`, `B`, `C`; Names shows the generated dictionary name for the slot, such as `Anchor` for `A`. The retired `directories` value is unsupported: it resolves to `letters`, is omitted from Settings, and is not rewritten automatically. A durable per-Workspace `manualThreadName`, set through Telegram `/name`, overrides any automatic projection until explicitly reset. Bare `/name` immediately enters exact-target rename input: cancel is always available, while reset is shown only when a manual override exists; no intermediate action-selection step exists. Store the automatic preference at `profiles.<name>.threadDisplayMode`, not as a process-local choice or a setting shared by unrelated bots.
219
+ - Automatic display mode is one shared setting used by Telegram Thread titles and Pi TUI status across every bot profile. The selector offers `letters`, `names`, `directory-snake`, then `directory-title`; absent or invalid values resolve to `letters`. Letters show `A`, `B`, `C`; Names shows the generated dictionary name for the slot, such as `Anchor` for `A`. The retired `directories` value is unsupported: it resolves to `letters`, is omitted from Settings, and is not rewritten automatically. A durable per-Workspace `manualThreadName`, set through Telegram `/name`, overrides any automatic projection until explicitly reset. Bare `/name` immediately enters exact-target rename input: cancel is always available, while reset is shown only when a manual override exists; no intermediate action-selection step exists. Store the automatic preference at top-level `threads.displayMode`, never as a process-local choice or inside `profiles.<name>`; the retired profile key is ignored.
220
220
  - Directory formatting operates on the shortest path-segment suffix that distinguishes the normalized exact cwd from every other displayed cwd, while routing and identity continue to use the unmodified full cwd. Each segment is tokenized deterministically at runs of non-letter/non-number characters, lowercase-or-number to uppercase transitions, and the acronym boundary before an uppercase-plus-lowercase word (`apiPRDServer` becomes `api`, `PRD`, `Server`). Empty tokens are discarded. `directory-snake` lowercases tokens and joins them with `_`; segment qualifiers also join with `_`, so `/work/API tools` previews as `api_tools`. `directory-title` joins words with spaces and qualified segments with ` / `; an all-uppercase token containing a letter is preserved (`PRD` remains `PRD`), while every other token is lowercased and capitalized (`api_tools` becomes `Api Tools`). Unicode letters and numbers participate in tokenization and locale-independent Unicode case conversion; punctuation is only a separator. Root and token-empty segments retain the existing safe fallback instead of fabricating an empty title. On Linux/macOS, a binding whose normalized cwd exactly equals the current OS user's normalized home directory uses the display segment `home`: `home` in Snake and `Home` in Title. This is display-only, not a change to cwd, binding or routing identity. Child paths, another user's home and Windows use ordinary formatting; Letters, Names, legacy presentation and manual overrides are unchanged. The synthetic segment participates in qualification, so an unrelated `/work/home` remains `work_home` or `Work / Home` rather than colliding; ordinary live-owner suffix rules still produce `home_a` or `Home A`.
221
221
  - Settings previews are computed by the same pure projector used for initial creation and reconciliation, against the current binding set rather than illustrative hard-coded text. Slot suffixes are applied after base formatting (`api_tools_a` and `Api Tools A`) and remain outside abbreviation/case normalization; title-case suffixes use one plain space and never a middle-dot separator. Manual names are never normalized. Case-insensitive or 128-character-bounded collisions remain fail-closed under the existing ambiguity contract. New persisted modes and follower requests require a new negotiated display-format capability; an older peer may continue operating only while the effective profile mode remains Letters or Names, and cannot silently reinterpret either new value.
222
222
  - The unreachable legacy `directories` projector remains only for decoding old internal snapshots; effective configuration falls back to Letters. The `directory-snake` and `directory-title` modes derive suffix visibility exclusively from the leader's current authenticated live-owner snapshot: one live binding for a normalized exact cwd has no suffix; two or more live bindings for that cwd all expose their globally assigned slots; dormant retained bindings never affect the count. `showSlotSuffix` remains readable for legacy presentation but is ignored by both new projectors and is never written merely because live concurrency changed. The leader captures exact profile, epoch, binding target, and follower registration generation before projection; a provisioning candidate counts only after its authenticated exact owner is admitted to the same serialized Workspace mutation, while an owner losing authority is excluded before reconciliation. Registration/provision completion, authenticated disconnect or confirmed-dead prune, target replacement, and promotion schedule one serialized reconciliation of every affected live same-cwd binding. A failed or partial Telegram rename retains acknowledged per-binding progress and retries only under a fresh live snapshot; dormant tabs are not renamed until they regain authenticated ownership. Leader startup treats its authenticated follower roster as unsettled for one follower-staleness window: automatic display reconciliations requested by election or incremental re-registration are deferred and coalesced until that boundary. A same-cwd follower returning within the window therefore preserves the already acknowledged suffix without an `unsuffixed → suffixed` Telegram rename; if it stays absent, the settled reconcile removes the suffix once. Explicit mode changes remain immediate, and leader stop/restart invalidates the pending timer. Equal basenames from different paths still require deterministic parent-path qualification independently from same-cwd suffixing. Preserve the existing `threadName` as generated/recovery identity while automatic modes are selected. New manual names live only in `manualThreadName`; do not guess manual provenance from a legacy name or palette membership. Telegram `/name Name` changes the manual override and displayed title only for its exact originating target. Leader and follower requests carry that target through final generation/binding checks, so replacement cannot redirect a stale dialog mutation. Reset uses the same negotiated `workspace-thread-rename-v1` capability and exact follower generation; the leader computes the current automatic projection from the latest live snapshot, edits the exact target, clears only `manualThreadName`, and persists before acknowledging. Follower metadata refresh preserves an acknowledged display title only while target and registration generation stay unchanged. Named-profile setup preserves the latest saved automatic preference even if another instance changes it while the token form is open.
@@ -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.
@@ -561,7 +563,7 @@ All files containing routing, chat ids, thread ids, or process details use priva
561
563
  - Each unfinished preservation retains one admission operation ID across sequential attempts, so a lost acquisition acknowledgement reuses and releases the exact lease instead of leaking another. A rejected pre-rename write leaves the owner intact; a committed write with lost acknowledgement is recognized without rewriting first inactivity. A liveness interruption is retryable, not successful settlement. Every attempt reacquires profile admission and the shared mutation gate, rechecks authority at rename, and preserves accepted work. Cancellation or scope loss never guesses away an unresolved admission lease; existing exact lease/dead-owner recovery remains authoritative.
562
564
  - Followers treat rejected/missing heartbeat acknowledgements as registration loss: retain the last known target locally, clear registered truth, show `reconnecting` instead of a healthy `follower` status, try to re-register with the current leader, wait a short leader-reload grace window, and retry. They promote only after the exact leader lease becomes stale or inactive; a live owner with an unreachable endpoint leaves the follower disconnected/retrying rather than creating a competing poller.
563
565
  - Persisted current manual-follower bindings survive abrupt process absence as restoration hints when Thread cleanup is disabled. When enabled, graceful Pi quit requests exact-generation teardown before lifecycle suspension; if that envelope is missed, stale pruning may delete only after the leader's OS confirms the exact registered PID has exited.
564
- - Fresh registration sends one compact connected notice in the assigned thread. Reusing a retained exact-session target resolves its acknowledged Workspace display title, even if the provisional claim uses another instance slot; a generated dictionary name is only fallback when no acknowledged title exists. Cross-process/session restoration, including an exact immediate handoff, uses that connected message as its visibility probe before publishing registration. Typing is not proof that a retained tab accepts messages. A proven stale target enters existing replacement provisioning; an ambiguous send refuses registration without inventing deletion or retry authority.
566
+ - Fresh registration sends one compact connected notice in the assigned thread. Reusing a retained exact-session target resolves its acknowledged Workspace display title, even if the provisional claim uses another instance slot; a generated dictionary name is only fallback when no acknowledged title exists. Cross-process/session restoration, including an exact cross-process handoff, uses that connected message as its visibility probe before publishing registration. A same-process session replacement (`/new`, `/fork`) that carries the exact active predecessor record is silent: it transfers that record, keeps the Thread, letter slot and acknowledged title, and re-keys the Workspace binding to the new session without sending a notice or allocating another slot. Typing is not proof that a retained tab accepts messages. A proven stale target enters existing replacement provisioning; an ambiguous send refuses registration without inventing deletion or retry authority.
565
567
  - Registration requires a present generation, and explicit disconnect requires that same exact live generation. Leader-side registration and disconnect mutations serialize per durable follower profile across old and replacement runtime instance IDs, so a replacement registration cannot overtake awaited destructive cleanup and an old disconnect cannot remove its successor's routing authority.
566
568
  - Successful forwarded updates and follower-originated API calls refresh liveness, so active followers are not pruned only because the interval heartbeat tick lagged. Each follower lifecycle owns at most one in-flight heartbeat for its exact registration generation; stop/replacement makes late settlement inert, and recovery or diagnostic failure cannot escape as an unhandled interval Promise.
567
569
  - Destructive follower thread teardown belongs to confirmed `/telegram-disconnect`, graceful Pi quit, or confirmed reconciliation actions, not generic heartbeat pruning. Manual disconnect retains its destructive confirmation and clears restart ownership; quit deletes the tab without prompting when Thread cleanup is enabled (default) but preserves the owner slot independently so a same-directory restart can reclaim leadership. Confirmed leader/follower teardown first persists an exact target/runtime-generation cleanup intent. The active leader attempts deletion under its current epoch; interruption preserves the intent so that leader or a successor can replay it under current authority, and confirmed deletion removes the binding plus intent in the same persisted state transition. If the graceful request is missed, stale heartbeat plus OS-confirmed absence of the exact registered PID may authorize the same cleanup while enabled; this action serializes ahead of replacement registration. Disabled cleanup, silence, heartbeat expiry alone, IPC/auth failure, and live or unknown process liveness remain non-destructive. Incomplete cleanup preserves durable intent for retry. A promoted leader uses its current owned leader epoch even when the inherited record still carries a historical `manual-follower` owner label.
@@ -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
 
@@ -93,7 +93,6 @@ interface TelegramBotProfile {
93
93
  botUsername?: string; // runtime-managed
94
94
  botId?: number; // runtime-managed
95
95
  allowedUserId?: number;
96
- threadDisplayMode?: "letters" | "names" | "directory-snake" | "directory-title";
97
96
  }
98
97
 
99
98
  interface TelegramConfig {
@@ -115,15 +114,16 @@ interface TelegramConfig {
115
114
  };
116
115
  threads?: {
117
116
  automaticCleanup?: boolean;
117
+ displayMode?: "letters" | "names" | "directory-snake" | "directory-title";
118
118
  };
119
119
  }
120
120
  ```
121
121
 
122
- Bot/session identity always persists under `profiles.<name>`. The ordinary setup path uses `profiles.default`; `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Named profiles use the same shape. Shared handlers plus `assistant`, `voice`, `time`, and `threads` remain top-level. On the first `0.24.0` load, unambiguous legacy root identity moves atomically into `profiles.default`; identical duplicates collapse, complementary fields merge, and conflicting values fail closed without modifying the file.
122
+ Bot/session identity always persists under `profiles.<name>`. The ordinary setup path uses `profiles.default`; `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Named profiles use the same shape. A profile holds only bot identity and its paired owner; shared handlers plus every Settings-menu preference (`assistant`, `voice`, `time`, and `threads`) remain top-level and apply to all profiles. On the first `0.24.0` load, unambiguous legacy root identity moves atomically into `profiles.default`; identical duplicates collapse, complementary fields merge, and conflicting values fail closed without modifying the file.
123
123
 
124
124
  The file is global across Pi instances and contains configuration only. The per-profile polling/admission cursor is `acceptedThroughUpdateId` in that profile's private durable update journal; it is not a config key. On first connection after this cut, a legacy config cursor is transferred directly into the journal before polling and then removed from config. Journal publication failure preserves the legacy source; config publication failure leaves the journal authoritative so retry is idempotent. Cooperating instances serialize recursive config delta merges through `telegram.json.transaction` and preserve unrelated global/profile changes from newer disk snapshots. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. Same-parent temp-file replacement retries bounded transient `EPERM`, `EACCES`, and `EBUSY` destination contention without deleting the live config or leaving transaction serialization. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
125
125
 
126
- Threaded Mode Settings exposes **Thread display** as Letters (default), Names, `directory-snake`, or `directory-title`; retained `directories` is unsupported, resolves to Letters, and is never rewritten automatically. `profiles.<name>.threadDisplayMode` is profile-scoped; absent and invalid values resolve to `letters`. Settings previews the current binding set through the same projector used for fresh titles and reconciliation. A manual `/name` override appears as `custom`; choosing any automatic Thread display option clears that override for the current Thread and immediately reapplies the selected projection. Names projects the generated dictionary name for the slot; the two directory modes tokenize Unicode path segments deterministically and render snake case or humanized title case, with `_a` or ` A` slot suffixes when required. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1`, the additional `directory-display-format-v1` capability for either new directory mode, and its exact registration generation. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
126
+ Threaded Mode Settings exposes **Thread display** as Letters (default), Names, `directory-snake`, or `directory-title`; retained `directories` is unsupported, resolves to Letters, and is never rewritten automatically. `threads.displayMode` is shared by every profile; absent and invalid values resolve to `letters`. The retired `profiles.<name>.threadDisplayMode` key is ignored, not migrated. Another profile's already-running leader applies a shared change at its next startup or display reconciliation. Settings previews the current binding set through the same projector used for fresh titles and reconciliation. A manual `/name` override appears as `custom`; choosing any automatic Thread display option clears that override for the current Thread and immediately reapplies the selected projection. Names projects the generated dictionary name for the slot; the two directory modes tokenize Unicode path segments deterministically and render snake case or humanized title case, with `_a` or ` A` slot suffixes when required. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1`, the additional `directory-display-format-v1` capability for either new directory mode, and its exact registration generation. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
127
127
 
128
128
  Hidden/default semantics are represented by absence:
129
129
 
@@ -1043,12 +1043,7 @@ export function registerTelegramCommandsAndTools({
1043
1043
  );
1044
1044
  }
1045
1045
  await configStore.load();
1046
- const latestProfile =
1047
- configStore.getStoredConfig().profiles?.[profileName];
1048
- configStore.setProfile(profileName, {
1049
- ...profile,
1050
- threadDisplayMode: latestProfile?.threadDisplayMode,
1051
- });
1046
+ configStore.setProfile(profileName, profile);
1052
1047
  configStore.activateProfile(profileName);
1053
1048
  await onTransportChanged?.();
1054
1049
  await persistConfig(configStore.get());
@@ -1176,7 +1171,7 @@ interface TelegramLifecycleBindingDeps {
1176
1171
  shutdownGenerativeAppLiveSurfaces?: () => void;
1177
1172
  resolveAutomaticThreadCleanupEnabled?: () => boolean | Promise<boolean>;
1178
1173
  buttonActionStore: OutboundHandlers.TelegramButtonActionStore;
1179
- callMultipart: OutboundHandlers.TelegramVoiceReplySenderDeps["sendMultipart"];
1174
+ callMultipart: (...args: Parameters<TelegramApi.TelegramBridgeApiRuntime["callMultipart"]>) => Promise<unknown>;
1180
1175
  sendChatAction: NonNullable<
1181
1176
  OutboundHandlers.TelegramVoiceReplySenderDeps["sendChatAction"]
1182
1177
  >;
@@ -1203,6 +1198,7 @@ interface TelegramLifecycleBindingDeps {
1203
1198
  ctx: Pi.ExtensionContext,
1204
1199
  ) => void;
1205
1200
  answerGuestQuery: TelegramApi.TelegramBridgeApiRuntime["answerGuestQuery"];
1201
+ editGuestInlineMessage?: TelegramApi.TelegramBridgeApiRuntime["editGuestInlineMessage"];
1206
1202
  deleteMessage: TelegramApi.TelegramBridgeApiRuntime["deleteMessage"];
1207
1203
  sendGuestReply: NonNullable<
1208
1204
  Queue.TelegramAgentEndHookRuntimeDeps<
@@ -1281,6 +1277,7 @@ export function registerTelegramLifecycleRuntimeHooks({
1281
1277
  onSteerTurnEnd,
1282
1278
  onPromptHandedOff,
1283
1279
  answerGuestQuery,
1280
+ editGuestInlineMessage,
1284
1281
  deleteMessage,
1285
1282
  sendGuestReply,
1286
1283
  editGuestReply,
@@ -1323,6 +1320,7 @@ export function registerTelegramLifecycleRuntimeHooks({
1323
1320
  turn: Queue.PendingTelegramTurn,
1324
1321
  attachment: Queue.QueuedAttachment,
1325
1322
  caption?: string,
1323
+ options?: { isDeliveryActive?: () => boolean },
1326
1324
  ): Promise<void> => {
1327
1325
  const stagingTarget = proactivePushTargetGetter();
1328
1326
  const stagingChatId = stagingTarget?.chatId;
@@ -1333,11 +1331,19 @@ export function registerTelegramLifecycleRuntimeHooks({
1333
1331
  }
1334
1332
  await OutboundAttachments.deliverTelegramGuestCachedAttachment({
1335
1333
  guestQueryId: turn.guestQueryId!,
1334
+ guestInlineMessageId: turn.guestInlineMessageId,
1335
+ editGuestInlineMessage,
1336
+ isDeliveryActive: options?.isDeliveryActive,
1336
1337
  stagingChatId,
1337
1338
  stagingTarget,
1338
1339
  attachment,
1339
1340
  caption,
1340
- sendMultipart: callMultipart,
1341
+ sendMultipart: (method, fields, fileField, path, name) =>
1342
+ callMultipart(method, fields, fileField, path, name, {
1343
+ assertAuthority() {
1344
+ if (options?.isDeliveryActive?.() === false) throw new Error("Guest attachment upload lost authority.");
1345
+ },
1346
+ }),
1341
1347
  answerGuestQuery: (guestQueryId, result) =>
1342
1348
  answerGuestQuery(guestQueryId, undefined, { result }),
1343
1349
  answerGuestText: (guestQueryId, text) =>
@@ -536,9 +536,10 @@ export function createTelegramBusAwareApiRuntime(
536
536
  editGuestInlineMessage(
537
537
  inlineMessageId: string,
538
538
  content: TelegramEditGuestInlineMessageContent,
539
+ options?: Pick<TelegramApiCallOptions, "assertAuthority">,
539
540
  ): Promise<void> {
540
541
  return deps.ownsDirect()
541
- ? deps.directRuntime.editGuestInlineMessage(inlineMessageId, content)
542
+ ? deps.directRuntime.editGuestInlineMessage(inlineMessageId, content, options)
542
543
  : rejectTelegramDirectOwnership("editGuestInlineMessage");
543
544
  },
544
545
  async deleteMessage(chatId: number, messageId: number): Promise<void> {
@@ -1591,6 +1591,7 @@ export function createTelegramBusFollowerTargetProvisioner(
1591
1591
  {
1592
1592
  existingBindingOnly: options?.existingWorkspaceBindingOnly === true,
1593
1593
  sessionId: registration.sessionId,
1594
+ sessionHandoff: registration.previousInstanceId !== undefined,
1594
1595
  provisionProfileKey: registration.profileKey ?? `manual:${registration.instanceId}`,
1595
1596
  onCapacityUnavailable() {
1596
1597
  capacityUnavailable = true;
@@ -1854,11 +1855,52 @@ export function createTelegramBusFollowerTargetProvisioner(
1854
1855
  };
1855
1856
  }
1856
1857
  const probeRequiredRecord = reconnectRecord?.status === "probe-required";
1858
+ // Pi /new or /fork inside one live process keeps its Thread; that process already proves the tab.
1859
+ const sameProcessSessionHandoff =
1860
+ crossSessionReuse &&
1861
+ reconnectRecord?.status === "active" &&
1862
+ registration.previousInstanceId === reconnectRecord.instanceId &&
1863
+ recoverableTarget === undefined &&
1864
+ Threads.isSameTelegramProcessInstance(
1865
+ reconnectRecord.instanceId,
1866
+ registration.instanceId,
1867
+ );
1857
1868
  const requiresVisibilityProbe =
1858
- crossSessionReuse ||
1869
+ (crossSessionReuse && !sameProcessSessionHandoff) ||
1859
1870
  probeRequiredRecord ||
1860
1871
  recoverableTarget !== undefined;
1861
- const retainedDisplayBinding = deps.topicTargetStore.getWorkspaceBindingByTarget(result.target, registration.sessionId);
1872
+ const transferSessionHandoffRecord = async (): Promise<void> => {
1873
+ const nowMs = getNowMs();
1874
+ const transferredRecord = deps.topicTargetStore.upsert({
1875
+ ...result.record,
1876
+ profileKey: followerProfileKey,
1877
+ owner:
1878
+ followerOwner.kind === "manual-follower"
1879
+ ? followerOwner
1880
+ : {
1881
+ kind: "manual-follower",
1882
+ instanceId: registration.instanceId,
1883
+ },
1884
+ instanceId: registration.instanceId,
1885
+ updatedAtMs: nowMs,
1886
+ lastSyncObservedAtMs: nowMs,
1887
+ lastReconcileAction: "follower-session-handoff",
1888
+ });
1889
+ await deps.topicTargetStore.persist();
1890
+ result = {
1891
+ target: transferredRecord.target,
1892
+ reused: true,
1893
+ record: transferredRecord,
1894
+ };
1895
+ };
1896
+ if (sameProcessSessionHandoff) {
1897
+ assertRestoreRegistration();
1898
+ await transferSessionHandoffRecord();
1899
+ }
1900
+ // A session handoff still owns the predecessor session's binding until the Workspace commit re-keys it.
1901
+ const retainedDisplayBinding =
1902
+ deps.topicTargetStore.getWorkspaceBindingByTarget(result.target, registration.sessionId) ??
1903
+ deps.topicTargetStore.getWorkspaceBindingByTarget(result.target);
1862
1904
  let connectedAnnouncement =
1863
1905
  !result.reused || requiresVisibilityProbe
1864
1906
  ? createTelegramBusInstanceLifecycleAnnouncement({
@@ -1913,28 +1955,7 @@ export function createTelegramBusFollowerTargetProvisioner(
1913
1955
  record: activatedRecord,
1914
1956
  };
1915
1957
  } else if (crossSessionReuse && reconnectRecord) {
1916
- const nowMs = getNowMs();
1917
- const transferredRecord = deps.topicTargetStore.upsert({
1918
- ...result.record,
1919
- profileKey: followerProfileKey,
1920
- owner:
1921
- followerOwner.kind === "manual-follower"
1922
- ? followerOwner
1923
- : {
1924
- kind: "manual-follower",
1925
- instanceId: registration.instanceId,
1926
- },
1927
- instanceId: registration.instanceId,
1928
- updatedAtMs: nowMs,
1929
- lastSyncObservedAtMs: nowMs,
1930
- lastReconcileAction: "follower-session-handoff",
1931
- });
1932
- await deps.topicTargetStore.persist();
1933
- result = {
1934
- target: transferredRecord.target,
1935
- reused: true,
1936
- record: transferredRecord,
1937
- };
1958
+ await transferSessionHandoffRecord();
1938
1959
  }
1939
1960
  connectedAnnouncement = undefined;
1940
1961
  } catch (error) {