@llblab/pi-kit 0.36.2 → 0.36.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-telegram/BACKLOG.md +27 -1
  4. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +22 -0
  5. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +16 -14
  6. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +8 -1
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +1 -1
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +6 -0
  9. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +7 -4
  10. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +22 -4
  11. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +19 -9
  12. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +1 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +4 -4
  14. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +3 -2
  15. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +1 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +4 -2
  17. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +5 -2
  18. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +1 -15
  19. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +2 -15
  20. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  21. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +15 -1
  22. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +18 -17
  23. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +11 -4
  24. package/node_modules/@llblab/pi-telegram/lib/bus.ts +8 -0
  25. package/node_modules/@llblab/pi-telegram/lib/commands.ts +34 -8
  26. package/node_modules/@llblab/pi-telegram/lib/extension.ts +21 -10
  27. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +1 -1
  28. package/node_modules/@llblab/pi-telegram/lib/menu.ts +4 -4
  29. package/node_modules/@llblab/pi-telegram/lib/routing.ts +5 -2
  30. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +5 -1
  31. package/node_modules/@llblab/pi-telegram/lib/updates.ts +2 -26
  32. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  33. package/node_modules/@llblab/pi-telegram/scripts/run-macos-tests.mjs +43 -0
  34. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.36.3: Bundled Package Updates (2026-10-24)
6
+
7
+ - `Exact pins`: `@llblab/pi-telegram` 0.57.7 → 0.57.11.
8
+ - `Packaging`: Synchronizes the lockfile, bundle aliases and README inventory. Package membership and resource order remain unchanged; packed validation does not certify extension safety or installed-client behavior.
9
+
5
10
  ## 0.36.2: Bundled Package Updates (2026-10-23)
6
11
 
7
12
  - `Exact pins`: `@llblab/pi-claude-usage` 0.2.2 → 0.2.3; `@llblab/pi-codex-usage` 0.13.1 → 0.13.2; `@llblab/pi-telegram` 0.57.3 → 0.57.7.
package/README.md CHANGED
@@ -18,7 +18,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
18
18
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.13.2` | OpenAI/legacy Codex subscription quota and Business credit status, with persistent priority Fast toggle mirrored in Telegram |
19
19
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.9.0` | Visible continuation scheduling and bounded worker Skills through compiled, manifest-owned resources |
20
20
  | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.26.5` | Scoped context/memory compiler with step-independent current memory, stable Passive/Active modes and visible blocked-state status |
21
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.57.7` | Telegram companion with per-prompt Steer, queued controls, Reroute/Restore, recoverable connections, previews, follower Threads, files, and voice |
21
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.57.11` | Telegram companion with per-prompt Steer, queued controls, Reroute/Restore, recoverable connections, previews, follower Threads, files, and voice |
22
22
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
23
23
 
24
24
  Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
@@ -2,6 +2,11 @@
2
2
 
3
3
  _This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
4
4
 
5
+ ## 0.57.11 publication checkpoint
6
+
7
+ - [ ] Validate and publish only the pre-publication follower reply-target fix through the protected dev-to-main release path. Require fresh Ubuntu, Windows and both macOS shard checks before merge, exact candidate/merged-tree equality, one immutable version tag and the repository-owned release workflow.
8
+ - [ ] Confirm the published GitHub Release and public npm version, tag/gitHead identity, release notes and packed runtime/skills before closing this checkpoint. Keep post-refusal recovery and disposable-tab live acceptance open; do not reload the running session.
9
+
5
10
  ## Disposable Thread live acceptance
6
11
 
7
12
  - [ ] After separate operator authorization, use disposable Telegram tabs to check leader Settings review/Back, one confirmed cleanup of an inactive slotless binding, preservation of its replacement letter holder, and visible outcome/recovery copy. Native filesystem and injected-transport tests do not prove live Telegram/client or native Windows acceptance. Never use production history as the disposable fixture.
@@ -17,7 +22,28 @@ The prompt-cache report remains open for the reporter's offered live Anthropic c
17
22
 
18
23
  Evidence (2026-10-23 07:11–07:16): a `/start` sent in a former leader tab moved into a follower's journal as a raw original. After that follower's Thread was re-created by the restore probe, every execution attempt failed with `Telegram bus API call is not allowed for this follower` and retried every 60 s indefinitely, because the reply targeted a Thread the follower no longer owned. Terminal settlement alone is unsafe: the same rejection can be a benign race during a live-rebind pre-dispatch.
19
24
 
20
- - [ ] Live acceptance: a forwarded command whose follower Thread was re-created is answered in the current Thread (disposable tabs only).
25
+ ### Post-refusal reply-only recovery — deferred contract gate
26
+
27
+ Remaining scope is recovery after a reply is refused or recipient authority changes during publication, not initial destination selection. The latter is specified under the [reply ownership boundary](./docs/multi-instance-bus.md#reply-recovery-ownership-boundary); its native regression covers a stale source address, not the historical refusal itself or live Telegram restoration.
28
+
29
+ A target-scoped reply refusal cannot prove that a command made no prior effects. Keep the handler-replay containment while preparing a replacement: unresolved inputs must remain in ordinary durable retry state, not be silently completed or retargeted as whole commands. Ordinary journal execution remains at-least-once, not globally exactly-once.
30
+
31
+ - [ ] Obtain explicit approval for a post-refusal recipient-delivery contract, then implement recipient-scoped reply-only recovery under the [reply ownership boundary](./docs/multi-instance-bus.md#reply-recovery-ownership-boundary), outside the containment hotfix. No ordinary recipient-scoped send grant is composed today: the local execution fence and queue-owner identity are insufficient, and receipt/admission namespaces cannot substitute for the registered recipient profile key. Ordinary sends return only a message ID, so the menu/delivery owner must participate in any destination change. Reuse selected-menu delivery only with its exact retained live-rebind/source grant; a raw journaled command cannot borrow it. Capture and recheck recipient/profile/session/registration authority at both ends, with no widening of the ordinary proxy.
32
+ - [ ] Prove the reply-only path with the real follower API caller and worker: one replacement send before any reply chunk is acknowledged, consistent callback/message ownership and destination across every chunk, no stale reply anchor, no recovery for deletion/editing/unknown or lost ACKs, and no second send after authority changes. A command effect before a refused reply must execute once within that admission attempt; original journal evidence stays unchanged and diagnostics cannot change a successful outcome. Run typecheck and the full suite at the stable boundary. If these proofs cannot be supplied, retain the input without handler re-entry.
33
+ - [ ] Live acceptance after the reply-only recovery checkpoint: a forwarded command whose follower Thread was re-created is answered in the current Thread (disposable tabs only).
34
+
35
+ ### Remaining routing investigation
36
+
21
37
  - [ ] Find why the temporary tab's selected `/start` stayed unresolved until expiry, and why that Restore moved custody without applying the rebind.
22
38
  - [ ] Route an input that arrives in a tab bound to nothing through a fresh temporary routing tab (the All flow) instead of publishing the chooser in the orphan, deleting the orphan with it once nothing unspent remains. Needs its own durable-metadata design.
23
39
  - [ ] Live acceptance on disposable tabs only: Cancel on an orphan tab deletes it; a second unspent input keeps it until the last Cancel; a plain `std::map` option button is delivered as an ordinary prompt.
40
+
41
+ ## macOS process-exit test flake
42
+
43
+ The 0.57.8 tag validation failed `Child process sharing the agent dir does not poll while parent owns Telegram lock` even when that file ran alone: `Child process did not exit. stdout= stderr=` at its existing child deadline. The other seven process tests and the identical pre-merge tree passed. That historical failure has no child-stage trace, so cold import, startup and a retained runtime handle remain competing explanations. Bounded diagnostic sampling is exhausted; passing traces showed imports up to 4.37 seconds against the five-second non-Windows child budget and natural exit within 8–14 ms after the event-loop checkpoint. These traces do not identify the original cause.
44
+ - [ ] Retain bounded child-stage/resource diagnostics for the next genuinely failing passive-child trace; the original timeout is still unexplained. Do not spend more unchanged-head diagnostic samples. The parent records explicit shutdown separately; do not add a shutdown call to the passive child to make it pass. Correct a fixture/runtime cause only when the failing stage evidence supports it, and require fresh platform CI before resuming release recovery. Do not widen deadlines or describe the runner as the proven cause without measurement.
45
+
46
+ ## Windows Workspace-gate fixture timeout
47
+
48
+ `Leader assembly serializes follower provisioning through its shared Workspace gate` once exceeded its generic one-second bus-response deadline. It sends `follower.register` directly, outside the changed API caller; production registration has a separate budget. The later native standalone witness acknowledged at about 90 ms, and the full suite passed, but the original failure has no gate/persistence trace. A synthetic delayed-write probe verifies diagnostic capture, not the native cause.
49
+ - [ ] Use the next genuinely failing native trace to distinguish gate wait, persistence and acknowledgement delay before correcting this fixture or runtime. Retain its bounded stage evidence and standalone/full-suite execution; do not spend unchanged-head reruns or infer a cause from passing checks. Any deadline-policy correction must be justified against the registration owner's contract, not a guessed larger timeout.
@@ -4,6 +4,28 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.57.11: Follower Reply Target
8
+
9
+ - `Follower command replies`: Status/model/settings and text replies from an admitted follower command select its acknowledged Thread before publication, not the old input Thread. A changed Thread drops the reply anchor; delivery, menu state and ownership use one destination. The journaled original and explicit selected-menu grants stay intact; no refused-send recovery, handler replay or proxy widening is added.
10
+
11
+ ## 0.57.10: Follower Request Identity
12
+
13
+ - `Follower request identity`: Follower API arguments use one JSON-wire snapshot before registration waits. Later caller mutation no longer changes the issued method/body or relabels a refused target and slow-call method. Ordinary and multipart calls retain wire JSON semantics and authority fences; refusal evidence still grants no resend or whole-command replay.
14
+ - `Windows CI diagnostics`: The Workspace-gate registration fixture records bounded gate and persistence stage timings, including them in failure context without paths or credentials. It also runs in the standalone Windows lifecycle probe before the full concurrent suite. Existing assertions, response deadline and job budgets are unchanged; instrumentation does not claim to solve the observed timeout.
15
+
16
+ ## 0.57.9: Reply Safety and CI Recovery
17
+
18
+ - `Recipient safety`: Ships the containment prepared for unpublished 0.57.8: a refused follower reply no longer immediately reruns its whole command handler and repeats earlier effects. The original input stays unchanged in durable retry state, without silent completion. Automatic reply relocation remains withdrawn; stale-Thread commands may still retry and ordinary execution remains at-least-once.
19
+ - `Follower refusals`: Structured bus acknowledgements identify requests blocked before Telegram API delegation, without granting resend authority. Legacy error text, mismatched methods and lost acknowledgements cannot prove non-execution. Reply chunks keep their captured destination and ownership; refused chunks acquire no message ownership.
20
+ - `CI sharding`: macOS distributes its serialized test files across two deterministic jobs to avoid the measured whole-job overrun. A checked inventory includes every file exactly once, with real-process tests isolated and the local Pi CLI on PATH. Assertions, test deadlines and the 30-minute job budget are unchanged; publication requires every platform and shard to pass.
21
+ - `Process diagnostics`: Child-exit checks report bounded lifecycle timings and active resource types without logging fixture credentials or paths or adding referenced timers. Import, startup and natural-exit stages can now distinguish future failures. The original macOS child timeout remains unexplained; passing traces do not establish its cause.
22
+
23
+ ## 0.57.8: Reply Recovery Safety
24
+
25
+ - `Recipient safety`: A refused follower reply no longer immediately re-runs the whole command handler, which could repeat changes made before the failed send. The original input stays unchanged in durable retry state, without silent completion. Automatic reply relocation is withdrawn pending transport-level safety proof; a stale-Thread command may still retry and ordinary execution remains at-least-once.
26
+ - `Follower refusals`: Structured bus acknowledgements distinguish requests blocked before Telegram API delegation from errors returned after execution. Legacy error text, mismatched methods and lost acknowledgements cannot certify non-execution or authorize a resend. Reply chunks retain their captured destination and ownership; refused chunks acquire no message ownership.
27
+ - `CI`: macOS runs real-process shutdown tests separately and the remaining test files one at a time, keeping the local Pi CLI on PATH. Assertions and deadlines are unchanged. This reduces observed runner-contention failures without claiming the process-exit flake is eliminated.
28
+
7
29
  ## 0.57.7: Routing Cleanup and Recipient Recovery
8
30
 
9
31
  - `Recipient recovery`: A forwarded input journaled for a follower whose Thread was re-created no longer retries forever against the old Thread. After the leader refuses that reply, the input re-runs once against the follower's current Thread; a second refusal or any other failure keeps the durable retry state, and nothing is dropped.
@@ -955,6 +955,11 @@ export function createTelegramBusFollowerApiCaller(deps) {
955
955
  const assertion = localOptions?.assertAuthority;
956
956
  const assertCurrent = () => assertTelegramApiCallAuthority(assertion, false);
957
957
  assertCurrent();
958
+ // Match IPC's JSON semantics once, before awaits; send and refusal metadata share this identity.
959
+ const requestArgs = JSON.parse(JSON.stringify(args));
960
+ const apiMethod = (method === "call" || method === "callMultipart") && typeof requestArgs[0] === "string"
961
+ ? requestArgs[0] : method;
962
+ timing.apiMethod = apiMethod;
958
963
  const registration = await resolveTelegramBusFollowerRegistration(deps, timeoutMs);
959
964
  timing.registeredAtMs = getNowMs();
960
965
  assertCurrent();
@@ -968,17 +973,13 @@ export function createTelegramBusFollowerApiCaller(deps) {
968
973
  instanceId: deps.instanceId,
969
974
  registrationGeneration: registration.generation,
970
975
  method,
971
- args,
976
+ args: requestArgs,
972
977
  sentAtMs: getNowMs(),
973
978
  }, assertion ? assertCurrent : undefined);
974
979
  }
975
980
  catch (error) {
976
981
  if (error instanceof TelegramBusLocalAuthorityError)
977
982
  throw new TelegramApiAuthorityError(error.requestIssued);
978
- const apiMethod = (method === "call" || method === "callMultipart") &&
979
- typeof args[0] === "string"
980
- ? args[0]
981
- : method;
982
983
  if (!isTelegramApiMethodRetrySafe(apiMethod)) {
983
984
  throw new TelegramApiCommitUnknownError(apiMethod, error);
984
985
  }
@@ -1002,15 +1003,19 @@ export function createTelegramBusFollowerApiCaller(deps) {
1002
1003
  response.error?.code === "commit-unknown") {
1003
1004
  throw new TelegramApiCommitUnknownError(response.error.method ?? method, new Error(message ?? "Telegram bus API call result is ambiguous."));
1004
1005
  }
1005
- if (message === TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE) {
1006
- const body = (method === "call" || method === "callMultipart") && isRecord(args[1])
1007
- ? args[1]
1006
+ const refusedMethod = response?.kind === "bus.ack" && !response.ok &&
1007
+ response.error?.code === "follower-api-not-allowed" &&
1008
+ response.error.method === apiMethod
1009
+ ? apiMethod : undefined;
1010
+ if (refusedMethod || message === TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE) {
1011
+ const body = (method === "call" || method === "callMultipart") && isRecord(requestArgs[1])
1012
+ ? requestArgs[1]
1008
1013
  : undefined;
1009
1014
  const chatId = Number(body?.chat_id);
1010
1015
  const threadId = Number(body?.message_thread_id);
1011
- throw new TelegramApiFollowerNotAllowedError(message, Number.isSafeInteger(chatId) && Number.isSafeInteger(threadId)
1016
+ throw new TelegramApiFollowerNotAllowedError(message ?? TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE, Number.isSafeInteger(chatId) && Number.isSafeInteger(threadId)
1012
1017
  ? { chatId, threadId }
1013
- : undefined);
1018
+ : undefined, refusedMethod);
1014
1019
  }
1015
1020
  throw new Error(message ?? "Telegram bus API call failed.");
1016
1021
  };
@@ -1028,10 +1033,7 @@ export function createTelegramBusFollowerApiCaller(deps) {
1028
1033
  finally {
1029
1034
  const elapsedMs = getNowMs() - startedAtMs;
1030
1035
  if (elapsedMs >= slowCallMs) {
1031
- const apiMethod = (method === "call" || method === "callMultipart") &&
1032
- typeof args[0] === "string"
1033
- ? args[0]
1034
- : method;
1036
+ const apiMethod = timing.apiMethod ?? method;
1035
1037
  try {
1036
1038
  deps.recordRuntimeEvent?.("bus", `Telegram bus follower API call was slow (${elapsedMs}ms).`, {
1037
1039
  phase: "follower-api-slow",
@@ -2770,7 +2770,14 @@ async function handleFollowerApiCall(envelope, deps) {
2770
2770
  method: envelope.method,
2771
2771
  args: envelope.args,
2772
2772
  })) {
2773
- return rejectTelegramBusRequest(envelope.requestId, TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE);
2773
+ const apiMethod = getFollowerApiMethodAndBody(envelope).apiMethod;
2774
+ return {
2775
+ ...rejectTelegramBusRequest(envelope.requestId, TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE),
2776
+ // Only this pre-dispatch authorizer refusal certifies that the API method was not called.
2777
+ ...(/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(apiMethod)
2778
+ ? { error: { code: "follower-api-not-allowed", method: apiMethod } }
2779
+ : {}),
2780
+ };
2774
2781
  }
2775
2782
  if (!deps.callApi) {
2776
2783
  return rejectTelegramBusRequest(envelope.requestId, "Telegram bus leader does not expose API calling.");
@@ -548,7 +548,7 @@ export type TelegramBusEnvelope = ({
548
548
  result?: unknown;
549
549
  protocol?: TelegramBusProtocolIdentity;
550
550
  error?: {
551
- code: "commit-unknown" | "request-id-collision" | "ledger-overloaded" | "incompatible-protocol" | "stale-target" | "workspace-binding-unavailable";
551
+ code: "commit-unknown" | "request-id-collision" | "ledger-overloaded" | "incompatible-protocol" | "stale-target" | "follower-api-not-allowed" | "workspace-binding-unavailable";
552
552
  method?: string;
553
553
  chatId?: number;
554
554
  threadId?: number;
@@ -2657,9 +2657,15 @@ function parseAckEnvelope(value, requestId) {
2657
2657
  code === "ledger-overloaded" ||
2658
2658
  code === "incompatible-protocol" ||
2659
2659
  code === "stale-target" ||
2660
+ code === "follower-api-not-allowed" ||
2660
2661
  code === "workspace-binding-unavailable") {
2661
2662
  const chatId = value.error.chatId;
2662
2663
  const threadId = value.error.threadId;
2664
+ if (code === "follower-api-not-allowed" &&
2665
+ (value.ok || Object.hasOwn(value, "result") ||
2666
+ typeof value.error.method !== "string" ||
2667
+ !/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(value.error.method)))
2668
+ return undefined;
2663
2669
  if (code === "stale-target" &&
2664
2670
  (!Number.isSafeInteger(chatId) || !Number.isSafeInteger(threadId))) {
2665
2671
  return undefined;
@@ -11,6 +11,7 @@ import type { TelegramPromptTemplateCommand } from "./prompt-templates.ts";
11
11
  import { createTelegramControlItemBuilder, type PendingTelegramControlItem, type TelegramControlQueueController, type TelegramControlQueueControllerDeps, type TelegramQueueAdmissionReceipt } from "./queue.ts";
12
12
  import { type TelegramBridgeStatusLineOptions } from "./status.ts";
13
13
  import { type TelegramApiCallOptions } from "./telegram-api.ts";
14
+ import type { TelegramTarget } from "./target.ts";
14
15
  import type { TelegramSessionReplacementIntent } from "./threads.ts";
15
16
  import { type TelegramDeferredSourceEvidence, type TelegramLiveSourceCompletionReadiness, type TelegramQueueAdmissionReceiptLike } from "./updates.ts";
16
17
  export type TelegramHeldCommandName = "status" | "start" | "help" | "abort" | "stop" | "next" | "continue";
@@ -383,11 +384,13 @@ export interface TelegramCommandTargetRuntimeDeps<TContext> {
383
384
  enqueueControlItem: (target: TelegramCommandMessageTarget, ctx: TContext, controlType: TelegramControlCommandType, statusSummary: string, execute: (ctx: TContext) => Promise<void>, admissionReceipts?: TelegramQueueAdmissionReceipt[], onQueued?: (item: PendingTelegramControlItem<TContext>) => void) => void;
384
385
  getAdmissionScope?: () => string | undefined;
385
386
  getAdmissionJournalBinding?: () => string | undefined;
387
+ /** Ordinary reply destination selected before publication, not resend authority. */
388
+ getReplyTarget?: (message: TelegramCommandRuntimeMessage) => TelegramTarget | undefined;
386
389
  onControlQueued?: (message: TelegramCommandRuntimeMessage, receipt: TelegramQueueAdmissionReceipt) => void;
387
- showStatus: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
388
- openModelMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
389
- openSettingsMenu?: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
390
- sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
390
+ showStatus: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
391
+ openModelMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
392
+ openSettingsMenu?: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
393
+ sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
391
394
  parseMode?: "HTML";
392
395
  target?: {
393
396
  chatId: number;
@@ -629,6 +629,7 @@ export function createTelegramCommandTargetQueueRuntime(deps) {
629
629
  createControlItem,
630
630
  enqueueControlItem: controlQueueController.enqueue,
631
631
  }),
632
+ getReplyTarget: deps.getReplyTarget,
632
633
  getAdmissionScope: deps.getAdmissionScope,
633
634
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
634
635
  onControlQueued: deps.onControlQueued,
@@ -639,6 +640,22 @@ export function createTelegramCommandTargetQueueRuntime(deps) {
639
640
  });
640
641
  }
641
642
  export function createTelegramCommandTargetRuntime(deps) {
643
+ const getReplyTarget = (message, options) => {
644
+ const source = getTelegramCommandMessageTarget(message);
645
+ const recipient = options?.assertAuthority
646
+ ? undefined
647
+ : deps.getReplyTarget?.(message);
648
+ if (!recipient)
649
+ return source;
650
+ if (recipient.chatId !== source.chatId)
651
+ throw new Error("Telegram command reply cannot change its recipient chat.");
652
+ if (source.threadId !== undefined && recipient.threadId === undefined)
653
+ throw new Error("Telegram command cannot move a Thread reply to All.");
654
+ return {
655
+ ...recipient,
656
+ replyToMessageId: recipient.threadId === source.threadId ? source.replyToMessageId : undefined,
657
+ };
658
+ };
642
659
  return {
643
660
  enqueueControlItem: (message, ctx, controlType, statusSummary, execute) => {
644
661
  const sourceUpdateId = message.pi_telegram_source_update_id;
@@ -659,15 +676,15 @@ export function createTelegramCommandTargetRuntime(deps) {
659
676
  deps.enqueueControlItem(getTelegramCommandMessageTarget(message), ctx, controlType, statusSummary, execute, receipt ? [receipt] : undefined, receipt ? () => deps.onControlQueued?.(message, receipt) : undefined);
660
677
  },
661
678
  showStatus: (message, ctx, options) => {
662
- const target = getTelegramCommandMessageTarget(message);
679
+ const target = getReplyTarget(message, options);
663
680
  return deps.showStatus(target.chatId, target.replyToMessageId, ctx, target.threadId, options);
664
681
  },
665
682
  openModelMenu: (message, ctx, options) => {
666
- const target = getTelegramCommandMessageTarget(message);
683
+ const target = getReplyTarget(message, options);
667
684
  return deps.openModelMenu(target.chatId, target.replyToMessageId, ctx, target.threadId, options);
668
685
  },
669
686
  openSettingsMenu: async (message, ctx, options) => {
670
- const target = getTelegramCommandMessageTarget(message);
687
+ const target = getReplyTarget(message, options);
671
688
  if (!deps.openSettingsMenu) {
672
689
  await deps.sendTextReply(target.chatId, target.replyToMessageId, formatTelegramInformationHeading("🚫", "Settings menu is unavailable."), { target, parseMode: "HTML" });
673
690
  return;
@@ -675,7 +692,7 @@ export function createTelegramCommandTargetRuntime(deps) {
675
692
  await deps.openSettingsMenu(target.chatId, target.replyToMessageId, ctx, target.threadId, options);
676
693
  },
677
694
  sendTextReply: async (message, text, options) => {
678
- const target = getTelegramCommandMessageTarget(message);
695
+ const target = getReplyTarget(message, options);
679
696
  const assertAuthority = options?.assertAuthority, send = deps.sendTextReply;
680
697
  const capturedOptions = { ...options, target };
681
698
  assertAuthority?.();
@@ -1061,6 +1078,7 @@ export function createTelegramCommandHandlerTargetRuntime(deps) {
1061
1078
  }),
1062
1079
  appendControlItem: deps.appendControlItem,
1063
1080
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
1081
+ getReplyTarget: deps.getReplyTarget,
1064
1082
  getAdmissionScope: deps.getAdmissionScope,
1065
1083
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
1066
1084
  onControlQueued: deps.onControlQueued,
@@ -1093,6 +1093,25 @@ export default function (pi) {
1093
1093
  },
1094
1094
  getAdmissionScope: getTelegramUpdateAdmissionScope,
1095
1095
  getAdmissionJournalBinding: getTelegramQueueJournalBinding,
1096
+ getCommandReplyTarget(message) {
1097
+ if (message.chat.type !== "private" ||
1098
+ "business_connection_id" in message ||
1099
+ !Updates.getTelegramUpdateExecutionFence(message) ||
1100
+ lockRuntime.owns())
1101
+ return undefined;
1102
+ Updates.assertTelegramUpdateExecutionCurrent(message);
1103
+ const binding = resolveTelegramFollowerJournalBinding();
1104
+ const target = telegramBusFollowerRegistrationState.getTarget();
1105
+ if (!telegramBusFollowerRegistrationState.isRegistered() ||
1106
+ !binding ||
1107
+ binding.recoveryKey !==
1108
+ followerAdmissionLifecycleRuntime.getJournalBindingKey() ||
1109
+ !target ||
1110
+ (threadStore.getBotState().threadMode === "enabled" &&
1111
+ target.threadId === undefined))
1112
+ throw new Error("Telegram command reply lost its registered recipient.");
1113
+ return { ...target };
1114
+ },
1096
1115
  beginCommandEffectWork: publicationRuntime.beginWork,
1097
1116
  getMessageOwnership: messageOwnershipRuntime.getForwardOwnership,
1098
1117
  recordMessageOwnership: messageOwnershipRuntime.recordRouted,
@@ -1400,15 +1419,6 @@ export default function (pi) {
1400
1419
  return binding.hasAuthority?.() === true;
1401
1420
  });
1402
1421
  },
1403
- recoverRejectedRecipientTarget(error) {
1404
- const rejected = error instanceof TelegramApi.TelegramApiFollowerNotAllowedError
1405
- ? error.requestTarget
1406
- : undefined;
1407
- const current = telegramBusFollowerRegistrationState.getTarget();
1408
- return rejected && current?.threadId !== undefined
1409
- ? { rejected, current: { chatId: current.chatId, threadId: current.threadId } }
1410
- : undefined;
1411
- },
1412
1422
  prepareUpdateForExecution(update) {
1413
1423
  return BusFollower.prepareTelegramBusFollowerJournaledUpdateForExecution(update, textGroupRuntime.prepareForwardedMessage);
1414
1424
  },
@@ -52,7 +52,7 @@ export interface TelegramSettingsMenuCallbackDeps extends TelegramSettingsMutati
52
52
  sectionRegistry?: TelegramSectionRegistry;
53
53
  }
54
54
  export interface TelegramSettingsMenuRuntime<TContext> {
55
- openSettingsMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
55
+ openSettingsMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
56
56
  handleCallbackQuery: (query: {
57
57
  id: string;
58
58
  data?: string;
@@ -87,7 +87,7 @@ export interface TelegramMenuActionRuntimeDeps<TContext, TModel extends MenuMode
87
87
  storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
88
88
  isIdle: (ctx: TContext) => boolean;
89
89
  canOfferInFlightModelSwitch: (ctx: TContext) => boolean;
90
- sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
90
+ sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
91
91
  target?: {
92
92
  chatId: number;
93
93
  threadId?: number;
@@ -102,9 +102,9 @@ export interface TelegramMenuActionRuntime<TContext, TModel extends MenuModel =
102
102
  updateModelMenuMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
103
103
  updateThinkingMenuMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
104
104
  updateStatusMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
105
- sendStatusMessage: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
106
- openModelMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
107
- openThinkingMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
105
+ sendStatusMessage: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
106
+ openModelMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
107
+ openThinkingMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
108
108
  }
109
109
  export declare function handleTelegramMenuCallbackEntry(callbackQueryId: string, data: string | undefined, state: TelegramModelMenuState | undefined, deps: TelegramMenuCallbackEntryDeps): Promise<void>;
110
110
  export declare function handleStoredTelegramMenuCallback<TModel extends MenuModel = MenuModel>(query: MenuCallbackQuery, deps: StoredTelegramMenuCallbackDeps<TModel>): Promise<void>;
@@ -174,6 +174,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
174
174
  getCurrentInstanceId?: () => string | undefined;
175
175
  getAdmissionScope?: () => string | undefined;
176
176
  getAdmissionJournalBinding?: () => string | undefined;
177
+ getCommandReplyTarget?: Commands.TelegramCommandTargetRuntimeDeps<TContext>["getReplyTarget"];
177
178
  getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
178
179
  getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
179
180
  recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
@@ -256,7 +257,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
256
257
  isVoiceReplyActive?: () => boolean;
257
258
  updateSettingsMenuMessage?: (state: Menu.TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
258
259
  openQueueMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
259
- openSettingsMenu?: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
260
+ openSettingsMenu?: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
260
261
  settingsMenuCallbackHandler?: (query: TCallbackQuery, ctx: TContext) => Promise<boolean>;
261
262
  queueMenuCallbackHandler: (query: TCallbackQuery, ctx: TContext) => Promise<boolean>;
262
263
  buttonActionStore?: OutboundHandlers.TelegramButtonActionStore;
@@ -303,7 +304,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
303
304
  }) => Promise<string | undefined>;
304
305
  /** Starts the animated placeholder on an answered guest inline message. */
305
306
  startGuestPlaceholder?: (inlineMessageId: string) => void;
306
- sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
307
+ sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
307
308
  parseMode?: "HTML";
308
309
  target?: Queue.TelegramQueueTarget;
309
310
  assertAuthority?: TelegramApiCallOptions["assertAuthority"];
@@ -7139,6 +7139,7 @@ export function createTelegramInboundRouteRuntime(deps) {
7139
7139
  appendControlItem: deps.queueMutationRuntime.append,
7140
7140
  getAdmissionScope: deps.getAdmissionScope,
7141
7141
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
7142
+ getReplyTarget: deps.getCommandReplyTarget,
7142
7143
  onControlQueued: (message, receipt) => reportQueueAdmission([message], [receipt]),
7143
7144
  showStatus: deps.menuActions.sendStatusMessage,
7144
7145
  openModelMenu: deps.menuActions.openModelMenu,
@@ -505,16 +505,18 @@ export declare class TelegramApiStaleTargetError extends Error {
505
505
  threadId: number;
506
506
  });
507
507
  }
508
- /** The leader's exact refusal of a follower API call outside that follower's own Thread; carries only the requested target. */
508
+ /** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
509
509
  export declare class TelegramApiFollowerNotAllowedError extends Error {
510
510
  readonly requestTarget: {
511
511
  chatId: number;
512
512
  threadId: number;
513
513
  } | undefined;
514
+ /** Present only after a structured pre-execution refusal matches the requested API method. */
515
+ readonly rejectedRequestMethod: string | undefined;
514
516
  constructor(message: string, requestTarget?: {
515
517
  chatId: number;
516
518
  threadId: number;
517
- });
519
+ }, rejectedRequestMethod?: string);
518
520
  }
519
521
  export declare function getTelegramApiErrorRequestTarget(error: unknown): {
520
522
  chatId: number;
@@ -338,13 +338,16 @@ export class TelegramApiStaleTargetError extends Error {
338
338
  this.requestTarget = { ...requestTarget };
339
339
  }
340
340
  }
341
- /** The leader's exact refusal of a follower API call outside that follower's own Thread; carries only the requested target. */
341
+ /** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
342
342
  export class TelegramApiFollowerNotAllowedError extends Error {
343
343
  requestTarget;
344
- constructor(message, requestTarget) {
344
+ /** Present only after a structured pre-execution refusal matches the requested API method. */
345
+ rejectedRequestMethod;
346
+ constructor(message, requestTarget, rejectedRequestMethod) {
345
347
  super(message);
346
348
  this.name = "TelegramApiFollowerNotAllowedError";
347
349
  this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
350
+ this.rejectedRequestMethod = rejectedRequestMethod;
348
351
  }
349
352
  }
350
353
  export function getTelegramApiErrorRequestTarget(error) {
@@ -1487,7 +1487,7 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate extends Te
1487
1487
  runtimeBinding: TelegramUpdateAdmissionRuntimeBinding<TContext>;
1488
1488
  inputCustodyBus?: TelegramInputCustodyBusBindingRuntime<TContext>;
1489
1489
  acquireSourceReference?: (role: "leader" | "follower", binding: TelegramUpdateAdmissionLifecycleJournalBinding) => () => void;
1490
- worker: Omit<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>, "journal" | "getJournalBindingKey" | "getRecipientBindingKey" | "hasAuthority" | "prepareUpdateForExecution" | "recoverRejectedRecipientTarget">;
1490
+ worker: Omit<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>, "journal" | "getJournalBindingKey" | "getRecipientBindingKey" | "hasAuthority" | "prepareUpdateForExecution">;
1491
1491
  leader: {
1492
1492
  resolveBinding: () => TelegramUpdateAdmissionLifecycleJournalBinding | undefined;
1493
1493
  hasAuthority: (ctx: TContext) => boolean;
@@ -1497,7 +1497,6 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate extends Te
1497
1497
  isRegistered: () => boolean;
1498
1498
  getGeneration: () => string | undefined;
1499
1499
  prepareUpdateForExecution: (update: TUpdate) => TUpdate;
1500
- recoverRejectedRecipientTarget?: NonNullable<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>["recoverRejectedRecipientTarget"]>;
1501
1500
  /** Session succession before worker creation, under the exact generation-fenced binding. */
1502
1501
  prepareBinding?: (binding: TelegramUpdateAdmissionLifecycleJournalBinding & {
1503
1502
  hasAuthority?: () => boolean;
@@ -1509,19 +1508,6 @@ export type TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate extends TelegramJou
1509
1508
  inputCustody?: TelegramCustodyExecutionJournal;
1510
1509
  defaultHandle: (update: TUpdate, ctx: TContext, execution?: TelegramUpdateExecutionFence) => Promise<void>;
1511
1510
  prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
1512
- /**
1513
- * Recipient-only recovery for a journaled message whose own Thread changed (for example after its Thread was re-created):
1514
- * classifies a leader refusal of a reply to the original Thread and returns that Thread plus the owner's current one.
1515
- */
1516
- recoverRejectedRecipientTarget?: (error: unknown) => {
1517
- rejected: {
1518
- chatId: number;
1519
- threadId: number;
1520
- };
1521
- current: TelegramTarget & {
1522
- threadId: number;
1523
- };
1524
- } | undefined;
1525
1511
  registry?: TelegramUpdateHandlerRegistry;
1526
1512
  };
1527
1513
  /** Compose source-bound routing and late grouped settlement under one worker. */
@@ -6359,7 +6359,6 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
6359
6359
  const typedUpdate = update;
6360
6360
  const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
6361
6361
  // Journal/source evidence remains the original; only the warm released handler projection changes destination.
6362
- // Journal/source evidence remains the original; only the warm released handler projection changes destination.
6363
6362
  const projectTo = (destination) => ({ ...prepared,
6364
6363
  ...(prepared.message ? { message: prepared.message.message_thread_id === destination.threadId ? { ...prepared.message }
6365
6364
  : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
@@ -6374,17 +6373,8 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
6374
6373
  if (await deps.settleTerminalExecutionFailure?.(error)) {
6375
6374
  return { kind: "complete" };
6376
6375
  }
6377
- // The leader refused a reply to a Thread this recipient no longer owns (re-created after the input was journaled).
6378
- // Re-run once against its current Thread; any other failure, or a second refusal, keeps the ordinary retry state.
6379
- const recovery = !target ? deps.recoverRejectedRecipientTarget?.(error) : undefined;
6380
- const message = prepared.message ?? prepared.edited_message;
6381
- if (recovery && message && message.message_thread_id === recovery.rejected.threadId &&
6382
- message.chat?.id === recovery.rejected.chatId && recovery.current.chatId === recovery.rejected.chatId &&
6383
- recovery.current.threadId !== recovery.rejected.threadId) {
6384
- deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "recipient-target-recovery", updateId: update.update_id,
6385
- chatId: recovery.current.chatId, staleThreadId: recovery.rejected.threadId, threadId: recovery.current.threadId });
6386
- return await executeUpdate(projectTo(recovery.current), ctx, signal);
6387
- }
6376
+ // A refused reply proves only that send was not issued, not that the handler made no prior effects.
6377
+ // Leave failure settlement to the worker's retry policy; never re-enter the handler to recover delivery here.
6388
6378
  throw error;
6389
6379
  }
6390
6380
  },
@@ -6457,9 +6447,6 @@ export function createTelegramUpdateAdmissionLifecycleAssembly(deps) {
6457
6447
  spendHistoricalInput: undefined,
6458
6448
  onHeldSourcesPrepared: undefined,
6459
6449
  prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
6460
- ...(deps.follower.recoverRejectedRecipientTarget
6461
- ? { recoverRejectedRecipientTarget: deps.follower.recoverRejectedRecipientTarget }
6462
- : {}),
6463
6450
  });
6464
6451
  },
6465
6452
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.57.7",
3
+ "version": "0.57.11",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -673,12 +673,26 @@ This inspection authorizes no reload/restart, live-state mutation, fault injecti
673
673
  ## Security Boundaries
674
674
 
675
675
  - Messages, edits, callbacks, and reactions check user authorization, not only chat/thread membership.
676
- - Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary. A reply refused because it targets a Thread other than the follower's own surfaces as the typed `TelegramApiFollowerNotAllowedError` carrying only the requested target. A journaled recipient input whose Thread was since re-created re-runs exactly once against the follower's current Thread (reply anchor cleared, diagnostic `recipient-target-recovery`); a second refusal or any other failure keeps the ordinary durable `retry-wait`.
676
+ - Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary. Follower API authorization refusal surfaces as `TelegramApiFollowerNotAllowedError` with the requested target when available. Only the leader's structured `follower-api-not-allowed` negative acknowledgement, emitted before API delegation and matched to the requested method, populates `rejectedRequestMethod`. Legacy error text, identical text thrown after delegation, a mismatched method or a lost acknowledgement supplies no such proof; old peers continue to receive the same human-readable rejection. The structured refusal proves only that the rejected API request was not executed, not that the command had no earlier effects. Admission therefore never immediately re-enters the handler or retargets its source to recover a reply. The original input remains in durable `retry-wait`; ordinary retries retain their documented at-least-once semantics. Safe reply-request recovery for re-created recipient Threads remains open in the backlog.
677
677
  - Follower Bot API proxying is allowlisted and target-scoped where applicable. Ordinary sends remain confined to the follower's assigned thread; trusted runtime-marked `telegram_message` cross-target sends may address only a different thread inside the same paired chat, and the leader strips the internal marker before calling Telegram. This preserves requested inter-thread delivery without granting arbitrary bot or cross-chat control.
678
678
  - Button and section callbacks verify authorized `from.id` and owning target/instance.
679
679
  - Generated artifacts stay scoped to the owning thread after leader failover.
680
680
  - Diagnostics redact bot tokens, large prompts, attachment paths, and handler output.
681
681
 
682
+ ### Reply Recovery Ownership Boundary
683
+
684
+ A destination belongs to the complete delivery operation, not just the outgoing Bot API body. `replies.sendTelegramChunkSequence` captures one target for all chunks and records message ownership against it. Menu state independently retains `chatId`, `threadId` and the returned message ID through `menu-model.createTelegramMenuDelivery`. Its numeric send result cannot report a relocated Thread to the menu owner. Rewriting only a refused send's `message_thread_id` would therefore separate actual delivery from ownership and callback state; moving only later chunks would also split one reply across tabs.
685
+
686
+ Follower API arguments use one private JSON-wire snapshot made before registration waits. Issued method/body, refusal target/method and slow-call diagnostics derive from that same snapshot; later caller mutations cannot relabel the attempt. Caller serialization runs once, with local authority checked before capture and again before issuance.
687
+
688
+ Admission and recipient identity are separate. The local update execution fence carries its admission generation, update ID and abort signal; the queue-owner snapshot carries instance, process/birth and session-generation identity. Neither contains a complete recipient/session/profile/registration/target grant. Receipt and Workspace-admission namespaces are not the follower's registered profile key: selected-menu composition supplies that registered key separately, while the leader independently captures its active Workspace scope. No ordinary recipient-scoped reply-delivery lease joins these views today.
689
+
690
+ Pre-publication destination selection is separate from refused-send recovery. For an executing private-DM follower journal, the status/model/settings and text-reply target adapters select the acknowledged recipient target while the active profile/session journal binding still matches the worker. The execution fence checks admission currency; it does not grant delivery. The selected target is passed to the menu owner before state construction and to the existing reply sender before chunking, with the old reply anchor removed on a Thread change. The original input and its journal are not rewritten. A different chat, a Thread-to-All fallback or missing recipient binding refuses selection; explicit selected-menu authority keeps its original fixed destination. The ordinary leader-side API authorization remains unchanged. This prevents a stale source address from choosing the initial reply destination; it neither relocates an issued request nor supplies the missing reply-recovery lease.
691
+
692
+ Structured refusal is evidence about one API attempt, not a delivery or command recovery grant. Current delivery propagates that refusal without a replacement send, without editing the target or reply anchor, and without replaying acknowledged chunks. Already acknowledged messages keep their original ownership; the refused message acquires none. This applies to interactive HTML menus, split native Markdown and single Rich Message sections.
693
+
694
+ Guarded follower delivery must use a recipient-scoped transport that enforces authority at the leader, not serialize a local callback through the ordinary proxy. The existing selected-menu channel is bound to an exact live-rebind operation, source cohort and recipient; a raw journaled command with no matching retained operation cannot borrow that grant. Any future reply-only recovery must establish its own exact authority, keep the destination consistent in delivery and menu ownership, clear stale reply metadata, and refuse relocation after partial publication or an unknown outcome. Handler-replay containment stays enabled until that boundary is proven; this is not an automatic recovery feature.
695
+
682
696
  ## Acceptance Criteria
683
697
 
684
698
  - [x] The lock semantics are redesigned as Telegram bus leadership with liveness proof and stale takeover rules.
@@ -1919,11 +1919,16 @@ export function createTelegramBusFollowerApiCaller(
1919
1919
  method: string,
1920
1920
  args: unknown[],
1921
1921
  localOptions: Pick<TelegramApiCallOptions, "assertAuthority"> | undefined,
1922
- timing: { registeredAtMs?: number },
1922
+ timing: { registeredAtMs?: number; apiMethod?: string },
1923
1923
  ): Promise<unknown> => {
1924
1924
  const assertion = localOptions?.assertAuthority;
1925
1925
  const assertCurrent = () => assertTelegramApiCallAuthority(assertion, false);
1926
1926
  assertCurrent();
1927
+ // Match IPC's JSON semantics once, before awaits; send and refusal metadata share this identity.
1928
+ const requestArgs = JSON.parse(JSON.stringify(args)) as unknown[];
1929
+ const apiMethod = (method === "call" || method === "callMultipart") && typeof requestArgs[0] === "string"
1930
+ ? requestArgs[0] : method;
1931
+ timing.apiMethod = apiMethod;
1927
1932
  const registration = await resolveTelegramBusFollowerRegistration(
1928
1933
  deps,
1929
1934
  timeoutMs,
@@ -1943,18 +1948,13 @@ export function createTelegramBusFollowerApiCaller(
1943
1948
  instanceId: deps.instanceId,
1944
1949
  registrationGeneration: registration.generation,
1945
1950
  method,
1946
- args,
1951
+ args: requestArgs,
1947
1952
  sentAtMs: getNowMs(),
1948
1953
  },
1949
1954
  assertion ? assertCurrent : undefined,
1950
1955
  );
1951
1956
  } catch (error) {
1952
1957
  if (error instanceof TelegramBusLocalAuthorityError) throw new TelegramApiAuthorityError(error.requestIssued);
1953
- const apiMethod =
1954
- (method === "call" || method === "callMultipart") &&
1955
- typeof args[0] === "string"
1956
- ? args[0]
1957
- : method;
1958
1958
  if (!isTelegramApiMethodRetrySafe(apiMethod)) {
1959
1959
  throw new TelegramApiCommitUnknownError(apiMethod, error);
1960
1960
  }
@@ -1988,25 +1988,30 @@ export function createTelegramBusFollowerApiCaller(
1988
1988
  new Error(message ?? "Telegram bus API call result is ambiguous."),
1989
1989
  );
1990
1990
  }
1991
- if (message === TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE) {
1991
+ const refusedMethod = response?.kind === "bus.ack" && !response.ok &&
1992
+ response.error?.code === "follower-api-not-allowed" &&
1993
+ response.error.method === apiMethod
1994
+ ? apiMethod : undefined;
1995
+ if (refusedMethod || message === TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE) {
1992
1996
  const body =
1993
- (method === "call" || method === "callMultipart") && isRecord(args[1])
1994
- ? args[1]
1997
+ (method === "call" || method === "callMultipart") && isRecord(requestArgs[1])
1998
+ ? requestArgs[1]
1995
1999
  : undefined;
1996
2000
  const chatId = Number(body?.chat_id);
1997
2001
  const threadId = Number(body?.message_thread_id);
1998
2002
  throw new TelegramApiFollowerNotAllowedError(
1999
- message,
2003
+ message ?? TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
2000
2004
  Number.isSafeInteger(chatId) && Number.isSafeInteger(threadId)
2001
2005
  ? { chatId, threadId }
2002
2006
  : undefined,
2007
+ refusedMethod,
2003
2008
  );
2004
2009
  }
2005
2010
  throw new Error(message ?? "Telegram bus API call failed.");
2006
2011
  };
2007
2012
  return async (method, args, localOptions) => {
2008
2013
  const startedAtMs = getNowMs();
2009
- const timing: { registeredAtMs?: number } = {};
2014
+ const timing: { registeredAtMs?: number; apiMethod?: string } = {};
2010
2015
  let outcome = "ok";
2011
2016
  try {
2012
2017
  return await caller(method, args, localOptions, timing);
@@ -2016,11 +2021,7 @@ export function createTelegramBusFollowerApiCaller(
2016
2021
  } finally {
2017
2022
  const elapsedMs = getNowMs() - startedAtMs;
2018
2023
  if (elapsedMs >= slowCallMs) {
2019
- const apiMethod =
2020
- (method === "call" || method === "callMultipart") &&
2021
- typeof args[0] === "string"
2022
- ? args[0]
2023
- : method;
2024
+ const apiMethod = timing.apiMethod ?? method;
2024
2025
  try {
2025
2026
  deps.recordRuntimeEvent?.(
2026
2027
  "bus",
@@ -4189,10 +4189,17 @@ async function handleFollowerApiCall(
4189
4189
  args: envelope.args,
4190
4190
  })
4191
4191
  ) {
4192
- return rejectTelegramBusRequest(
4193
- envelope.requestId,
4194
- TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
4195
- );
4192
+ const apiMethod = getFollowerApiMethodAndBody(envelope).apiMethod;
4193
+ return {
4194
+ ...rejectTelegramBusRequest(
4195
+ envelope.requestId,
4196
+ TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
4197
+ ),
4198
+ // Only this pre-dispatch authorizer refusal certifies that the API method was not called.
4199
+ ...(/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(apiMethod)
4200
+ ? { error: { code: "follower-api-not-allowed" as const, method: apiMethod } }
4201
+ : {}),
4202
+ };
4196
4203
  }
4197
4204
  if (!deps.callApi) {
4198
4205
  return rejectTelegramBusRequest(
@@ -1260,6 +1260,7 @@ export type TelegramBusEnvelope = (
1260
1260
  | "ledger-overloaded"
1261
1261
  | "incompatible-protocol"
1262
1262
  | "stale-target"
1263
+ | "follower-api-not-allowed"
1263
1264
  | "workspace-binding-unavailable";
1264
1265
  method?: string;
1265
1266
  chatId?: number;
@@ -4148,10 +4149,17 @@ function parseAckEnvelope(
4148
4149
  code === "ledger-overloaded" ||
4149
4150
  code === "incompatible-protocol" ||
4150
4151
  code === "stale-target" ||
4152
+ code === "follower-api-not-allowed" ||
4151
4153
  code === "workspace-binding-unavailable"
4152
4154
  ) {
4153
4155
  const chatId = value.error.chatId;
4154
4156
  const threadId = value.error.threadId;
4157
+ if (
4158
+ code === "follower-api-not-allowed" &&
4159
+ (value.ok || Object.hasOwn(value, "result") ||
4160
+ typeof value.error.method !== "string" ||
4161
+ !/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(value.error.method))
4162
+ ) return undefined;
4155
4163
  if (
4156
4164
  code === "stale-target" &&
4157
4165
  (!Number.isSafeInteger(chatId) || !Number.isSafeInteger(threadId))
@@ -36,6 +36,7 @@ import {
36
36
  TelegramApiAuthorityError,
37
37
  type TelegramApiCallOptions,
38
38
  } from "./telegram-api.ts";
39
+ import type { TelegramTarget } from "./target.ts";
39
40
  import type { TelegramSessionReplacementIntent } from "./threads.ts";
40
41
  import {
41
42
  acquireTelegramUpdateRouting,
@@ -1194,34 +1195,38 @@ export interface TelegramCommandTargetRuntimeDeps<TContext> {
1194
1195
  ) => void;
1195
1196
  getAdmissionScope?: () => string | undefined;
1196
1197
  getAdmissionJournalBinding?: () => string | undefined;
1198
+ /** Ordinary reply destination selected before publication, not resend authority. */
1199
+ getReplyTarget?: (
1200
+ message: TelegramCommandRuntimeMessage,
1201
+ ) => TelegramTarget | undefined;
1197
1202
  onControlQueued?: (
1198
1203
  message: TelegramCommandRuntimeMessage,
1199
1204
  receipt: TelegramQueueAdmissionReceipt,
1200
1205
  ) => void;
1201
1206
  showStatus: (
1202
1207
  chatId: number,
1203
- replyToMessageId: number,
1208
+ replyToMessageId: number | undefined,
1204
1209
  ctx: TContext,
1205
1210
  threadId?: number,
1206
1211
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
1207
1212
  ) => Promise<void>;
1208
1213
  openModelMenu: (
1209
1214
  chatId: number,
1210
- replyToMessageId: number,
1215
+ replyToMessageId: number | undefined,
1211
1216
  ctx: TContext,
1212
1217
  threadId?: number,
1213
1218
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
1214
1219
  ) => Promise<void>;
1215
1220
  openSettingsMenu?: (
1216
1221
  chatId: number,
1217
- replyToMessageId: number,
1222
+ replyToMessageId: number | undefined,
1218
1223
  ctx: TContext,
1219
1224
  threadId?: number,
1220
1225
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
1221
1226
  ) => Promise<void>;
1222
1227
  sendTextReply: (
1223
1228
  chatId: number,
1224
- replyToMessageId: number,
1229
+ replyToMessageId: number | undefined,
1225
1230
  text: string,
1226
1231
  options?: {
1227
1232
  parseMode?: "HTML";
@@ -1336,6 +1341,7 @@ export function createTelegramCommandTargetQueueRuntime<
1336
1341
  createControlItem,
1337
1342
  enqueueControlItem: controlQueueController.enqueue,
1338
1343
  }),
1344
+ getReplyTarget: deps.getReplyTarget,
1339
1345
  getAdmissionScope: deps.getAdmissionScope,
1340
1346
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
1341
1347
  onControlQueued: deps.onControlQueued,
@@ -1352,6 +1358,25 @@ export function createTelegramCommandTargetRuntime<
1352
1358
  >(
1353
1359
  deps: TelegramCommandTargetRuntimeDeps<TContext>,
1354
1360
  ): TelegramCommandTargetRuntime<TMessage, TContext> {
1361
+ const getReplyTarget = (
1362
+ message: TMessage,
1363
+ options?: Pick<TelegramApiCallOptions, "assertAuthority">,
1364
+ ) => {
1365
+ const source = getTelegramCommandMessageTarget(message);
1366
+ const recipient = options?.assertAuthority
1367
+ ? undefined
1368
+ : deps.getReplyTarget?.(message);
1369
+ if (!recipient) return source;
1370
+ if (recipient.chatId !== source.chatId)
1371
+ throw new Error("Telegram command reply cannot change its recipient chat.");
1372
+ if (source.threadId !== undefined && recipient.threadId === undefined)
1373
+ throw new Error("Telegram command cannot move a Thread reply to All.");
1374
+ return {
1375
+ ...recipient,
1376
+ replyToMessageId:
1377
+ recipient.threadId === source.threadId ? source.replyToMessageId : undefined,
1378
+ };
1379
+ };
1355
1380
  return {
1356
1381
  enqueueControlItem: (message, ctx, controlType, statusSummary, execute) => {
1357
1382
  const sourceUpdateId = message.pi_telegram_source_update_id;
@@ -1381,7 +1406,7 @@ export function createTelegramCommandTargetRuntime<
1381
1406
  );
1382
1407
  },
1383
1408
  showStatus: (message, ctx, options) => {
1384
- const target = getTelegramCommandMessageTarget(message);
1409
+ const target = getReplyTarget(message, options);
1385
1410
  return deps.showStatus(
1386
1411
  target.chatId,
1387
1412
  target.replyToMessageId,
@@ -1391,7 +1416,7 @@ export function createTelegramCommandTargetRuntime<
1391
1416
  );
1392
1417
  },
1393
1418
  openModelMenu: (message, ctx, options) => {
1394
- const target = getTelegramCommandMessageTarget(message);
1419
+ const target = getReplyTarget(message, options);
1395
1420
  return deps.openModelMenu(
1396
1421
  target.chatId,
1397
1422
  target.replyToMessageId,
@@ -1401,7 +1426,7 @@ export function createTelegramCommandTargetRuntime<
1401
1426
  );
1402
1427
  },
1403
1428
  openSettingsMenu: async (message, ctx, options) => {
1404
- const target = getTelegramCommandMessageTarget(message);
1429
+ const target = getReplyTarget(message, options);
1405
1430
  if (!deps.openSettingsMenu) {
1406
1431
  await deps.sendTextReply(
1407
1432
  target.chatId,
@@ -1423,7 +1448,7 @@ export function createTelegramCommandTargetRuntime<
1423
1448
  );
1424
1449
  },
1425
1450
  sendTextReply: async (message, text, options) => {
1426
- const target = getTelegramCommandMessageTarget(message);
1451
+ const target = getReplyTarget(message, options);
1427
1452
  const assertAuthority = options?.assertAuthority,
1428
1453
  send = deps.sendTextReply;
1429
1454
  const capturedOptions = { ...options, target };
@@ -2217,6 +2242,7 @@ export function createTelegramCommandHandlerTargetRuntime<
2217
2242
  }),
2218
2243
  appendControlItem: deps.appendControlItem,
2219
2244
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
2245
+ getReplyTarget: deps.getReplyTarget,
2220
2246
  getAdmissionScope: deps.getAdmissionScope,
2221
2247
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
2222
2248
  onControlQueued: deps.onControlQueued,
@@ -1364,6 +1364,27 @@ export default function (pi: Pi.ExtensionAPI) {
1364
1364
  },
1365
1365
  getAdmissionScope: getTelegramUpdateAdmissionScope,
1366
1366
  getAdmissionJournalBinding: getTelegramQueueJournalBinding,
1367
+ getCommandReplyTarget(message) {
1368
+ if (
1369
+ message.chat.type !== "private" ||
1370
+ "business_connection_id" in message ||
1371
+ !Updates.getTelegramUpdateExecutionFence(message) ||
1372
+ lockRuntime.owns()
1373
+ ) return undefined;
1374
+ Updates.assertTelegramUpdateExecutionCurrent(message);
1375
+ const binding = resolveTelegramFollowerJournalBinding();
1376
+ const target = telegramBusFollowerRegistrationState.getTarget();
1377
+ if (
1378
+ !telegramBusFollowerRegistrationState.isRegistered() ||
1379
+ !binding ||
1380
+ binding.recoveryKey !==
1381
+ followerAdmissionLifecycleRuntime.getJournalBindingKey() ||
1382
+ !target ||
1383
+ (threadStore.getBotState().threadMode === "enabled" &&
1384
+ target.threadId === undefined)
1385
+ ) throw new Error("Telegram command reply lost its registered recipient.");
1386
+ return { ...target };
1387
+ },
1367
1388
  beginCommandEffectWork: publicationRuntime.beginWork,
1368
1389
  getMessageOwnership: messageOwnershipRuntime.getForwardOwnership,
1369
1390
  recordMessageOwnership: messageOwnershipRuntime.recordRouted,
@@ -1763,16 +1784,6 @@ export default function (pi: Pi.ExtensionAPI) {
1763
1784
  },
1764
1785
  );
1765
1786
  },
1766
- recoverRejectedRecipientTarget(error) {
1767
- const rejected =
1768
- error instanceof TelegramApi.TelegramApiFollowerNotAllowedError
1769
- ? error.requestTarget
1770
- : undefined;
1771
- const current = telegramBusFollowerRegistrationState.getTarget();
1772
- return rejected && current?.threadId !== undefined
1773
- ? { rejected, current: { chatId: current.chatId, threadId: current.threadId } }
1774
- : undefined;
1775
- },
1776
1787
  prepareUpdateForExecution(update) {
1777
1788
  return BusFollower.prepareTelegramBusFollowerJournaledUpdateForExecution(
1778
1789
  update,
@@ -90,7 +90,7 @@ export interface TelegramSettingsMenuCallbackDeps extends TelegramSettingsMutati
90
90
  export interface TelegramSettingsMenuRuntime<TContext> {
91
91
  openSettingsMenu: (
92
92
  chatId: number,
93
- replyToMessageId: number,
93
+ replyToMessageId: number | undefined,
94
94
  ctx: TContext,
95
95
  threadId?: number,
96
96
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
@@ -264,7 +264,7 @@ export interface TelegramMenuActionRuntimeDeps<
264
264
  canOfferInFlightModelSwitch: (ctx: TContext) => boolean;
265
265
  sendTextReply: (
266
266
  chatId: number,
267
- replyToMessageId: number,
267
+ replyToMessageId: number | undefined,
268
268
  text: string,
269
269
  options?: {
270
270
  target?: { chatId: number; threadId?: number };
@@ -294,21 +294,21 @@ export interface TelegramMenuActionRuntime<
294
294
  ) => Promise<void>;
295
295
  sendStatusMessage: (
296
296
  chatId: number,
297
- replyToMessageId: number,
297
+ replyToMessageId: number | undefined,
298
298
  ctx: TContext,
299
299
  threadId?: number,
300
300
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
301
301
  ) => Promise<void>;
302
302
  openModelMenu: (
303
303
  chatId: number,
304
- replyToMessageId: number,
304
+ replyToMessageId: number | undefined,
305
305
  ctx: TContext,
306
306
  threadId?: number,
307
307
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
308
308
  ) => Promise<void>;
309
309
  openThinkingMenu: (
310
310
  chatId: number,
311
- replyToMessageId: number,
311
+ replyToMessageId: number | undefined,
312
312
  ctx: TContext,
313
313
  threadId?: number,
314
314
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
@@ -1499,6 +1499,8 @@ export interface TelegramInboundRouteRuntimeDeps<
1499
1499
  getCurrentInstanceId?: () => string | undefined;
1500
1500
  getAdmissionScope?: () => string | undefined;
1501
1501
  getAdmissionJournalBinding?: () => string | undefined;
1502
+ getCommandReplyTarget?:
1503
+ Commands.TelegramCommandTargetRuntimeDeps<TContext>["getReplyTarget"];
1502
1504
  getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
1503
1505
  getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
1504
1506
  recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
@@ -1640,7 +1642,7 @@ export interface TelegramInboundRouteRuntimeDeps<
1640
1642
  ) => Promise<void>;
1641
1643
  openSettingsMenu?: (
1642
1644
  chatId: number,
1643
- replyToMessageId: number,
1645
+ replyToMessageId: number | undefined,
1644
1646
  ctx: TContext,
1645
1647
  threadId?: number,
1646
1648
  options?: Pick<TelegramApiCallOptions, "assertAuthority">,
@@ -1728,7 +1730,7 @@ export interface TelegramInboundRouteRuntimeDeps<
1728
1730
  startGuestPlaceholder?: (inlineMessageId: string) => void;
1729
1731
  sendTextReply: (
1730
1732
  chatId: number,
1731
- replyToMessageId: number,
1733
+ replyToMessageId: number | undefined,
1732
1734
  text: string,
1733
1735
  options?: {
1734
1736
  parseMode?: "HTML";
@@ -10181,6 +10183,7 @@ export function createTelegramInboundRouteRuntime<
10181
10183
  appendControlItem: deps.queueMutationRuntime.append,
10182
10184
  getAdmissionScope: deps.getAdmissionScope,
10183
10185
  getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
10186
+ getReplyTarget: deps.getCommandReplyTarget,
10184
10187
  onControlQueued: (message, receipt) =>
10185
10188
  reportQueueAdmission([message], [receipt]),
10186
10189
  showStatus: deps.menuActions.sendStatusMessage,
@@ -1049,17 +1049,21 @@ export class TelegramApiStaleTargetError extends Error {
1049
1049
  }
1050
1050
  }
1051
1051
 
1052
- /** The leader's exact refusal of a follower API call outside that follower's own Thread; carries only the requested target. */
1052
+ /** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
1053
1053
  export class TelegramApiFollowerNotAllowedError extends Error {
1054
1054
  readonly requestTarget: { chatId: number; threadId: number } | undefined;
1055
+ /** Present only after a structured pre-execution refusal matches the requested API method. */
1056
+ readonly rejectedRequestMethod: string | undefined;
1055
1057
 
1056
1058
  constructor(
1057
1059
  message: string,
1058
1060
  requestTarget?: { chatId: number; threadId: number },
1061
+ rejectedRequestMethod?: string,
1059
1062
  ) {
1060
1063
  super(message);
1061
1064
  this.name = "TelegramApiFollowerNotAllowedError";
1062
1065
  this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
1066
+ this.rejectedRequestMethod = rejectedRequestMethod;
1063
1067
  }
1064
1068
  }
1065
1069
 
@@ -10495,7 +10495,6 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
10495
10495
  | "getRecipientBindingKey"
10496
10496
  | "hasAuthority"
10497
10497
  | "prepareUpdateForExecution"
10498
- | "recoverRejectedRecipientTarget"
10499
10498
  >;
10500
10499
  leader: {
10501
10500
  resolveBinding: () =>
@@ -10508,9 +10507,6 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
10508
10507
  isRegistered: () => boolean;
10509
10508
  getGeneration: () => string | undefined;
10510
10509
  prepareUpdateForExecution: (update: TUpdate) => TUpdate;
10511
- recoverRejectedRecipientTarget?: NonNullable<
10512
- TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>["recoverRejectedRecipientTarget"]
10513
- >;
10514
10510
  /** Session succession before worker creation, under the exact generation-fenced binding. */
10515
10511
  prepareBinding?: (
10516
10512
  binding: TelegramUpdateAdmissionLifecycleJournalBinding & {
@@ -10535,13 +10531,6 @@ export type TelegramUpdateAdmissionWorkerRuntimeDeps<
10535
10531
  execution?: TelegramUpdateExecutionFence,
10536
10532
  ) => Promise<void>;
10537
10533
  prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
10538
- /**
10539
- * Recipient-only recovery for a journaled message whose own Thread changed (for example after its Thread was re-created):
10540
- * classifies a leader refusal of a reply to the original Thread and returns that Thread plus the owner's current one.
10541
- */
10542
- recoverRejectedRecipientTarget?: (error: unknown) =>
10543
- | { rejected: { chatId: number; threadId: number }; current: TelegramTarget & { threadId: number } }
10544
- | undefined;
10545
10534
  registry?: TelegramUpdateHandlerRegistry;
10546
10535
  };
10547
10536
 
@@ -10668,7 +10657,6 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
10668
10657
  const typedUpdate = update as TUpdate;
10669
10658
  const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
10670
10659
  // Journal/source evidence remains the original; only the warm released handler projection changes destination.
10671
- // Journal/source evidence remains the original; only the warm released handler projection changes destination.
10672
10660
  const projectTo = (destination: TelegramTarget & { threadId: number }): TUpdate => ({ ...prepared,
10673
10661
  ...(prepared.message ? { message: prepared.message.message_thread_id === destination.threadId ? { ...prepared.message }
10674
10662
  : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
@@ -10686,17 +10674,8 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
10686
10674
  if (await deps.settleTerminalExecutionFailure?.(error)) {
10687
10675
  return { kind: "complete" };
10688
10676
  }
10689
- // The leader refused a reply to a Thread this recipient no longer owns (re-created after the input was journaled).
10690
- // Re-run once against its current Thread; any other failure, or a second refusal, keeps the ordinary retry state.
10691
- const recovery = !target ? deps.recoverRejectedRecipientTarget?.(error) : undefined;
10692
- const message = prepared.message ?? prepared.edited_message;
10693
- if (recovery && message && message.message_thread_id === recovery.rejected.threadId &&
10694
- message.chat?.id === recovery.rejected.chatId && recovery.current.chatId === recovery.rejected.chatId &&
10695
- recovery.current.threadId !== recovery.rejected.threadId) {
10696
- deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "recipient-target-recovery", updateId: update.update_id,
10697
- chatId: recovery.current.chatId, staleThreadId: recovery.rejected.threadId, threadId: recovery.current.threadId });
10698
- return await executeUpdate(projectTo(recovery.current), ctx, signal);
10699
- }
10677
+ // A refused reply proves only that send was not issued, not that the handler made no prior effects.
10678
+ // Leave failure settlement to the worker's retry policy; never re-enter the handler to recover delivery here.
10700
10679
  throw error;
10701
10680
  }
10702
10681
  },
@@ -10777,9 +10756,6 @@ export function createTelegramUpdateAdmissionLifecycleAssembly<
10777
10756
  spendHistoricalInput: undefined,
10778
10757
  onHeldSourcesPrepared: undefined,
10779
10758
  prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
10780
- ...(deps.follower.recoverRejectedRecipientTarget
10781
- ? { recoverRejectedRecipientTarget: deps.follower.recoverRejectedRecipientTarget }
10782
- : {}),
10783
10759
  });
10784
10760
  },
10785
10761
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.57.7",
3
+ "version": "0.57.11",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Deterministic macOS CI test shards
3
+ * Zones: CI, test execution
4
+ * Keeps real-process tests isolated and partitions the remaining files without changing test deadlines.
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+ import { readdirSync } from "node:fs";
8
+ import { delimiter, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const [shard, phase, mode, ...extra] = process.argv.slice(2);
12
+ if (!["0", "1"].includes(shard) || !["process", "remaining"].includes(phase) ||
13
+ (mode !== undefined && mode !== "--list") || extra.length) {
14
+ throw new Error("Usage: run-macos-tests.mjs <0|1> <process|remaining> [--list]");
15
+ }
16
+ const root = fileURLToPath(new URL("..", import.meta.url));
17
+ const entries = readdirSync(join(root, "tests"), { withFileTypes: true })
18
+ .filter((entry) => entry.name.endsWith(".test.ts"));
19
+ if (entries.some((entry) => !entry.isFile()))
20
+ throw new Error("The CI test inventory must contain only regular test files.");
21
+ const names = entries.map((entry) => entry.name).sort();
22
+ const processTest = "process-shutdown.test.ts";
23
+ if (!names.includes(processTest)) throw new Error("The real-process test suite is missing.");
24
+ const files = (phase === "process"
25
+ ? (shard === "0" ? [processTest] : [])
26
+ : names.filter((name) => name !== processTest).filter((_, index) => index % 2 === Number(shard)))
27
+ .map((name) => `tests/${name}`);
28
+ if (mode === "--list") {
29
+ console.log(JSON.stringify(files));
30
+ } else if (files.length) {
31
+ console.log(`macOS shard ${Number(shard) + 1}/2 ${phase}: ${files.length} test file(s)`);
32
+ const result = spawnSync(process.execPath, [
33
+ "--experimental-strip-types", "--import", "./tests/fixtures/canonical-tmpdir.ts",
34
+ "--test", "--test-concurrency=1", `--test-reporter=${phase === "process" ? "spec" : "dot"}`,
35
+ ...files,
36
+ ], {
37
+ cwd: root,
38
+ stdio: "inherit",
39
+ env: { ...process.env, PATH: [join(root, "node_modules", ".bin"), process.env.PATH].filter(Boolean).join(delimiter) },
40
+ });
41
+ if (result.error) throw result.error;
42
+ process.exitCode = result.status ?? 1;
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.36.2",
3
+ "version": "0.36.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -50,7 +50,7 @@
50
50
  "@llblab/pi-codex-usage": "0.13.2",
51
51
  "@llblab/pi-grow-loop": "0.9.0",
52
52
  "@llblab/pi-state-flow": "0.26.5",
53
- "@llblab/pi-telegram": "0.57.7",
53
+ "@llblab/pi-telegram": "0.57.11",
54
54
  "@llblab/skills": "1.15.0"
55
55
  },
56
56
  "bundledDependencies": [