@gajae-code/coding-agent 0.12.11 → 0.12.12

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 (112) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/cli/notify-cli.d.ts +42 -1
  3. package/dist/types/cli/update-cli.d.ts +9 -3
  4. package/dist/types/commands/notify.d.ts +6 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/coordinator/contract.d.ts +1 -1
  7. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  8. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  9. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  10. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/main.d.ts +6 -2
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/utils/host-status.d.ts +23 -0
  15. package/dist/types/runtime-mcp/types.d.ts +2 -0
  16. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  17. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  18. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  21. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  22. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  23. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  24. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  25. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  26. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  27. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  28. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  29. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +24 -7
  31. package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
  32. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
  33. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  34. package/dist/types/sdk/host/host.d.ts +52 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  36. package/dist/types/sdk/session-activation.d.ts +41 -0
  37. package/dist/types/sdk/session.d.ts +18 -0
  38. package/dist/types/sdk/startup-capability.d.ts +8 -1
  39. package/dist/types/session/agent-session.d.ts +7 -0
  40. package/dist/types/session/artifacts.d.ts +2 -2
  41. package/dist/types/session/session-manager.d.ts +23 -5
  42. package/dist/types/tools/index.d.ts +8 -0
  43. package/dist/types/utils/npm-registry.d.ts +71 -0
  44. package/examples/extensions/README.md +38 -4
  45. package/examples/extensions/ooo-bridge.ts +15 -0
  46. package/package.json +8 -8
  47. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  48. package/src/async/job-manager.ts +9 -2
  49. package/src/cli/notify-cli.ts +226 -2
  50. package/src/cli/update-cli.ts +90 -23
  51. package/src/commands/notify.ts +27 -3
  52. package/src/commands/sdk.ts +1 -0
  53. package/src/config/file-lock-gc.ts +18 -0
  54. package/src/config/file-lock.ts +61 -16
  55. package/src/config/model-profiles.ts +31 -15
  56. package/src/coordinator/contract.ts +1 -0
  57. package/src/coordinator-mcp/question-state.ts +2 -0
  58. package/src/coordinator-mcp/server.ts +358 -7
  59. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  60. package/src/extensibility/extensions/index.ts +1 -0
  61. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  62. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  63. package/src/extensibility/extensions/runner.ts +15 -2
  64. package/src/extensibility/extensions/types.ts +2 -0
  65. package/src/gjc-runtime/launch-tmux.ts +53 -1
  66. package/src/internal-urls/docs-index.generated.ts +11 -10
  67. package/src/main.ts +203 -67
  68. package/src/modes/DESIGN.md +8 -5
  69. package/src/modes/components/footer.ts +10 -18
  70. package/src/modes/components/session-selector.ts +26 -3
  71. package/src/modes/components/status-line/gh.ts +23 -2
  72. package/src/modes/controllers/event-controller.ts +3 -0
  73. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  74. package/src/modes/controllers/input-controller.ts +11 -0
  75. package/src/modes/controllers/selector-controller.ts +8 -2
  76. package/src/modes/interactive-mode.ts +7 -1
  77. package/src/modes/utils/host-status.ts +38 -0
  78. package/src/runtime-mcp/types.ts +2 -0
  79. package/src/sdk/broker/lifecycle.ts +60 -3
  80. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  81. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  82. package/src/sdk/bus/chat-daemon-control.ts +16 -3
  83. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  84. package/src/sdk/bus/control-plane-frames.ts +51 -0
  85. package/src/sdk/bus/conversation-store.ts +29 -1
  86. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  87. package/src/sdk/bus/index.ts +70 -0
  88. package/src/sdk/bus/notification-service.ts +28 -1
  89. package/src/sdk/bus/rich-render.ts +3 -2
  90. package/src/sdk/bus/slack-daemon.ts +99 -4
  91. package/src/sdk/bus/slack-live-provider.ts +32 -2
  92. package/src/sdk/bus/slack-provider.ts +11 -0
  93. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  94. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  95. package/src/sdk/bus/telegram-daemon-contract.ts +24 -7
  96. package/src/sdk/bus/telegram-daemon.ts +1961 -497
  97. package/src/sdk/bus/telegram-reference.ts +42 -6
  98. package/src/sdk/bus/topic-registry.ts +674 -181
  99. package/src/sdk/host/host.ts +180 -1
  100. package/src/sdk/lifecycle-session.ts +8 -2
  101. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  102. package/src/sdk/session-activation.ts +127 -0
  103. package/src/sdk/session.ts +139 -28
  104. package/src/sdk/startup-capability.ts +10 -1
  105. package/src/session/agent-session.ts +220 -9
  106. package/src/session/artifacts.ts +56 -13
  107. package/src/session/internal/managed-session-storage.ts +206 -7
  108. package/src/session/session-manager.ts +148 -38
  109. package/src/setup/provider-presets.json +1 -1
  110. package/src/task/index.ts +92 -44
  111. package/src/tools/index.ts +8 -0
  112. package/src/utils/npm-registry.ts +660 -0
@@ -4,6 +4,7 @@ import * as crypto from "node:crypto";
4
4
  import * as fs from "node:fs";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
+ import { exactReplacePath, type NativeExactFileIdentity, type NativeExactUnlinkResult } from "@gajae-code/natives";
7
8
  import { logger } from "@gajae-code/utils";
8
9
  import { withFileLock } from "../../config/file-lock";
9
10
  import type { Settings } from "../../config/settings";
@@ -107,7 +108,13 @@ import {
107
108
  } from "./telegram-reference";
108
109
  import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
109
110
  import { renderThreadedFrame, supportsTelegramPhotoUpload, type ThreadedSend } from "./threaded-render";
110
- import { type TopicEndpointBinding, TopicRegistry, type TopicRegistryState } from "./topic-registry";
111
+ import {
112
+ parseTopicRegistryState,
113
+ type TopicEndpointBinding,
114
+ TopicRegistry,
115
+ type TopicRegistryCasAuthority,
116
+ type TopicRegistryState,
117
+ } from "./topic-registry";
111
118
 
112
119
  export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
113
120
  /** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */
@@ -146,15 +153,27 @@ export interface DaemonState {
146
153
  servingEpoch?: number;
147
154
  stoppedAt?: number;
148
155
  }
156
+ interface ExactFileStat {
157
+ dev: bigint;
158
+ ino: bigint;
159
+ nlink: bigint;
160
+ size: bigint;
161
+ mtimeNs: bigint;
162
+ isFile(): boolean;
163
+ }
164
+
149
165
  export interface TelegramDaemonFs {
150
166
  mkdir(path: string, opts?: fs.MakeDirectoryOptions): Promise<void>;
151
167
  readFile(path: string, encoding: BufferEncoding): Promise<string>;
152
168
  writeFile(path: string, data: string, opts?: fs.WriteFileOptions): Promise<void>;
153
169
  rename(oldPath: string, newPath: string): Promise<void>;
154
170
  unlink(path: string): Promise<void>;
155
- open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
171
+ open(path: string, flags: string, mode?: number): Promise<{ sync?: () => Promise<void>; close(): Promise<void> }>;
156
172
  readdir(path: string): Promise<string[]>;
157
173
  chmod(path: string, mode: number): Promise<void>;
174
+ /** Crash-atomic persistence seams. Implementations without them fail closed. */
175
+ fsyncFile?(path: string): Promise<void>;
176
+ fsyncDirectory?(path: string): Promise<void>;
158
177
  stat?(path: string): Promise<{
159
178
  mtimeMs: number;
160
179
  size?: number;
@@ -163,8 +182,9 @@ export interface TelegramDaemonFs {
163
182
  /** Hard-link count; required to prove a staging temp has no second name. */
164
183
  nlink?: number;
165
184
  ctimeMs?: number;
166
- isDirectory?: () => boolean;
185
+ isDirectory?(): boolean;
167
186
  }>;
187
+ lstat?(path: string, opts: { bigint: true }): Promise<ExactFileStat>;
168
188
  readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
169
189
  exactUnlink?(
170
190
  path: string,
@@ -233,6 +253,22 @@ function negotiateToolActivityCapability(
233
253
 
234
254
  const nodeFs: TelegramDaemonFs = {
235
255
  ...(fs.promises as unknown as TelegramDaemonFs),
256
+ fsyncFile: async file => {
257
+ const handle = await fs.promises.open(file, "r+");
258
+ try {
259
+ await handle.sync();
260
+ } finally {
261
+ await handle.close();
262
+ }
263
+ },
264
+ fsyncDirectory: async directory => {
265
+ const handle = await fs.promises.open(directory, "r");
266
+ try {
267
+ await handle.sync();
268
+ } finally {
269
+ await handle.close();
270
+ }
271
+ },
236
272
  readEndpointFile: readNotificationEndpointFile,
237
273
  exactUnlink: async (file, identity, quarantineName) =>
238
274
  exactUnlinkNotificationFile(
@@ -479,12 +515,15 @@ function endpointGenerationKey(url: string, token: string): string {
479
515
  function topicRenameApplied(response: unknown): boolean {
480
516
  return !!response && typeof response === "object" && (response as { ok?: unknown }).ok === true;
481
517
  }
482
- function topicDeleteSettled(response: unknown): boolean {
518
+ function topicArchiveSettled(response: unknown): boolean {
483
519
  if (!response || typeof response !== "object") return false;
484
- const result = response as { ok?: unknown; description?: unknown };
485
- if (result.ok === true) return true;
486
- if (typeof result.description !== "string") return false;
487
- return /(?:TOPIC_ID_INVALID|message thread not found)/i.test(result.description);
520
+ const result = response as { ok?: unknown; result?: unknown; error_code?: unknown; description?: unknown };
521
+ if (result.ok === true && result.result === true) return true;
522
+ if (result.ok !== false || result.error_code !== 400 || typeof result.description !== "string") return false;
523
+ const description = result.description.trim();
524
+ return /^(?:Bad Request: )?(?:TOPIC_NOT_FOUND|THREAD_NOT_FOUND|topic (?:already|is already) closed|message thread (?:not found|is not modified))$/i.test(
525
+ description,
526
+ );
488
527
  }
489
528
 
490
529
  /**
@@ -589,11 +628,428 @@ async function readJson<T>(fsImpl: TelegramDaemonFs, file: string): Promise<T |
589
628
  }
590
629
  }
591
630
 
592
- async function writeJsonAtomic(fsImpl: TelegramDaemonFs, file: string, data: unknown): Promise<void> {
631
+ async function writeJsonAtomic(
632
+ fsImpl: TelegramDaemonFs,
633
+ file: string,
634
+ data: unknown,
635
+ options: { durable?: boolean } = {},
636
+ ): Promise<void> {
593
637
  const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
594
- await fsImpl.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
595
- await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
596
- await fsImpl.rename(tmp, file);
638
+ let renamed = false;
639
+ try {
640
+ await fsImpl.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
641
+ await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
642
+ if (options.durable) await syncTelegramFile(fsImpl, tmp);
643
+ await fsImpl.rename(tmp, file);
644
+ renamed = true;
645
+ if (options.durable) await syncTelegramDirectory(fsImpl, path.dirname(file));
646
+ } catch (error) {
647
+ if (renamed) throw error;
648
+ let cleanupError: unknown;
649
+ try {
650
+ await fsImpl.unlink(tmp);
651
+ } catch (candidate) {
652
+ if ((candidate as NodeJS.ErrnoException).code !== "ENOENT") cleanupError = candidate;
653
+ }
654
+ if (cleanupError) throw new AggregateError([error, cleanupError], "Atomic JSON write and cleanup failed");
655
+ throw error;
656
+ }
657
+ }
658
+
659
+ async function syncTelegramFile(fsImpl: TelegramDaemonFs, file: string): Promise<void> {
660
+ const handle = await fsImpl.open(file, "r+");
661
+ let syncError: unknown;
662
+ try {
663
+ if (typeof handle.sync !== "function")
664
+ throw new Error("Telegram alias durability requires filesystem sync support");
665
+ await handle.sync();
666
+ } catch (error) {
667
+ syncError = error;
668
+ }
669
+ let closeError: unknown;
670
+ try {
671
+ await handle.close();
672
+ } catch (error) {
673
+ closeError = error;
674
+ }
675
+ if (syncError && closeError)
676
+ throw new AggregateError([syncError, closeError], "Telegram alias file sync and close failed");
677
+ if (syncError) throw syncError;
678
+ if (closeError) throw closeError;
679
+ }
680
+
681
+ async function syncTelegramDirectory(fsImpl: TelegramDaemonFs, directory: string): Promise<void> {
682
+ let handle: Awaited<ReturnType<TelegramDaemonFs["open"]>>;
683
+ try {
684
+ handle = await fsImpl.open(directory, "r");
685
+ } catch (error) {
686
+ if (process.platform === "win32" && isUnsupportedTelegramDirectoryBarrier(error)) return;
687
+ throw error;
688
+ }
689
+ let syncError: unknown;
690
+ try {
691
+ if (typeof handle.sync !== "function")
692
+ throw new Error("Telegram alias durability requires filesystem sync support");
693
+ await handle.sync();
694
+ } catch (error) {
695
+ if (!(process.platform === "win32" && isUnsupportedTelegramDirectoryBarrier(error))) syncError = error;
696
+ }
697
+ let closeError: unknown;
698
+ try {
699
+ await handle.close();
700
+ } catch (error) {
701
+ closeError = error;
702
+ }
703
+ if (syncError && closeError)
704
+ throw new AggregateError([syncError, closeError], "Telegram alias directory sync and close failed");
705
+ if (syncError) throw syncError;
706
+ if (closeError) throw closeError;
707
+ }
708
+
709
+ function isUnsupportedTelegramDirectoryBarrier(error: unknown): boolean {
710
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
711
+ return code === "EINVAL" || code === "EISDIR" || code === "ENOTSUP" || code === "EPERM";
712
+ }
713
+
714
+ export class TopicRegistryDurabilityUnavailableError extends Error {
715
+ readonly code = "durability_unavailable";
716
+
717
+ constructor(cause: unknown) {
718
+ super("topic registry durability is unavailable", { cause });
719
+ this.name = "TopicRegistryDurabilityUnavailableError";
720
+ }
721
+ }
722
+
723
+ type TopicRegistryExactReplace = (
724
+ sourcePath: string,
725
+ destinationPath: string,
726
+ expectedSource: NativeExactFileIdentity,
727
+ expectedDestination: NativeExactFileIdentity,
728
+ ) => NativeExactUnlinkResult;
729
+
730
+ function sameExactFileStat(left: ExactFileStat, right: ExactFileStat): boolean {
731
+ return (
732
+ left.dev === right.dev &&
733
+ left.ino === right.ino &&
734
+ left.nlink === right.nlink &&
735
+ left.size === right.size &&
736
+ left.mtimeNs === right.mtimeNs
737
+ );
738
+ }
739
+
740
+ async function captureExactFileSnapshot(
741
+ fsImpl: TelegramDaemonFs,
742
+ file: string,
743
+ ): Promise<{ bytes: string; identity: NativeExactFileIdentity }> {
744
+ if (!fsImpl.lstat)
745
+ throw new TopicRegistryDurabilityUnavailableError(
746
+ new Error("topic registry persistence requires no-follow file identity"),
747
+ );
748
+ const before = await fsImpl.lstat(file, { bigint: true });
749
+ const bytes = await fsImpl.readFile(file, "utf8");
750
+ const after = await fsImpl.lstat(file, { bigint: true });
751
+ const parentStat = await fsImpl.lstat(path.dirname(file), { bigint: true });
752
+ if (!before.isFile() || !after.isFile() || before.nlink !== 1n || !sameExactFileStat(before, after))
753
+ throw new TopicRegistryDurabilityUnavailableError(
754
+ new Error("topic registry persistence requires a stable single-link regular file"),
755
+ );
756
+ return {
757
+ bytes,
758
+ identity: {
759
+ dev: after.dev,
760
+ ino: after.ino,
761
+ nlink: after.nlink,
762
+ parentDev: parentStat.dev,
763
+ parentIno: parentStat.ino,
764
+ size: after.size,
765
+ mtimeNs: after.mtimeNs,
766
+ sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
767
+ },
768
+ };
769
+ }
770
+ async function captureTopicRegistryDestination(
771
+ fsImpl: TelegramDaemonFs,
772
+ file: string,
773
+ platform: NodeJS.Platform,
774
+ ): Promise<NativeExactFileIdentity | undefined> {
775
+ if (platform !== "win32") return undefined;
776
+ try {
777
+ return (await captureExactFileSnapshot(fsImpl, file)).identity;
778
+ } catch (error) {
779
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
780
+ throw error;
781
+ }
782
+ }
783
+
784
+ /** Publish topic authority only after its staged bytes and replacement are durable. */
785
+ export async function writeTopicRegistryAtomic(
786
+ fsImpl: TelegramDaemonFs,
787
+ file: string,
788
+ data: unknown,
789
+ platform: NodeJS.Platform = process.platform,
790
+ exactReplace: TopicRegistryExactReplace = exactReplacePath,
791
+ expectedDestination?: NativeExactFileIdentity,
792
+ ): Promise<void> {
793
+ if (!fsImpl.fsyncFile)
794
+ throw new TopicRegistryDurabilityUnavailableError(
795
+ new Error("topic registry persistence requires file fsync crash-atomicity"),
796
+ );
797
+ if (platform !== "win32" && !fsImpl.fsyncDirectory)
798
+ throw new TopicRegistryDurabilityUnavailableError(
799
+ new Error("topic registry persistence requires directory fsync crash-atomicity"),
800
+ );
801
+ const parent = path.dirname(file);
802
+ const serialized = `${JSON.stringify(data, null, 2)}\n`;
803
+ let destinationExists = true;
804
+ try {
805
+ await fsImpl.readFile(file, "utf8");
806
+ } catch (error) {
807
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
808
+ destinationExists = false;
809
+ }
810
+ if (!destinationExists && expectedDestination)
811
+ throw new TopicRegistryDurabilityUnavailableError(
812
+ new Error("topic registry destination disappeared after generation validation"),
813
+ );
814
+ if (!destinationExists) {
815
+ try {
816
+ await fsImpl.writeFile(file, serialized, { mode: 0o600, flag: "wx" });
817
+ } catch (error) {
818
+ if ((error as NodeJS.ErrnoException).code === "EEXIST")
819
+ throw new TopicRegistryDurabilityUnavailableError(
820
+ new Error("topic registry appeared during exclusive creation"),
821
+ );
822
+ throw error;
823
+ }
824
+ await fsImpl.chmod(file, 0o600).catch(() => undefined);
825
+ await fsImpl.fsyncFile(file);
826
+ if (platform !== "win32") await fsImpl.fsyncDirectory!(parent);
827
+ const written = await fsImpl.readFile(file, "utf8");
828
+ if (written !== serialized)
829
+ throw new TopicRegistryDurabilityUnavailableError(
830
+ new Error("exclusive topic registry creation verification failed"),
831
+ );
832
+ return;
833
+ }
834
+
835
+ const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
836
+ try {
837
+ await fsImpl.writeFile(tmp, serialized, { mode: 0o600, flag: "wx" });
838
+ await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
839
+ await fsImpl.fsyncFile(tmp);
840
+ if (platform === "win32") {
841
+ if (typeof exactReplace !== "function")
842
+ throw new TopicRegistryDurabilityUnavailableError(
843
+ new Error("native Windows exact replacement is unavailable"),
844
+ );
845
+ if (!expectedDestination)
846
+ throw new TopicRegistryDurabilityUnavailableError(
847
+ new Error("native Windows replacement requires the validated destination identity"),
848
+ );
849
+ const expectedSource = (await captureExactFileSnapshot(fsImpl, tmp)).identity;
850
+ const outcome = exactReplace(tmp, file, expectedSource, expectedDestination);
851
+ if (!outcome.ok)
852
+ throw new TopicRegistryDurabilityUnavailableError(
853
+ new Error(`native Windows topic replacement failed: ${outcome.code ?? "unknown"}`),
854
+ );
855
+ } else {
856
+ await fsImpl.rename(tmp, file);
857
+ await fsImpl.fsyncDirectory!(parent);
858
+ }
859
+ const written = await fsImpl.readFile(file, "utf8");
860
+ if (written !== serialized)
861
+ throw new TopicRegistryDurabilityUnavailableError(new Error("topic registry replacement verification failed"));
862
+ } catch (error) {
863
+ await fsImpl.unlink(tmp).catch(() => undefined);
864
+ throw error;
865
+ }
866
+ }
867
+ /**
868
+ * Shared-volume topic authority backed by the existing cross-process file lock.
869
+ * A missing authority is the only valid bootstrap state; malformed and future
870
+ * snapshots are never interpreted as empty state.
871
+ */
872
+ export class FilesystemTopicRegistryCasAuthority implements TopicRegistryCasAuthority {
873
+ private readonly fsImpl: TelegramDaemonFs;
874
+ private readonly platform: NodeJS.Platform;
875
+ private readonly exactReplace: TopicRegistryExactReplace;
876
+ private readonly installationHostId: string;
877
+
878
+ constructor(
879
+ private readonly file: string,
880
+ input: {
881
+ installationHostId: string;
882
+ fs?: TelegramDaemonFs;
883
+ platform?: NodeJS.Platform;
884
+ exactReplace?: TopicRegistryExactReplace;
885
+ },
886
+ ) {
887
+ if (!input.installationHostId) throw new Error("installationHostId must be non-empty");
888
+ this.installationHostId = input.installationHostId;
889
+ this.fsImpl = input.fs ?? nodeFs;
890
+ this.platform = input.platform ?? process.platform;
891
+ this.exactReplace = input.exactReplace ?? exactReplacePath;
892
+ }
893
+
894
+ async read(): Promise<TopicRegistryState> {
895
+ return await withFileLock(this.file, async () => await this.readLocked(), {
896
+ staleMs: 10_000,
897
+ ownerHostId: this.installationHostId,
898
+ });
899
+ }
900
+
901
+ async compareAndSet(expectedGeneration: number, next: TopicRegistryState): Promise<boolean> {
902
+ if (!Number.isSafeInteger(expectedGeneration) || expectedGeneration < 0)
903
+ throw new Error("invalid expected topic registry generation");
904
+ if (
905
+ next.version !== 2 ||
906
+ next.registryGeneration !== expectedGeneration + 1 ||
907
+ parseTopicRegistryState(next) === undefined
908
+ )
909
+ throw new Error("invalid next topic registry generation");
910
+ return await withFileLock(
911
+ this.file,
912
+ async () => {
913
+ const { state: current, legacyRaw, expectedDestination } = await this.#readLockedWithLegacy();
914
+ if (current.registryGeneration !== expectedGeneration) return false;
915
+ await ensureDir(this.fsImpl, path.dirname(this.file));
916
+ if (legacyRaw !== undefined) await this.#quarantineLegacyLocked(legacyRaw);
917
+ await writeTopicRegistryAtomic(
918
+ this.fsImpl,
919
+ this.file,
920
+ next,
921
+ this.platform,
922
+ this.exactReplace,
923
+ expectedDestination,
924
+ );
925
+ return true;
926
+ },
927
+ { staleMs: 10_000, ownerHostId: this.installationHostId },
928
+ );
929
+ }
930
+
931
+ private async readLocked(): Promise<TopicRegistryState> {
932
+ return (await this.#readLockedWithLegacy()).state;
933
+ }
934
+
935
+ async #readLockedWithLegacy(): Promise<{
936
+ state: TopicRegistryState;
937
+ legacyRaw?: unknown;
938
+ expectedDestination?: NativeExactFileIdentity;
939
+ }> {
940
+ let raw: unknown;
941
+ let expectedDestination: NativeExactFileIdentity | undefined;
942
+ try {
943
+ if (this.platform === "win32") {
944
+ const snapshot = await captureExactFileSnapshot(this.fsImpl, this.file);
945
+ raw = JSON.parse(snapshot.bytes);
946
+ expectedDestination = snapshot.identity;
947
+ } else {
948
+ raw = JSON.parse(await this.fsImpl.readFile(this.file, "utf8"));
949
+ }
950
+ } catch (error) {
951
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
952
+ return { state: { version: 2, registryGeneration: 0, topics: {} } };
953
+ throw new Error("shared topic authority is malformed or unavailable");
954
+ }
955
+ const legacyRaw =
956
+ raw && typeof raw === "object" && !Array.isArray(raw) && !Object.hasOwn(raw as object, "version")
957
+ ? raw
958
+ : undefined;
959
+ const state = parseTopicRegistryState(raw);
960
+ const generation = state?.registryGeneration;
961
+ if (state?.version !== 2 || generation === undefined || !Number.isSafeInteger(generation) || generation < 0)
962
+ throw new Error("shared topic authority is malformed or unsupported");
963
+ return {
964
+ state: { ...state, registryGeneration: generation },
965
+ ...(legacyRaw === undefined ? {} : { legacyRaw }),
966
+ ...(expectedDestination === undefined ? {} : { expectedDestination }),
967
+ };
968
+ }
969
+
970
+ async #quarantineLegacyLocked(raw: unknown): Promise<void> {
971
+ const digest = crypto.createHash("sha256").update(JSON.stringify(raw)).digest("hex");
972
+ const quarantinePath = `${this.file}.legacy-quarantine.${digest}.json`;
973
+ try {
974
+ await this.fsImpl.readFile(quarantinePath, "utf8");
975
+ return;
976
+ } catch (error) {
977
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
978
+ throw new Error("shared topic authority is malformed or unavailable");
979
+ }
980
+ await writeTopicRegistryAtomic(this.fsImpl, quarantinePath, raw, this.platform, this.exactReplace);
981
+ }
982
+ }
983
+
984
+ export interface MachineIdentityDeps {
985
+ platform?: NodeJS.Platform;
986
+ readFile?: (file: string) => Promise<string>;
987
+ runCommand?: (command: string, args: readonly string[]) => { exitCode: number; stdout: Uint8Array };
988
+ }
989
+
990
+ function normalizedMachineIdentity(value: string, pattern: RegExp): string | undefined {
991
+ const normalized = value.trim().toLowerCase();
992
+ if (!pattern.test(normalized) || /^0+$/.test(normalized.replace(/-/g, ""))) return undefined;
993
+ return normalized;
994
+ }
995
+
996
+ /** @internal */
997
+ export function parseWindowsMachineGuid(output: string): string | undefined {
998
+ const match = /^\s*MachineGuid\s+REG_\w+\s+(\S+)\s*$/im.exec(output);
999
+ return match
1000
+ ? normalizedMachineIdentity(match[1], /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
1001
+ : undefined;
1002
+ }
1003
+
1004
+ /** @internal */
1005
+ export function parseMacPlatformUuid(output: string): string | undefined {
1006
+ const match = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(output);
1007
+ return match
1008
+ ? normalizedMachineIdentity(match[1], /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
1009
+ : undefined;
1010
+ }
1011
+
1012
+ function hashMachineIdentity(rawId: string): string {
1013
+ return crypto
1014
+ .createHash("sha256")
1015
+ .update("gajae-code:telegram-daemon:machine-identity:v1\0")
1016
+ .update(rawId)
1017
+ .digest("hex");
1018
+ }
1019
+
1020
+ /** Loads a verified machine-local identity without persisting the underlying machine ID. */
1021
+ export async function loadInstallationHostId(deps: MachineIdentityDeps = {}): Promise<string> {
1022
+ const platform = deps.platform ?? process.platform;
1023
+ const readFile = deps.readFile ?? (async (file: string) => await fs.promises.readFile(file, "utf8"));
1024
+ const runCommand =
1025
+ deps.runCommand ??
1026
+ ((command: string, args: readonly string[]) =>
1027
+ Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "ignore" }));
1028
+
1029
+ let rawId: string | undefined;
1030
+ if (platform === "linux") {
1031
+ for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
1032
+ try {
1033
+ const value = normalizedMachineIdentity(await readFile(file), /^[0-9a-f]{32}$/);
1034
+ if (!value) throw new Error("machine-local identity is unavailable or malformed");
1035
+ rawId = value;
1036
+ break;
1037
+ } catch (error) {
1038
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1039
+ }
1040
+ }
1041
+ } else if (platform === "win32") {
1042
+ const result = runCommand("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]);
1043
+ if (result.exitCode === 0) rawId = parseWindowsMachineGuid(new TextDecoder().decode(result.stdout));
1044
+ } else if (platform === "darwin") {
1045
+ const result = runCommand("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
1046
+ if (result.exitCode === 0) rawId = parseMacPlatformUuid(new TextDecoder().decode(result.stdout));
1047
+ } else {
1048
+ throw new Error(`machine-local identity is unsupported on ${platform}`);
1049
+ }
1050
+
1051
+ if (!rawId) throw new Error("machine-local identity is unavailable or malformed");
1052
+ return hashMachineIdentity(rawId);
597
1053
  }
598
1054
 
599
1055
  function validDaemonPid(pid: unknown): pid is number {
@@ -2184,7 +2640,8 @@ export function isCurrentCompatibleOwner(input: {
2184
2640
  state?.ownershipPhase === "ready" &&
2185
2641
  typeof state.acquisitionId === "string" &&
2186
2642
  state.acquisitionId.length > 0 &&
2187
- (state.servingEpoch === undefined ? 1 : state.servingEpoch) >= SERVING_EPOCH,
2643
+ state.generation === DAEMON_GENERATION &&
2644
+ state.servingEpoch === SERVING_EPOCH,
2188
2645
  );
2189
2646
  }
2190
2647
 
@@ -3122,6 +3579,8 @@ export interface TelegramSpawnOwnerInput {
3122
3579
  roots?: string[];
3123
3580
  tokenFingerprint: string;
3124
3581
  chatId: string;
3582
+ /** Ephemeral outbound-only validation destination for this owner launch. */
3583
+ validationTestSupergroupChatId?: string;
3125
3584
  }
3126
3585
 
3127
3586
  export interface TelegramSpawnAcquisition {
@@ -3149,7 +3608,12 @@ export type TelegramSpawnOwnerResult =
3149
3608
  * Source mode prepends the entry script so the respawn loads edited source;
3150
3609
  * a compiled binary self-spawns its own subcommand directly.
3151
3610
  */
3152
- export function buildTelegramDaemonSpawnArgs(input: { execPath?: string; ownerId: string; agentDir: string }): {
3611
+ export function buildTelegramDaemonSpawnArgs(input: {
3612
+ execPath?: string;
3613
+ ownerId: string;
3614
+ agentDir: string;
3615
+ validationTestSupergroupChatId?: string;
3616
+ }): {
3153
3617
  command: string;
3154
3618
  args: string[];
3155
3619
  runtime: DaemonRuntimeInfo;
@@ -3163,6 +3627,9 @@ export function buildTelegramDaemonSpawnArgs(input: { execPath?: string; ownerId
3163
3627
  input.ownerId,
3164
3628
  "--agent-dir",
3165
3629
  input.agentDir,
3630
+ ...(input.validationTestSupergroupChatId !== undefined
3631
+ ? ["--validation-test-supergroup-chat-id", input.validationTestSupergroupChatId]
3632
+ : []),
3166
3633
  ];
3167
3634
  const runtime: DaemonRuntimeInfo = {
3168
3635
  mode: rt.mode,
@@ -3238,6 +3705,7 @@ export async function spawnTelegramDaemonOwner(
3238
3705
  execPath,
3239
3706
  ownerId: provisionalAcquisition.ownerId,
3240
3707
  agentDir,
3708
+ validationTestSupergroupChatId: input.validationTestSupergroupChatId,
3241
3709
  });
3242
3710
  const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
3243
3711
  const child = spawnImpl(command, args, {
@@ -3419,13 +3887,17 @@ async function ensureTelegramDaemonRunningDetailedOnce(
3419
3887
  // unproven cooperative handoff into destructive cleanup or a replacement spawn.
3420
3888
  if ((deps.platform ?? process.platform) === "win32") {
3421
3889
  const parentState: unknown = await readDaemonState(input.settings, deps.fs);
3422
- if (isParentDaemonState(parentState) && (deps.pidAlive ?? defaultPidAlive)(parentState.pid))
3890
+ if (
3891
+ isParentDaemonState(parentState) &&
3892
+ (deps.pidAlive ?? defaultPidAlive)(parentState.pid) &&
3893
+ (!hasSafeDaemonStateShape(parentState) || ownerProvenanceMatches(parentState, deps.pidIncarnation))
3894
+ )
3423
3895
  return "blocked_identity";
3424
3896
  }
3425
3897
  // Windows can retain dead launcher metadata without an ownership lock; reclaim
3426
3898
  // its dead discovery records before the replacement can publish a new owner.
3427
3899
  if ((deps.platform ?? process.platform) === "win32" && !deps.fs) {
3428
- const preflight = await reclaimDeadDaemonOwner({
3900
+ let preflight = await reclaimDeadDaemonOwner({
3429
3901
  settings: input.settings,
3430
3902
  endpointDir: path.join(root, "sdk"),
3431
3903
  fs: deps.fs,
@@ -3433,6 +3905,28 @@ async function ensureTelegramDaemonRunningDetailedOnce(
3433
3905
  pidAlive: deps.pidAlive,
3434
3906
  pidIncarnation: deps.pidIncarnation,
3435
3907
  });
3908
+ // A just-reused PID can be reported alive for the first probe. Recheck once
3909
+ // before acquisition only when stale discovery records need fenced cleanup.
3910
+ // reclaimDeadDaemonOwner still requires exact state, lock, incarnation,
3911
+ // transition-lock, and endpoint-identity proofs before deleting anything.
3912
+ const endpointNames =
3913
+ preflight.reason === "not-confirmed-dead"
3914
+ ? await (deps.fs ?? nodeFs).readdir(path.join(root, "sdk")).catch(() => undefined)
3915
+ : undefined;
3916
+ if (
3917
+ !preflight.recovered &&
3918
+ preflight.reason === "not-confirmed-dead" &&
3919
+ endpointNames?.some(name => name.endsWith(".json"))
3920
+ ) {
3921
+ preflight = await reclaimDeadDaemonOwner({
3922
+ settings: input.settings,
3923
+ endpointDir: path.join(root, "sdk"),
3924
+ fs: deps.fs,
3925
+ now: deps.now,
3926
+ pidAlive: deps.pidAlive,
3927
+ pidIncarnation: deps.pidIncarnation,
3928
+ });
3929
+ }
3436
3930
  if (!preflight.recovered && preflight.reason !== "not-confirmed-dead") {
3437
3931
  logger.warn(
3438
3932
  `notifications: startup recovery unsafe (${preflight.reason}); run \`gjc notify recovery\` for diagnostics`,
@@ -3849,7 +4343,7 @@ export class TelegramBotTransport implements BotApi {
3849
4343
  }
3850
4344
  }
3851
4345
 
3852
- type PairedChatPrivacy = "private" | "non-private" | "indeterminate";
4346
+ type PairedChatPrivacy = "private" | "validation-forum" | "non-private" | "indeterminate";
3853
4347
 
3854
4348
  export type TelegramUpdateOutcome = "consumed" | "retry";
3855
4349
 
@@ -4065,6 +4559,12 @@ export interface TelegramDaemonOptions {
4065
4559
  ownerId: string;
4066
4560
  botToken: string;
4067
4561
  chatId: string;
4562
+ /**
4563
+ * Exact ephemeral forum destination supplied to the daemon-internal validation
4564
+ * command. Production ownership, pairing, and durable topic authority remain
4565
+ * bound to `chatId`.
4566
+ */
4567
+ validationTestSupergroupChatId?: string;
4068
4568
  apiBase?: string;
4069
4569
  fetchImpl?: typeof fetch;
4070
4570
  fs?: TelegramDaemonFs;
@@ -4116,6 +4616,20 @@ export interface TelegramDaemonOptions {
4116
4616
  * built-in `{repo}/{branch} - {title}` composition and its fallbacks.
4117
4617
  */
4118
4618
  topics?: { nameTemplate?: string };
4619
+ /**
4620
+ * Optional compare-and-set store for installations that share topic state
4621
+ * across hosts. When configured, every publication is fenced by it.
4622
+ */
4623
+ topicRegistryAuthority?: TopicRegistryCasAuthority;
4624
+ /** Stable host-local identity. Required whenever a shared authority is configured. */
4625
+ installationHostId?: string;
4626
+ }
4627
+
4628
+ interface StagedCallbackActivation {
4629
+ pending: { sessionId: string; actionId: string };
4630
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
4631
+ topicLease?: TopicAuthorityLease;
4632
+ settled: Promise<boolean>;
4119
4633
  }
4120
4634
 
4121
4635
  interface SessionSocket {
@@ -4226,7 +4740,8 @@ interface PendingThreadedFrame {
4226
4740
  send: ThreadedSend;
4227
4741
  msg: Record<string, unknown>;
4228
4742
  logicalSessionId: string;
4229
- socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
4743
+ session: SessionSocket;
4744
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string };
4230
4745
  toolActivity?: ToolActivityOwner;
4231
4746
  }
4232
4747
 
@@ -4246,6 +4761,37 @@ interface BotApiCallResult {
4246
4761
  response: unknown;
4247
4762
  outcome: BotApiCallOutcome;
4248
4763
  }
4764
+ function createBotApiAdapter(
4765
+ call: (
4766
+ method: string,
4767
+ body: unknown,
4768
+ callOpts?: { signal?: AbortSignal; noRetry?: boolean },
4769
+ ) => Promise<BotApiCallResult>,
4770
+ ): BotApi {
4771
+ return {
4772
+ call: async (method, body, callOpts) => (await call(method, body, callOpts)).response,
4773
+ };
4774
+ }
4775
+ function createBotApiPipeline(
4776
+ rawBotApi: BotApi,
4777
+ callBotApi: (
4778
+ rawBotApi: BotApi,
4779
+ method: string,
4780
+ body: unknown,
4781
+ callOpts?: { signal?: AbortSignal; noRetry?: boolean },
4782
+ ) => Promise<BotApiCallResult>,
4783
+ ): {
4784
+ classified: (
4785
+ method: string,
4786
+ body: unknown,
4787
+ callOpts?: { signal?: AbortSignal; noRetry?: boolean },
4788
+ ) => Promise<BotApiCallResult>;
4789
+ api: BotApi;
4790
+ } {
4791
+ const classified = (method: string, body: unknown, callOpts?: { signal?: AbortSignal; noRetry?: boolean }) =>
4792
+ callBotApi(rawBotApi, method, body, callOpts);
4793
+ return { classified, api: createBotApiAdapter(classified) };
4794
+ }
4249
4795
 
4250
4796
  function classifyBotApiCallOutcome(response: unknown, cooldownSuppressed = false): BotApiCallOutcome {
4251
4797
  if (cooldownSuppressed) return { kind: "retryable", retryAfterMs: 0 };
@@ -4329,6 +4875,7 @@ interface PendingBtwDelivery {
4329
4875
 
4330
4876
  class TelegramEffectSupervisor {
4331
4877
  #stopping = false;
4878
+ #admitting = true;
4332
4879
  readonly #abort = new AbortController();
4333
4880
  readonly #terminalContext = new AsyncLocalStorage<boolean>();
4334
4881
  readonly #pending = new Set<Promise<unknown>>();
@@ -4350,6 +4897,16 @@ class TelegramEffectSupervisor {
4350
4897
  return this.track(api.call(method, body, { ...opts, signal }));
4351
4898
  }
4352
4899
 
4900
+ admit<T>(effect: () => Promise<T>): Promise<T> {
4901
+ if (!this.#admitting)
4902
+ return Promise.reject(Object.assign(new Error("Daemon is stopping"), { name: "AbortError" }));
4903
+ try {
4904
+ return this.track(effect());
4905
+ } catch (error) {
4906
+ return Promise.reject(error);
4907
+ }
4908
+ }
4909
+
4353
4910
  track<T>(effect: Promise<T>): Promise<T> {
4354
4911
  this.#pending.add(effect);
4355
4912
  void effect.then(
@@ -4363,7 +4920,12 @@ class TelegramEffectSupervisor {
4363
4920
  return this.#stopping;
4364
4921
  }
4365
4922
 
4923
+ closeAdmission(): void {
4924
+ this.#admitting = false;
4925
+ }
4926
+
4366
4927
  beginShutdown(): void {
4928
+ this.closeAdmission();
4367
4929
  this.#stopping = true;
4368
4930
  this.#abort.abort("daemon_shutdown");
4369
4931
  }
@@ -4388,7 +4950,18 @@ class TelegramEffectSupervisor {
4388
4950
 
4389
4951
  export class TelegramNotificationDaemon {
4390
4952
  readonly aliasTable: AliasTable;
4953
+ /** Callback aliases are routable only by the immutable socket lease that rendered them. */
4954
+ #callbackAliasLeases = new Map<string, { session: SessionSocket; token: number; logicalSessionId: string }>();
4955
+ /** Each active alias is bound to the exact pending ask object that rendered it. */
4956
+ #callbackAliasPending = new Map<string, { sessionId: string; actionId: string }>();
4957
+ /** Active callbacks preserve the exact topic authority under which their keyboard was rendered. */
4958
+ #callbackAliasTopics = new Map<string, TopicAuthorityLease>();
4959
+ /** Exposed aliases wait for their accepted receipt persistence before routing. */
4960
+ #stagedCallbackActivations = new Map<string, StagedCallbackActivation>();
4391
4961
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
4962
+ /** Restart-revoked aliases retained only to terminalize their old keyboards on an authoritative replay. */
4963
+ private readonly reissueBacklog = new Map<string, CallbackRoute>();
4964
+ private aliasPersistenceQueue: Promise<void> = Promise.resolve();
4392
4965
  /** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
4393
4966
  private readonly liveMessages = new Map<string, number>();
4394
4967
  /** Endpoint-bound ownership for visible or dispatching tool bubbles. */
@@ -4432,6 +5005,8 @@ export class TelegramNotificationDaemon {
4432
5005
  private readonly botApi: BotApi;
4433
5006
  private readonly effects = new TelegramEffectSupervisor();
4434
5007
  private readonly topics = new TopicRegistry();
5008
+ /** Stable host-local identity; never persisted in shared topic authority. */
5009
+ private installationHostId: string;
4435
5010
  /**
4436
5011
  * Telegram may accept createForumTopic while returning an unusable success
4437
5012
  * payload. Remember that ambiguity per endpoint so later frames cannot repeat
@@ -4446,6 +5021,11 @@ export class TelegramNotificationDaemon {
4446
5021
  private recoveryBindingClaimQueue: Promise<void> = Promise.resolve();
4447
5022
  /** Durable compensation fences retry under supervision until persistence succeeds. */
4448
5023
  private readonly compensationFenceRetries = new Map<string, Promise<void>>();
5024
+ /** All archive paths for one session share one durable fence and remote dispatch. */
5025
+ private readonly archiveFlights = new Map<
5026
+ string,
5027
+ Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled">
5028
+ >();
4449
5029
 
4450
5030
  /** Daemon edit attempts that can race an accepted user service message. */
4451
5031
  private readonly daemonRenameAttempts = new Map<string, number>();
@@ -4486,8 +5066,8 @@ export class TelegramNotificationDaemon {
4486
5066
  private threadedFallbackNoticeSent = false;
4487
5067
  /** Sessions whose identity header was already sent flat (Threaded Mode off). */
4488
5068
  private readonly flatIdentitySent = new Set<string>();
4489
- /** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
4490
- private pairedChatPrivate: boolean | undefined;
5069
+ /** Cached delivery boundary for the private owner chat or validation forum. */
5070
+ private pairedChatPrivacy: PairedChatPrivacy | undefined;
4491
5071
  /** Bot username from getMe, cached once at owner startup for group/forum command targeting. */
4492
5072
  private botUsername: string | undefined;
4493
5073
  /** Sessions whose agent loop is currently busy (drives the typing indicator). */
@@ -4555,6 +5135,7 @@ export class TelegramNotificationDaemon {
4555
5135
  */
4556
5136
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
4557
5137
  this.stopRequested = true;
5138
+ this.effects.closeAdmission();
4558
5139
 
4559
5140
  const toolShutdown = this.beginToolActivityShutdown();
4560
5141
  void toolShutdown
@@ -4919,7 +5500,8 @@ export class TelegramNotificationDaemon {
4919
5500
  const outcome = classifyBotApiCallOutcome(undefined, true);
4920
5501
  return { response: undefined, outcome };
4921
5502
  }
4922
- const response = await this.effects.call(rawBotApi, method, body, callOpts);
5503
+ const outboundBody = this.validationTopicDestination(method, body);
5504
+ const response = await this.effects.call(rawBotApi, method, outboundBody, callOpts);
4923
5505
  const outcome = classifyBotApiCallOutcome(response);
4924
5506
  if (outcome.kind === "retryable")
4925
5507
  this.botCooldownUntil = Math.max(
@@ -4928,6 +5510,20 @@ export class TelegramNotificationDaemon {
4928
5510
  );
4929
5511
  return { response, outcome };
4930
5512
  }
5513
+ private validationTopicDestination(method: string, body: unknown): unknown {
5514
+ if (
5515
+ !this.validationMode() ||
5516
+ method === "getChat" ||
5517
+ !body ||
5518
+ typeof body !== "object" ||
5519
+ String((body as { chat_id?: unknown }).chat_id) !== String(this.opts.chatId)
5520
+ )
5521
+ return body;
5522
+ return {
5523
+ ...(body as Record<string, unknown>),
5524
+ chat_id: this.opts.validationTestSupergroupChatId!,
5525
+ };
5526
+ }
4931
5527
 
4932
5528
  private readonly callBotApiClassified: (
4933
5529
  method: string,
@@ -4948,6 +5544,9 @@ export class TelegramNotificationDaemon {
4948
5544
  constructor(private readonly opts: TelegramDaemonOptions) {
4949
5545
  this.fsImpl = opts.fs ?? nodeFs;
4950
5546
  this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
5547
+ if (opts.topicRegistryAuthority && !opts.installationHostId)
5548
+ throw new Error("shared topic authority requires a host-local installation identity");
5549
+ this.installationHostId = opts.installationHostId ?? crypto.randomUUID();
4951
5550
  this.aliasTable = createAliasTable();
4952
5551
  this.#adoptionIntentTtlMs = opts.adoptionIntentTtlMs ?? DEFAULT_ADOPTION_INTENT_TTL_MS;
4953
5552
  const adoptionFs: AdoptionIntentFs = {
@@ -4981,10 +5580,9 @@ export class TelegramNotificationDaemon {
4981
5580
  fetchImpl: opts.fetchImpl,
4982
5581
  setTimeoutImpl: opts.setTimeoutImpl,
4983
5582
  });
4984
- this.callBotApiClassified = (method, body, callOpts) => this.callBotApi(rawBotApi, method, body, callOpts);
4985
- this.botApi = {
4986
- call: async (method, body, callOpts) => (await this.callBotApiClassified(method, body, callOpts)).response,
4987
- };
5583
+ const botApiPipeline = createBotApiPipeline(rawBotApi, this.callBotApi.bind(this));
5584
+ this.callBotApiClassified = botApiPipeline.classified;
5585
+ this.botApi = botApiPipeline.api;
4988
5586
  this.runtime = new NotificationOperatorRuntime({
4989
5587
  now: opts.now,
4990
5588
  setTimeoutImpl: opts.setTimeoutImpl,
@@ -5058,7 +5656,6 @@ export class TelegramNotificationDaemon {
5058
5656
  if (!socketLease) return;
5059
5657
  const topicLease = this.topicAuthorityLeaseFromRegistry(logicalSessionId);
5060
5658
  if (
5061
- this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
5062
5659
  this.topics.get(logicalSessionId)?.bindingMalformed ||
5063
5660
  (mode === "recovery" && (!topicLease || msg.sessionId !== logicalSessionId))
5064
5661
  ) {
@@ -5217,40 +5814,49 @@ export class TelegramNotificationDaemon {
5217
5814
  if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5218
5815
 
5219
5816
  const closedBinding = this.#endpointBinding(session);
5220
- const closeTopicAuthority = this.topics.captureDeleteAuthority(logicalSessionId);
5817
+ const closedBindingMatches = () => {
5818
+ const current = this.closedEndpointKeys.get(session.sessionId);
5819
+ return (
5820
+ current?.chatId === closedBinding.chatId &&
5821
+ current.endpointKey === closedBinding.endpointKey &&
5822
+ current.endpointDigest === closedBinding.endpointDigest &&
5823
+ current.endpointGeneration === closedBinding.endpointGeneration
5824
+ );
5825
+ };
5826
+ const closeTopicAuthority = this.topics.captureArchiveAuthority(logicalSessionId);
5221
5827
  const previousClosedBinding = this.closedEndpointKeys.get(session.sessionId);
5222
5828
  await this.#persistTopicMutation(
5223
5829
  () => {
5224
5830
  this.closedEndpointKeys.set(session.sessionId, closedBinding);
5225
- this.topics.beginDelete(logicalSessionId);
5831
+ this.topics.beginArchive(logicalSessionId, this.installationHostId, this.runtime.now());
5226
5832
  },
5227
5833
  () => {
5228
- this.topics.restoreDeleteAuthority(closeTopicAuthority);
5834
+ this.topics.restoreArchiveAuthority(closeTopicAuthority);
5229
5835
  if (previousClosedBinding === undefined) this.closedEndpointKeys.delete(session.sessionId);
5230
- else if (this.closedEndpointKeys.get(session.sessionId) === closedBinding)
5836
+ else if (closedBindingMatches())
5231
5837
  this.closedEndpointKeys.set(session.sessionId, previousClosedBinding);
5232
5838
  },
5233
5839
  );
5234
5840
  if (
5235
- socketLease &&
5236
- (this.sessions.get(session.sessionId) !== session ||
5237
- session.recoveryLease?.token !== socketLease.token ||
5238
- session.recoveryLease.state !== "authorized" ||
5239
- this.logicalSessionOwners.get(logicalSessionId) !== session)
5841
+ this.sessions.get(session.sessionId) !== session ||
5842
+ (socketLease &&
5843
+ (session.recoveryLease?.token !== socketLease.token ||
5844
+ session.recoveryLease.state !== "authorized" ||
5845
+ this.logicalSessionOwners.get(logicalSessionId) !== session))
5240
5846
  ) {
5241
5847
  // A replacement won after the close fence committed. Restore the exact
5242
5848
  // pre-close authority and remove the predecessor tombstone together.
5243
5849
  const restoreCloseAuthority = (): Promise<boolean> =>
5244
5850
  this.#persistTopicMutation(
5245
5851
  () => {
5246
- const restored = this.topics.restoreDeleteAuthority(closeTopicAuthority);
5247
- if (!restored) throw new Error("close authority changed before compensation");
5248
- this.closedEndpointKeys.delete(session.sessionId);
5852
+ this.topics.restoreArchiveAuthority(closeTopicAuthority);
5853
+ if (closedBindingMatches()) this.closedEndpointKeys.delete(session.sessionId);
5249
5854
  return true;
5250
5855
  },
5251
5856
  () => {
5252
- this.topics.restoreDeleteFence(closeTopicAuthority);
5253
- this.closedEndpointKeys.set(session.sessionId, closedBinding);
5857
+ this.topics.restoreArchiveFence(closeTopicAuthority);
5858
+ if (this.sessions.get(session.sessionId) === session)
5859
+ this.closedEndpointKeys.set(session.sessionId, closedBinding);
5254
5860
  },
5255
5861
  );
5256
5862
  try {
@@ -5260,7 +5866,7 @@ export class TelegramNotificationDaemon {
5260
5866
  }
5261
5867
  return;
5262
5868
  }
5263
- const deleteOutcome = await this.deleteTopic(logicalSessionId, socketLease, true);
5869
+ const deleteOutcome = await this.archiveTopic(logicalSessionId, socketLease, true);
5264
5870
  if (
5265
5871
  deleteOutcome === "pre_dispatch_cancelled" &&
5266
5872
  socketLease &&
@@ -5268,12 +5874,12 @@ export class TelegramNotificationDaemon {
5268
5874
  ) {
5269
5875
  await this.#persistTopicMutation(
5270
5876
  () => {
5271
- if (!this.topics.restoreDeleteAuthority(closeTopicAuthority))
5877
+ if (!this.topics.restoreArchiveAuthority(closeTopicAuthority))
5272
5878
  throw new Error("close authority changed before compensation");
5273
5879
  this.closedEndpointKeys.delete(session.sessionId);
5274
5880
  },
5275
5881
  () => {
5276
- this.topics.restoreDeleteFence(closeTopicAuthority);
5882
+ this.topics.restoreArchiveFence(closeTopicAuthority);
5277
5883
  this.closedEndpointKeys.set(session.sessionId, closedBinding);
5278
5884
  },
5279
5885
  );
@@ -5286,17 +5892,40 @@ export class TelegramNotificationDaemon {
5286
5892
  }
5287
5893
 
5288
5894
  async loadAliases(): Promise<void> {
5895
+ if (this.validationMode()) return;
5289
5896
  const raw = await readJson<unknown>(this.fsImpl, daemonPaths(this.opts.settings.getAgentDir()).aliases);
5290
- if (raw) this.aliasTable.load(raw);
5897
+ if (!raw) return;
5898
+ const persisted = raw as { revokedRoutes?: unknown };
5899
+ const revoked = createAliasTable();
5900
+ revoked.load({ version: 2, next: 1, routes: persisted.revokedRoutes });
5901
+ for (const [alias, route] of revoked.entries()) this.reissueBacklog.set(alias, route);
5902
+ this.aliasTable.load(raw);
5903
+ for (const [alias, route] of this.aliasTable.entries()) this.reissueBacklog.set(alias, route);
5904
+ this.aliasTable.clear();
5905
+ await this.persistAliases();
5291
5906
  }
5292
5907
 
5293
- async persistAliases(): Promise<void> {
5294
- const paths = daemonPaths(this.opts.settings.getAgentDir());
5295
- await ensureDir(this.fsImpl, paths.dir);
5296
- await writeJsonAtomic(this.fsImpl, paths.aliases, this.aliasTable.serialize());
5908
+ persistAliases(): Promise<void> {
5909
+ if (this.validationMode()) return Promise.resolve();
5910
+ const pending = this.aliasPersistenceQueue.then(async () => {
5911
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
5912
+ await ensureDir(this.fsImpl, paths.dir);
5913
+ await writeJsonAtomic(
5914
+ this.fsImpl,
5915
+ paths.aliases,
5916
+ {
5917
+ ...this.aliasTable.serialize(),
5918
+ revokedRoutes: Object.fromEntries(this.reissueBacklog),
5919
+ },
5920
+ { durable: true },
5921
+ );
5922
+ });
5923
+ this.aliasPersistenceQueue = pending.catch(() => undefined);
5924
+ return pending;
5297
5925
  }
5298
5926
 
5299
5927
  async loadSeenUpdateIds(): Promise<void> {
5928
+ if (this.validationMode()) return;
5300
5929
  const raw = await readJson<{ updateIds?: unknown }>(
5301
5930
  this.fsImpl,
5302
5931
  daemonPaths(this.opts.settings.getAgentDir()).seenUpdates,
@@ -5312,6 +5941,7 @@ export class TelegramNotificationDaemon {
5312
5941
  }
5313
5942
 
5314
5943
  async persistSeenUpdateIds(): Promise<void> {
5944
+ if (this.validationMode()) return;
5315
5945
  const paths = daemonPaths(this.opts.settings.getAgentDir());
5316
5946
  await ensureDir(this.fsImpl, paths.dir);
5317
5947
  await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
@@ -5341,6 +5971,12 @@ export class TelegramNotificationDaemon {
5341
5971
  }
5342
5972
  }
5343
5973
  private async reserveSeenUpdateId(updateId: number): Promise<boolean> {
5974
+ if (this.validationMode()) {
5975
+ if (this.dispatchState.seenUpdateIds.has(updateId)) return false;
5976
+ this.dispatchState.seenUpdateIds.add(updateId);
5977
+ this.pruneSeenUpdateIds();
5978
+ return true;
5979
+ }
5344
5980
  if (!Number.isSafeInteger(updateId) || updateId < 0) return false;
5345
5981
  const candidate = new Set(this.dispatchState.seenUpdateIds);
5346
5982
  candidate.add(updateId);
@@ -5362,6 +5998,10 @@ export class TelegramNotificationDaemon {
5362
5998
  }
5363
5999
 
5364
6000
  private async releaseSeenUpdateId(updateId: number): Promise<void> {
6001
+ if (this.validationMode()) {
6002
+ this.dispatchState.seenUpdateIds.delete(updateId);
6003
+ return;
6004
+ }
5365
6005
  if (!this.dispatchState.seenUpdateIds.has(updateId)) return;
5366
6006
  const candidate = new Set(this.dispatchState.seenUpdateIds);
5367
6007
  candidate.delete(updateId);
@@ -5447,28 +6087,31 @@ export class TelegramNotificationDaemon {
5447
6087
  } catch {}
5448
6088
  }
5449
6089
  }
5450
- if (permanentlyMissingRoots.length > 0) {
6090
+ if (!this.validationMode()) {
6091
+ if (permanentlyMissingRoots.length > 0) {
6092
+ try {
6093
+ await pruneMissingNotificationRoots({
6094
+ settings: this.opts.settings,
6095
+ fs: this.fsImpl,
6096
+ candidates: permanentlyMissingRoots,
6097
+ });
6098
+ } catch (error) {
6099
+ logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`);
6100
+ }
6101
+ }
6102
+ // Best-effort periodic reap of retained exact-unlink quarantines (#2956).
5451
6103
  try {
5452
- await pruneMissingNotificationRoots({
6104
+ await reapStaleNotificationArtifacts({
5453
6105
  settings: this.opts.settings,
5454
6106
  fs: this.fsImpl,
5455
- candidates: permanentlyMissingRoots,
6107
+ now: this.opts.now,
6108
+ pidAlive: this.opts.pidAlive,
5456
6109
  });
5457
6110
  } catch (error) {
5458
- logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`);
6111
+ logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
5459
6112
  }
5460
6113
  }
5461
- // Best-effort periodic reap of retained exact-unlink quarantines (#2956).
5462
- try {
5463
- await reapStaleNotificationArtifacts({
5464
- settings: this.opts.settings,
5465
- fs: this.fsImpl,
5466
- now: this.opts.now,
5467
- pidAlive: this.opts.pidAlive,
5468
- });
5469
- } catch (error) {
5470
- logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
5471
- }
6114
+
5472
6115
  if (allRootsReadable) {
5473
6116
  for (const sessionId of this.topics.sessionIds()) {
5474
6117
  const owner = this.logicalSessionOwners.get(sessionId);
@@ -5521,7 +6164,7 @@ export class TelegramNotificationDaemon {
5521
6164
  replayQueue: [],
5522
6165
  };
5523
6166
  this.sessions.set(sessionId, session);
5524
- if (this.topics.get(sessionId)) this.preservedInitiatorTopics.add(sessionId);
6167
+ if (this.topics.get(sessionId)?.authorityState === "active") this.preservedInitiatorTopics.add(sessionId);
5525
6168
 
5526
6169
  // Bidirectional capability advertisement: announce client_ping_pong once the
5527
6170
  // socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
@@ -5566,7 +6209,9 @@ export class TelegramNotificationDaemon {
5566
6209
  void (async () => {
5567
6210
  if (this.#logicalSessionId(session) !== sessionId) return;
5568
6211
  const topic = this.topics.get(sessionId);
5569
- if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
6212
+ if (!topic || topic.bindingMalformed) return;
6213
+ if (topic.authorityState === "disconnect_grace" && !(await this.#renewTopicLease(sessionId))) return;
6214
+ if (topic.authorityState !== "active") return;
5570
6215
  const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
5571
6216
  if (topicLease?.topicId === topic.topicId) await this.flushPendingThreadedFrames(sessionId, topicLease);
5572
6217
  })().catch(err =>
@@ -5579,11 +6224,13 @@ export class TelegramNotificationDaemon {
5579
6224
  // Identity guard: a delayed frame from a superseded socket must not act
5580
6225
  // through the replacement session.
5581
6226
  if (this.sessions.get(sessionId) !== session) return;
5582
- void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(err => {
5583
- // Surface frame-handling failures (e.g. a rejected ask sendMessage) to
5584
- // the daemon log instead of an invisible unhandled rejection.
5585
- logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
5586
- });
6227
+ void this.effects
6228
+ .admit(() => this.handleSessionMessage(session, JSON.parse(String(ev.data))))
6229
+ .catch(err => {
6230
+ // Surface frame-handling failures (e.g. a rejected ask sendMessage) to
6231
+ // the daemon log instead of an invisible unhandled rejection.
6232
+ logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
6233
+ });
5587
6234
  });
5588
6235
  ws.addEventListener("close", () => {
5589
6236
  this.dropSession(session, "socket_closed");
@@ -5610,6 +6257,11 @@ export class TelegramNotificationDaemon {
5610
6257
  this.dropSession(session, "liveness_timeout");
5611
6258
  return;
5612
6259
  }
6260
+ const logicalSessionId = this.#logicalSessionId(session);
6261
+ if (session.logicalSessionIdTrusted)
6262
+ void this.#renewTopicLease(logicalSessionId).then(renewed => {
6263
+ if (!renewed) this.dropSession(session, "topic_lease_lost");
6264
+ });
5613
6265
  if (session.ws.readyState === WebSocket.OPEN) {
5614
6266
  const nonce = `${session.sessionId}:${t}:${Math.random().toString(36).slice(2)}`;
5615
6267
  session.awaitingNonce = nonce;
@@ -5753,12 +6405,24 @@ export class TelegramNotificationDaemon {
5753
6405
  }
5754
6406
 
5755
6407
  private dropSession(session: SessionSocket, reason: string): void {
5756
- // A dropped socket must lose authority before any teardown-triggered asynchronous
6408
+ // Capture the exact callback lease before revoking recovery authority. A
6409
+ // predecessor transport can remain keyed by its own session id after a
6410
+ // successor becomes the logical owner, so logical-session revocation
6411
+ // would incorrectly delete the successor's freshly accepted aliases.
6412
+ const callbackLease = {
6413
+ session,
6414
+ token: session.recoveryLease?.token ?? 0,
6415
+ logicalSessionId: this.#logicalSessionId(session),
6416
+ };
6417
+ // A dropped socket must lose authority before any teardown-triggered asynchronous
5757
6418
  // work can observe it. Its immutable lease token remains captured by queued work,
5758
6419
  // but is no longer usable.
5759
6420
  if (session.recoveryLease) session.recoveryLease = { ...session.recoveryLease, state: "rejected" };
5760
6421
  const isCurrentSession = this.sessions.get(session.sessionId) === session;
5761
- if (isCurrentSession) this.droppedSessions.add(session);
6422
+ if (isCurrentSession) {
6423
+ this.revokeCallbackAliases(callbackLease);
6424
+ this.droppedSessions.add(session);
6425
+ }
5762
6426
  if (isCurrentSession) this.cancelLegacyToolStartsForSession(session);
5763
6427
  if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
5764
6428
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
@@ -5771,6 +6435,7 @@ export class TelegramNotificationDaemon {
5771
6435
  } else {
5772
6436
  void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
5773
6437
  }
6438
+ if (isCurrentSession && session.logicalSessionIdTrusted) this.#releaseTopicLease(this.#logicalSessionId(session));
5774
6439
  if (isCurrentSession || reason === "session_closed") {
5775
6440
  this.deleteMessageRoutes(session.sessionId);
5776
6441
  }
@@ -6094,6 +6759,82 @@ export class TelegramNotificationDaemon {
6094
6759
  }
6095
6760
  }
6096
6761
  }
6762
+ #revokeCallbackAlias(alias: string): CallbackRoute | undefined {
6763
+ const route = this.aliasTable.get(alias);
6764
+ if (!route) return undefined;
6765
+ this.reissueBacklog.set(alias, route);
6766
+ this.aliasTable.delete(alias);
6767
+ this.#callbackAliasLeases.delete(alias);
6768
+ this.#callbackAliasPending.delete(alias);
6769
+ this.#callbackAliasTopics.delete(alias);
6770
+ return route;
6771
+ }
6772
+ #stageCallbackActivation(
6773
+ aliases: Iterable<string>,
6774
+ pending: { sessionId: string; actionId: string },
6775
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string },
6776
+ topicLease: TopicAuthorityLease | undefined,
6777
+ ): PromiseWithResolvers<boolean> {
6778
+ const activation = Promise.withResolvers<boolean>();
6779
+ const staged: StagedCallbackActivation = { pending, socketLease, topicLease, settled: activation.promise };
6780
+ for (const alias of aliases) this.#stagedCallbackActivations.set(alias, staged);
6781
+ return activation;
6782
+ }
6783
+
6784
+ revokeCallbackAliases(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): void {
6785
+ const actions = new Set<string>();
6786
+ for (const [alias, aliasLease] of this.#callbackAliasLeases) {
6787
+ if (
6788
+ aliasLease.session === socketLease.session &&
6789
+ aliasLease.token === socketLease.token &&
6790
+ aliasLease.logicalSessionId === socketLease.logicalSessionId
6791
+ ) {
6792
+ const route = this.#revokeCallbackAlias(alias);
6793
+ if (route) actions.add(route.actionId);
6794
+ }
6795
+ }
6796
+ if (actions.size === 0) return;
6797
+ const terminalization = this.effects.track(
6798
+ (async () => {
6799
+ await this.persistAliases();
6800
+ for (const actionId of actions) await this.reissuePendingAction(socketLease.logicalSessionId, actionId);
6801
+ })(),
6802
+ );
6803
+ void terminalization.catch(() => undefined);
6804
+ }
6805
+
6806
+ private async reissuePendingAction(sessionId: string, actionId: string): Promise<void> {
6807
+ const oldEntries = [...this.reissueBacklog.entries()].filter(
6808
+ ([, route]) => route.sessionId === sessionId && route.actionId === actionId,
6809
+ );
6810
+ const messageIds = new Set<number>();
6811
+ for (const [, route] of oldEntries) {
6812
+ if (String(route.chatId) === String(this.opts.chatId) && Number.isSafeInteger(route.messageId))
6813
+ messageIds.add(route.messageId!);
6814
+ }
6815
+ const terminalizedMessageIds = new Set<number>();
6816
+ for (const messageId of messageIds) {
6817
+ try {
6818
+ const terminalized = (await this.botApi.call("editMessageReplyMarkup", {
6819
+ chat_id: this.opts.chatId,
6820
+ message_id: messageId,
6821
+ reply_markup: { inline_keyboard: [] },
6822
+ })) as { ok?: unknown } | undefined;
6823
+ if (terminalized?.ok === true) terminalizedMessageIds.add(messageId);
6824
+ } catch {
6825
+ // Retain the durable receipt so a later replay can retry terminalization.
6826
+ }
6827
+ }
6828
+ for (const [alias, route] of oldEntries) {
6829
+ if (
6830
+ String(route.chatId) === String(this.opts.chatId) &&
6831
+ Number.isSafeInteger(route.messageId) &&
6832
+ terminalizedMessageIds.has(route.messageId!)
6833
+ )
6834
+ this.reissueBacklog.delete(alias);
6835
+ }
6836
+ await this.persistAliases();
6837
+ }
6097
6838
 
6098
6839
  #logicalSessionId(session: SessionSocket): string {
6099
6840
  return session.logicalSessionId ?? session.sessionId;
@@ -6123,8 +6864,10 @@ export class TelegramNotificationDaemon {
6123
6864
  lease.binding.endpointDigest === session.endpointDigest &&
6124
6865
  lease.binding.endpointGeneration === session.hostGeneration &&
6125
6866
  (!record ||
6126
- (record.authorityState !== "delete_pending" &&
6867
+ (record.authorityState === "active" &&
6127
6868
  !record.bindingMalformed &&
6869
+ record.leaseOwner === this.installationHostId &&
6870
+ (record.leaseExpiresAt ?? 0) > this.runtime.now() &&
6128
6871
  record.chatId === lease.binding.chatId &&
6129
6872
  record.endpointKey === lease.binding.endpointKey &&
6130
6873
  record.endpointDigest === lease.binding.endpointDigest &&
@@ -6208,14 +6951,86 @@ export class TelegramNotificationDaemon {
6208
6951
  ? undefined
6209
6952
  : { session, token: 0, logicalSessionId };
6210
6953
  }
6954
+ async #renewTopicLease(sessionId: string): Promise<boolean> {
6955
+ const record = this.topics.get(sessionId);
6956
+ if (!record) return true;
6957
+ const previous = {
6958
+ leaseOwner: record.leaseOwner,
6959
+ leaseHeartbeatAt: record.leaseHeartbeatAt,
6960
+ leaseExpiresAt: record.leaseExpiresAt,
6961
+ authorityState: record.authorityState,
6962
+ orphanedAt: record.orphanedAt,
6963
+ disconnectGraceExpiresAt: record.disconnectGraceExpiresAt,
6964
+ };
6965
+ return this.#persistTopicMutation(
6966
+ () =>
6967
+ this.topics.acquireLease(
6968
+ sessionId,
6969
+ this.installationHostId,
6970
+ this.runtime.now(),
6971
+ HEARTBEAT_TTL_MS,
6972
+ ORPHAN_TOPIC_GRACE_MS,
6973
+ ),
6974
+ () => {
6975
+ const current = this.topics.get(sessionId);
6976
+ if (!current) return;
6977
+ Object.assign(current, previous);
6978
+ if (previous.leaseOwner === undefined) delete current.leaseOwner;
6979
+ if (previous.leaseHeartbeatAt === undefined) delete current.leaseHeartbeatAt;
6980
+ if (previous.leaseExpiresAt === undefined) delete current.leaseExpiresAt;
6981
+ if (previous.orphanedAt === undefined) delete current.orphanedAt;
6982
+ if (previous.disconnectGraceExpiresAt === undefined) delete current.disconnectGraceExpiresAt;
6983
+ },
6984
+ );
6985
+ }
6986
+ #releaseTopicLease(sessionId: string): void {
6987
+ const record = this.topics.get(sessionId);
6988
+ if (!record) return;
6989
+ const previous = {
6990
+ leaseHeartbeatAt: record.leaseHeartbeatAt,
6991
+ leaseExpiresAt: record.leaseExpiresAt,
6992
+ authorityState: record.authorityState,
6993
+ orphanedAt: record.orphanedAt,
6994
+ disconnectGraceExpiresAt: record.disconnectGraceExpiresAt,
6995
+ };
6996
+ void this.#persistTopicMutation(
6997
+ () => {
6998
+ const owner = this.logicalSessionOwners.get(sessionId);
6999
+ return owner === undefined
7000
+ ? this.topics.releaseLeaseToGrace(
7001
+ sessionId,
7002
+ this.installationHostId,
7003
+ this.runtime.now(),
7004
+ ORPHAN_TOPIC_GRACE_MS,
7005
+ )
7006
+ : false;
7007
+ },
7008
+ () => {
7009
+ const current = this.topics.get(sessionId);
7010
+ if (!current) return;
7011
+ Object.assign(current, previous);
7012
+ if (previous.leaseHeartbeatAt === undefined) delete current.leaseHeartbeatAt;
7013
+ if (previous.leaseExpiresAt === undefined) delete current.leaseExpiresAt;
7014
+ if (previous.orphanedAt === undefined) delete current.orphanedAt;
7015
+ if (previous.disconnectGraceExpiresAt === undefined) delete current.disconnectGraceExpiresAt;
7016
+ },
7017
+ ).catch(() => undefined);
7018
+ }
6211
7019
 
6212
7020
  #authorizeLease(session: SessionSocket, logicalSessionId: string, binding: TopicEndpointBinding): void {
6213
7021
  const previousSessionId = this.#logicalSessionId(session);
6214
7022
  if (previousSessionId !== logicalSessionId && this.logicalSessionOwners.get(previousSessionId) === session)
6215
7023
  this.logicalSessionOwners.delete(previousSessionId);
6216
7024
  const previousOwner = this.logicalSessionOwners.get(logicalSessionId);
6217
- if (previousOwner && previousOwner !== session && previousOwner.recoveryLease)
7025
+ if (previousOwner && previousOwner !== session && previousOwner.recoveryLease) {
7026
+ const predecessorLease = {
7027
+ session: previousOwner,
7028
+ token: previousOwner.recoveryLease.token,
7029
+ logicalSessionId,
7030
+ };
6218
7031
  previousOwner.recoveryLease = { ...previousOwner.recoveryLease, state: "rejected" };
7032
+ this.revokeCallbackAliases(predecessorLease);
7033
+ }
6219
7034
  this.logicalSessionOwners.set(logicalSessionId, session);
6220
7035
  this.preservedInitiatorTopics.delete(logicalSessionId);
6221
7036
  session.logicalSessionId = logicalSessionId;
@@ -6229,8 +7044,18 @@ export class TelegramNotificationDaemon {
6229
7044
  }
6230
7045
 
6231
7046
  async #revokeAskAuthority(sessionId: string): Promise<void> {
7047
+ const actionIds = new Set<string>();
6232
7048
  for (const [alias, route] of this.aliasTable.entries()) {
6233
- if (route.sessionId === sessionId) this.aliasTable.delete(alias);
7049
+ if (route.sessionId !== sessionId) continue;
7050
+ this.reissueBacklog.set(alias, route);
7051
+ this.aliasTable.delete(alias);
7052
+ this.#callbackAliasLeases.delete(alias);
7053
+ this.#callbackAliasPending.delete(alias);
7054
+ this.#callbackAliasTopics.delete(alias);
7055
+ actionIds.add(route.actionId);
7056
+ }
7057
+ for (const route of this.reissueBacklog.values()) {
7058
+ if (route.sessionId === sessionId) actionIds.add(route.actionId);
6234
7059
  }
6235
7060
  for (const session of this.sessions.values()) {
6236
7061
  if (session.sessionId === sessionId || this.#logicalSessionId(session) === sessionId) {
@@ -6240,6 +7065,7 @@ export class TelegramNotificationDaemon {
6240
7065
  }
6241
7066
  }
6242
7067
  await this.persistAliases();
7068
+ for (const actionId of actionIds) await this.reissuePendingAction(sessionId, actionId);
6243
7069
  }
6244
7070
 
6245
7071
  #clearModelChoiceAliasesForSocket(session: SessionSocket): void {
@@ -6378,84 +7204,123 @@ export class TelegramNotificationDaemon {
6378
7204
  const binding = this.#endpointBinding(session);
6379
7205
  const pendingToken = this.nextSocketLeaseToken++;
6380
7206
  session.recoveryLease = { state: "pending", logicalSessionId: candidateSessionId, binding, token: pendingToken };
6381
- const claim = await this.#withRecoveryBindingClaim(async () => {
6382
- const existing = this.topics.get(candidateSessionId);
6383
- const hadDurableTopic = existing !== undefined;
6384
- const previousBinding = existing
6385
- ? {
6386
- chatId: existing.chatId,
6387
- endpointKey: existing.endpointKey,
6388
- endpointDigest: existing.endpointDigest,
6389
- endpointGeneration: existing.endpointGeneration,
6390
- endpointIncarnation: existing.endpointIncarnation,
6391
- }
6392
- : undefined;
6393
- const outcome = await this.#persistTopicMutation(
6394
- () =>
6395
- this.topics.get(candidateSessionId)
6396
- ? this.topics.bindEndpoint(
6397
- candidateSessionId,
6398
- binding,
6399
- this.#activeEndpointKeysFor(candidateSessionId, session),
6400
- allowEndpointRotation,
6401
- )
6402
- : "unchanged",
6403
- () => {
6404
- if (previousBinding) this.topics.restoreEndpointBinding(candidateSessionId, binding, previousBinding);
6405
- },
6406
- ).catch(() => "rejected" as const);
6407
- if (outcome === "rejected") {
6408
- if (session.recoveryLease?.token === pendingToken)
6409
- session.recoveryLease = {
6410
- state: "rejected",
6411
- logicalSessionId: candidateSessionId,
6412
- binding,
6413
- token: pendingToken,
6414
- };
6415
- return undefined;
6416
- }
6417
- const endpointAuthority = this.#endpointAuthority(binding, session);
6418
- const identitylessAdmissionAllows =
6419
- identitylessAdmission === undefined ||
6420
- (identitylessAdmission === "bootstrap"
6421
- ? (endpointAuthority.state === "none" && !this.topics.get(candidateSessionId)) ||
6422
- (endpointAuthority.state === "unique" &&
7207
+ let claim: { previousSessionId: string; hadDurableTopic: boolean } | undefined;
7208
+ try {
7209
+ claim = await this.#withRecoveryBindingClaim(async () => {
7210
+ const existing = this.topics.get(candidateSessionId);
7211
+ const hadDurableTopic = existing !== undefined;
7212
+ const previousBinding = existing
7213
+ ? {
7214
+ chatId: existing.chatId,
7215
+ endpointKey: existing.endpointKey,
7216
+ endpointDigest: existing.endpointDigest,
7217
+ endpointGeneration: existing.endpointGeneration,
7218
+ endpointIncarnation: existing.endpointIncarnation,
7219
+ }
7220
+ : undefined;
7221
+ let retiredTopicState: TopicRegistryState | undefined;
7222
+ const outcome = await this.#persistTopicMutation(
7223
+ () => {
7224
+ if (allowEndpointRotation && this.topics.get(candidateSessionId)?.authorityState === "inactive") {
7225
+ retiredTopicState = this.topics.serialize();
7226
+ this.topics.retireInactiveEndpointForSuccessor(candidateSessionId, binding);
7227
+ }
7228
+ return this.topics.get(candidateSessionId)
7229
+ ? this.topics.bindEndpoint(
7230
+ candidateSessionId,
7231
+ binding,
7232
+ this.#activeEndpointKeysFor(candidateSessionId, session),
7233
+ allowEndpointRotation,
7234
+ )
7235
+ : "unchanged";
7236
+ },
7237
+ () => {
7238
+ if (retiredTopicState) this.topics.replace(retiredTopicState);
7239
+ else if (previousBinding)
7240
+ this.topics.restoreEndpointBinding(candidateSessionId, binding, previousBinding);
7241
+ },
7242
+ ).catch(() => "rejected" as const);
7243
+ if (outcome === "rejected") {
7244
+ if (session.recoveryLease?.token === pendingToken)
7245
+ session.recoveryLease = {
7246
+ state: "rejected",
7247
+ logicalSessionId: candidateSessionId,
7248
+ binding,
7249
+ token: pendingToken,
7250
+ };
7251
+ return undefined;
7252
+ }
7253
+ if (!(await this.#renewTopicLease(candidateSessionId))) {
7254
+ if (session.recoveryLease?.token === pendingToken)
7255
+ session.recoveryLease = {
7256
+ state: "rejected",
7257
+ logicalSessionId: candidateSessionId,
7258
+ binding,
7259
+ token: pendingToken,
7260
+ };
7261
+ return undefined;
7262
+ }
7263
+ const endpointAuthority = this.#endpointAuthority(binding, session);
7264
+ const identitylessAdmissionAllows =
7265
+ identitylessAdmission === undefined ||
7266
+ (identitylessAdmission === "bootstrap"
7267
+ ? (endpointAuthority.state === "none" && !this.topics.get(candidateSessionId)) ||
7268
+ (endpointAuthority.state === "unique" &&
7269
+ endpointAuthority.sessionId === candidateSessionId &&
7270
+ this.topics.matchesEndpoint(candidateSessionId, binding))
7271
+ : endpointAuthority.state === "unique" &&
6423
7272
  endpointAuthority.sessionId === candidateSessionId &&
6424
- this.topics.matchesEndpoint(candidateSessionId, binding))
6425
- : endpointAuthority.state === "unique" &&
6426
- endpointAuthority.sessionId === candidateSessionId &&
6427
- this.topics.matchesEndpoint(candidateSessionId, binding));
6428
- if (
6429
- session.recoveryLease?.token !== pendingToken ||
6430
- session.recoveryLease.state !== "pending" ||
6431
- !this.#ownsLiveOpenEndpoint(session, binding) ||
6432
- !identitylessAdmissionAllows
6433
- ) {
6434
- if (session.recoveryLease?.token === pendingToken)
6435
- session.recoveryLease = {
6436
- state: "rejected",
6437
- logicalSessionId: candidateSessionId,
6438
- binding,
6439
- token: pendingToken,
6440
- };
6441
- return undefined;
6442
- }
6443
- const previousSessionId = this.#logicalSessionId(session);
6444
- if (previousSessionId !== candidateSessionId) await this.#terminalizeBtwTurnsForSession(session, true);
6445
- if (!this.#ownsLiveOpenEndpoint(session, binding)) {
6446
- if (session.recoveryLease?.token === pendingToken)
6447
- session.recoveryLease = {
6448
- state: "rejected",
6449
- logicalSessionId: candidateSessionId,
6450
- binding,
6451
- token: pendingToken,
6452
- };
6453
- return undefined;
6454
- }
6455
- this.#authorizeLease(session, candidateSessionId, binding);
6456
- return { previousSessionId, hadDurableTopic };
6457
- });
6458
- if (!claim) return false;
7273
+ this.topics.matchesEndpoint(candidateSessionId, binding));
7274
+ if (
7275
+ session.recoveryLease?.token !== pendingToken ||
7276
+ session.recoveryLease.state !== "pending" ||
7277
+ !this.#ownsLiveOpenEndpoint(session, binding) ||
7278
+ !identitylessAdmissionAllows
7279
+ ) {
7280
+ if (session.recoveryLease?.token === pendingToken)
7281
+ session.recoveryLease = {
7282
+ state: "rejected",
7283
+ logicalSessionId: candidateSessionId,
7284
+ binding,
7285
+ token: pendingToken,
7286
+ };
7287
+ return undefined;
7288
+ }
7289
+ const previousSessionId = this.#logicalSessionId(session);
7290
+ if (previousSessionId !== candidateSessionId) await this.#terminalizeBtwTurnsForSession(session, true);
7291
+ if (!this.#ownsLiveOpenEndpoint(session, binding)) {
7292
+ if (session.recoveryLease?.token === pendingToken)
7293
+ session.recoveryLease = {
7294
+ state: "rejected",
7295
+ logicalSessionId: candidateSessionId,
7296
+ binding,
7297
+ token: pendingToken,
7298
+ };
7299
+ return undefined;
7300
+ }
7301
+ this.#authorizeLease(session, candidateSessionId, binding);
7302
+ return { previousSessionId, hadDurableTopic };
7303
+ });
7304
+ } catch {
7305
+ if (session.recoveryLease?.token === pendingToken)
7306
+ session.recoveryLease = {
7307
+ state: "rejected",
7308
+ logicalSessionId: candidateSessionId,
7309
+ binding,
7310
+ token: pendingToken,
7311
+ };
7312
+ return false;
7313
+ }
7314
+ if (!claim) {
7315
+ if (session.recoveryLease?.token === pendingToken)
7316
+ session.recoveryLease = {
7317
+ state: "rejected",
7318
+ logicalSessionId: candidateSessionId,
7319
+ binding,
7320
+ token: pendingToken,
7321
+ };
7322
+ return false;
7323
+ }
6459
7324
  const { previousSessionId } = claim;
6460
7325
  if (preserveTransportTopic && previousSessionId !== candidateSessionId) {
6461
7326
  this.legacyTopicOwners.set(previousSessionId, session);
@@ -6464,7 +7329,7 @@ export class TelegramNotificationDaemon {
6464
7329
  if (candidateSessionId === session.sessionId)
6465
7330
  void (async () => {
6466
7331
  const topic = this.topics.get(candidateSessionId);
6467
- if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
7332
+ if (topic?.authorityState !== "active" || topic.bindingMalformed) return;
6468
7333
  const topicLease = this.topicAuthorityLeaseFromRegistry(candidateSessionId);
6469
7334
  if (topicLease?.topicId === topic.topicId)
6470
7335
  await this.flushPendingThreadedFrames(candidateSessionId, topicLease);
@@ -6539,12 +7404,12 @@ export class TelegramNotificationDaemon {
6539
7404
  const identityKey = this.topicIdentityKey(msg);
6540
7405
  const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
6541
7406
  const rememberedTopic = remembered ? this.topics.get(remembered) : undefined;
6542
- if (remembered && rememberedTopic && rememberedTopic.authorityState !== "delete_pending") return remembered;
7407
+ if (remembered && rememberedTopic && rememberedTopic.authorityState === "active") return remembered;
6543
7408
  if (!identityKey) return undefined;
6544
7409
  const base = this.topicIdentityBase(msg);
6545
7410
  for (const sessionId of this.topics.sessionIds()) {
6546
7411
  const topic = this.topics.get(sessionId);
6547
- if (topic?.authorityState === "delete_pending") continue;
7412
+ if (topic?.authorityState !== "active") continue;
6548
7413
  const nameMatchesLegacyIdentity =
6549
7414
  base !== undefined && (topic?.name === base || topic?.name?.startsWith(`${base} - `));
6550
7415
  if (topic?.identityKey === identityKey || nameMatchesLegacyIdentity) {
@@ -6823,21 +7688,29 @@ export class TelegramNotificationDaemon {
6823
7688
  }
6824
7689
 
6825
7690
  private async topicAuthorityLease(sessionId: string): Promise<TopicAuthorityLease | undefined> {
6826
- if (!(await this.pairedChatIsPrivate())) return undefined;
7691
+ if (!(await this.pairedChatAllowsTopics())) return undefined;
6827
7692
  return this.topicAuthorityLeaseFromRegistry(sessionId);
6828
7693
  }
6829
7694
 
6830
7695
  private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
6831
7696
  const topic = this.topics.get(sessionId);
6832
- if (!topic || !this.topics.isActiveUnambiguous(sessionId) || topic.bindingMalformed) return undefined;
7697
+ if (
7698
+ !topic ||
7699
+ !this.topics.isActiveUnambiguous(sessionId) ||
7700
+ topic.bindingMalformed ||
7701
+ topic.leaseOwner !== this.installationHostId ||
7702
+ (topic.leaseExpiresAt ?? 0) <= this.runtime.now()
7703
+ )
7704
+ return undefined;
6833
7705
  return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
6834
7706
  }
6835
-
6836
7707
  private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
6837
7708
  const topic = this.topics.get(lease.sessionId);
6838
7709
  return (
6839
7710
  this.topics.isActiveUnambiguous(lease.sessionId) &&
6840
7711
  !topic?.bindingMalformed &&
7712
+ topic?.leaseOwner === this.installationHostId &&
7713
+ (topic.leaseExpiresAt ?? 0) > this.runtime.now() &&
6841
7714
  topic?.topicId === lease.topicId &&
6842
7715
  (topic.authorityEpoch ?? 0) === lease.authorityEpoch
6843
7716
  );
@@ -6884,7 +7757,7 @@ export class TelegramNotificationDaemon {
6884
7757
  ): void {
6885
7758
  const logicalSessionId = this.#logicalSessionId(session);
6886
7759
  const socketLease = this.#socketLease(session, logicalSessionId);
6887
- if (!socketLease) {
7760
+ if (!socketLease && !session.logicalSessionIdTrusted) {
6888
7761
  this.failLegacyToolStart(toolActivity);
6889
7762
  return;
6890
7763
  }
@@ -6898,6 +7771,7 @@ export class TelegramNotificationDaemon {
6898
7771
  send,
6899
7772
  msg,
6900
7773
  logicalSessionId,
7774
+ session,
6901
7775
  socketLease,
6902
7776
  ...(toolActivity ? { toolActivity } : {}),
6903
7777
  });
@@ -6913,15 +7787,17 @@ export class TelegramNotificationDaemon {
6913
7787
  if (!frames || frames.length === 0) return;
6914
7788
  this.pendingThreadedFrames.delete(sessionId);
6915
7789
  for (const frame of frames) {
7790
+ const socketLease = frame.socketLease ?? this.#socketLease(frame.session, sessionId);
6916
7791
  if (
6917
7792
  frame.logicalSessionId !== sessionId ||
6918
- !this.#leaseTokenAllows(frame.socketLease) ||
7793
+ !socketLease ||
7794
+ !this.#leaseTokenAllows(socketLease) ||
6919
7795
  (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled !== true)
6920
7796
  ) {
6921
7797
  this.failLegacyToolStart(frame.toolActivity);
6922
7798
  continue;
6923
7799
  }
6924
- await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, frame.socketLease);
7800
+ await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, socketLease);
6925
7801
  }
6926
7802
  }
6927
7803
 
@@ -6937,15 +7813,16 @@ export class TelegramNotificationDaemon {
6937
7813
  session?: SessionSocket,
6938
7814
  creationLease?: { session: SessionSocket; token: number; logicalSessionId: string },
6939
7815
  ): Promise<string | undefined> {
6940
- if (!(await this.pairedChatIsPrivate())) return undefined;
7816
+ if (!(await this.pairedChatAllowsTopics())) return undefined;
6941
7817
  if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
6942
7818
  const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
6943
7819
  if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
6944
7820
  const creationEndpointKey = session?.endpointDigest ?? session?.endpointKey ?? "unbound";
6945
- if (this.#malformedTopicCreateEndpoints.get(sessionId) === creationEndpointKey)
7821
+ const malformedCreateEndpoint = this.#malformedTopicCreateEndpoints.get(sessionId);
7822
+ if (malformedCreateEndpoint === "unbound" || malformedCreateEndpoint === creationEndpointKey)
6946
7823
  throw new Error("createForumTopic: invalid message_thread_id");
6947
7824
  const existing = this.topics.get(sessionId);
6948
- if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
7825
+ if (existing && (existing.authorityState !== "active" || existing.bindingMalformed)) return undefined;
6949
7826
  if (existing) return existing.topicId;
6950
7827
  if (
6951
7828
  session &&
@@ -6958,9 +7835,9 @@ export class TelegramNotificationDaemon {
6958
7835
  const creationLeaseEpoch = this.topics.authorityEpoch(sessionId);
6959
7836
  let acceptedTopicId: string | undefined;
6960
7837
  let acceptedTopicCompensated = false;
6961
- let acceptedTopicDeleteAttempted = false;
7838
+ let acceptedTopicArchiveAttempted = false;
6962
7839
  let creationSuppressed = false;
6963
- /** User-created topicId adopted in this callback, if any (for sidecar reconciliation). */
7840
+ let creationRejected = false;
6964
7841
  let adoptedTopicId: number | undefined;
6965
7842
  const adoptionIntentCandidate = this.#adoptionIntents.bySession(sessionId);
6966
7843
  try {
@@ -7010,57 +7887,52 @@ export class TelegramNotificationDaemon {
7010
7887
  };
7011
7888
  const tid = response.result?.message_thread_id;
7012
7889
  if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) {
7013
- if (response.ok === true) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
7890
+ creationRejected = response.ok === false;
7891
+ if (!creationRejected) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
7014
7892
  throw new Error("createForumTopic: invalid message_thread_id");
7015
7893
  }
7016
7894
  acceptedTopicId = String(tid);
7017
7895
  this.#malformedTopicCreateEndpoints.delete(sessionId);
7018
7896
  if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
7019
- const fencedCreate = this.topics.fenceAcceptedCreateForLease(
7020
- sessionId,
7021
- acceptedTopicId,
7022
- creationLeaseEpoch,
7023
- this.opts.now,
7024
- name,
7025
- creationBinding,
7026
- );
7027
- if (!fencedCreate) throw new Error("topic authority was revoked during creation");
7028
- // Epoch held when the compensating delete is dispatched below; a
7029
- // concurrent re-fence must not be settled by this delete's result.
7030
- const fencedCreateEpoch = fencedCreate.authorityEpoch ?? 0;
7897
+ if (
7898
+ !this.topics.fenceAcceptedCreateForLease(
7899
+ sessionId,
7900
+ acceptedTopicId,
7901
+ creationLeaseEpoch,
7902
+ this.installationHostId,
7903
+ this.opts.now,
7904
+ name,
7905
+ creationBinding,
7906
+ undefined,
7907
+ String(this.opts.chatId),
7908
+ )
7909
+ )
7910
+ throw new Error("topic authority was revoked during creation");
7031
7911
  try {
7032
7912
  await this.persistTopics();
7033
- } finally {
7034
- acceptedTopicDeleteAttempted = true;
7035
- const deletion = await this.botApi.call("deleteForumTopic", {
7036
- chat_id: this.opts.chatId,
7037
- message_thread_id: tid,
7038
- });
7039
- // Remote compensation succeeded, but the transaction is not complete
7040
- // until the registry clear is durable. `acceptedTopicCompensated` is
7041
- // what tells outer recovery to stop supervising the fence, so it is
7042
- // set only after the phase-2 commit: a failed clear persist leaves
7043
- // the fence supervised instead of stranding a cleared memory state
7044
- // against a `delete_pending` disk state.
7045
- if (topicDeleteSettled(deletion)) {
7046
- const settled = this.topics.settleDelete(sessionId, acceptedTopicId, fencedCreateEpoch);
7047
- if (!settled) {
7048
- this.#superviseCompensationFence(sessionId);
7049
- await this.#persistTopicsWithRetry().catch(() => undefined);
7050
- } else
7051
- try {
7052
- await this.persistTopics();
7053
- this.topics.commitSettledDelete(settled);
7054
- acceptedTopicCompensated = true;
7055
- } catch {
7056
- this.topics.rollbackSettledDelete(settled);
7057
- this.#superviseCompensationFence(sessionId);
7058
- await this.#persistTopicsWithRetry().catch(() => undefined);
7059
- }
7060
- } else {
7061
- this.#superviseCompensationFence(sessionId);
7062
- await this.#persistTopicsWithRetry().catch(() => undefined);
7913
+ } catch (error) {
7914
+ this.topics.scheduleArchiveRetry(
7915
+ sessionId,
7916
+ this.runtime.now(),
7917
+ "archive fence persistence failed",
7918
+ );
7919
+ try {
7920
+ await this.#persistTopicsWithRetry();
7921
+ } catch {
7922
+ await this.#superviseCompensationFence(sessionId);
7063
7923
  }
7924
+ throw error;
7925
+ }
7926
+ if (
7927
+ this.#acceptedCreateArchiveFenceAllows(
7928
+ sessionId,
7929
+ acceptedTopicId,
7930
+ creationLeaseEpoch,
7931
+ creationBinding,
7932
+ )
7933
+ ) {
7934
+ acceptedTopicArchiveAttempted = true;
7935
+ acceptedTopicCompensated = (await this.archiveTopic(sessionId, undefined, true)) === "settled";
7064
7936
  }
7065
7937
  throw new Error("topic authority was revoked during creation");
7066
7938
  }
@@ -7073,9 +7945,8 @@ export class TelegramNotificationDaemon {
7073
7945
  session,
7074
7946
  adoptionIntentCandidate ? "user_created" : undefined,
7075
7947
  );
7076
- // Adoption commit success: remove the durable sidecar. A cleanup failure
7077
- // cannot roll back the committed record; topic success is retained and the
7078
- // stale sidecar is left for startup reconciliation to remove safely.
7948
+ // Adoption commit success removes only the sidecar. The committed
7949
+ // user-created topic remains retained even when sidecar cleanup fails.
7079
7950
  if (adoptedTopicId !== undefined) {
7080
7951
  if (adoptionIntentCandidate)
7081
7952
  logger.info(
@@ -7090,6 +7961,10 @@ export class TelegramNotificationDaemon {
7090
7961
  );
7091
7962
  }
7092
7963
  }
7964
+ // Publish the durable host lease before rechecking the captured socket
7965
+ // lease. The recheck consults the registry once a record exists, so the
7966
+ // newly-created record must already authorize this host.
7967
+ if (!(await this.#renewTopicLease(sessionId))) return undefined;
7093
7968
  // getOrCreateTopic deduplicates callers, so an accepted create can be
7094
7969
  // observed by a successor after its initiating socket was revoked. Check
7095
7970
  // the immutable lease again before exposing that record to frame delivery.
@@ -7111,78 +7986,100 @@ export class TelegramNotificationDaemon {
7111
7986
  sessionId,
7112
7987
  rec.topicId,
7113
7988
  creationLeaseEpoch,
7989
+ this.installationHostId,
7114
7990
  this.opts.now,
7115
7991
  name,
7116
7992
  creationBinding,
7993
+ undefined,
7994
+ String(this.opts.chatId),
7117
7995
  )
7118
- )
7119
- await this.deleteTopic(sessionId, undefined, true);
7996
+ ) {
7997
+ try {
7998
+ await this.persistTopics();
7999
+ } catch {
8000
+ this.#superviseCompensationFence(sessionId);
8001
+ return undefined;
8002
+ }
8003
+ if (this.#acceptedCreateArchiveFenceAllows(sessionId, rec.topicId, creationLeaseEpoch, creationBinding))
8004
+ await this.archiveTopic(sessionId, undefined, true);
8005
+ }
7120
8006
  return undefined;
7121
8007
  }
7122
8008
  return rec.topicId;
7123
8009
  } catch (err) {
7124
- if (creationSuppressed || err instanceof ThreadedModeCapabilityRefusal) return undefined;
7125
- // Adoption commit failure: release the in-memory claim so a retry can
7126
- // re-claim, and retain the durable sidecar (never delete the user topic).
7127
- if (adoptedTopicId !== undefined) {
7128
- this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId);
8010
+ if (adoptedTopicId !== undefined) this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId);
8011
+ if (adoptionIntentCandidate) this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch);
8012
+ if (creationSuppressed || creationRejected || err instanceof ThreadedModeCapabilityRefusal) {
8013
+ if (this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch)) await this.persistTopics();
8014
+ return undefined;
8015
+ }
8016
+ const revokedAcceptedRecord = acceptedTopicId ? this.topics.get(sessionId) : undefined;
8017
+ if (
8018
+ revokedAcceptedRecord !== undefined &&
8019
+ revokedAcceptedRecord.topicId === acceptedTopicId &&
8020
+ revokedAcceptedRecord.authorityState === "archive_pending" &&
8021
+ !this.topics.archiveAuthorityAllows(
8022
+ sessionId,
8023
+ this.installationHostId,
8024
+ this.opts.chatId,
8025
+ this.runtime.now(),
8026
+ )
8027
+ ) {
8028
+ if (
8029
+ revokedAcceptedRecord.chatId === undefined &&
8030
+ revokedAcceptedRecord.endpointKey === undefined &&
8031
+ revokedAcceptedRecord.endpointDigest === undefined
8032
+ )
8033
+ revokedAcceptedRecord.chatId = String(this.opts.chatId);
8034
+ this.topics.beginArchive(sessionId, this.installationHostId, this.runtime.now());
8035
+ await this.persistTopics();
7129
8036
  }
7130
8037
  if (
7131
8038
  acceptedTopicId &&
7132
8039
  !acceptedTopicCompensated &&
7133
- !acceptedTopicDeleteAttempted &&
7134
- this.topics.get(sessionId)?.authorityState !== "delete_pending"
8040
+ !acceptedTopicArchiveAttempted &&
8041
+ this.topics.get(sessionId)?.authorityState !== "archive_pending"
7135
8042
  ) {
7136
8043
  // A failed initial commit must never make compensation conditional on
7137
8044
  // successfully publishing its fence.
7138
- const fencedCompensation = this.topics.fenceAcceptedCreateForLease(
7139
- sessionId,
7140
- acceptedTopicId,
7141
- creationLeaseEpoch,
7142
- this.opts.now,
7143
- name,
7144
- creationBinding,
7145
- );
7146
- if (fencedCompensation) {
7147
- // Epoch held when the compensating delete is dispatched below; a
7148
- // concurrent re-fence must not be settled by this delete's result.
7149
- const fencedCompensationEpoch = fencedCompensation.authorityEpoch ?? 0;
8045
+ if (
8046
+ this.topics.fenceAcceptedCreateForLease(
8047
+ sessionId,
8048
+ acceptedTopicId,
8049
+ creationLeaseEpoch,
8050
+ this.installationHostId,
8051
+ this.opts.now,
8052
+ name,
8053
+ creationBinding,
8054
+ undefined,
8055
+ String(this.opts.chatId),
8056
+ )
8057
+ ) {
7150
8058
  try {
7151
8059
  await this.#persistTopicsWithRetry();
7152
8060
  } catch {
7153
- this.#superviseCompensationFence(sessionId);
8061
+ await this.#superviseCompensationFence(sessionId);
7154
8062
  }
7155
8063
 
7156
- try {
7157
- const deletion = await this.botApi.call("deleteForumTopic", {
7158
- chat_id: this.opts.chatId,
7159
- message_thread_id: Number(acceptedTopicId),
7160
- });
7161
- if (topicDeleteSettled(deletion)) {
7162
- const settled = this.topics.settleDelete(sessionId, acceptedTopicId, fencedCompensationEpoch);
7163
- if (!settled) {
7164
- this.#superviseCompensationFence(sessionId);
7165
- await this.#persistTopicsWithRetry().catch(() => undefined);
7166
- } else
7167
- try {
7168
- await this.persistTopics();
7169
- this.topics.commitSettledDelete(settled);
7170
- } catch {
7171
- this.topics.rollbackSettledDelete(settled);
7172
- this.#superviseCompensationFence(sessionId);
7173
- await this.#persistTopicsWithRetry().catch(() => undefined);
7174
- }
7175
- } else {
8064
+ if (
8065
+ this.#acceptedCreateArchiveFenceAllows(
8066
+ sessionId,
8067
+ acceptedTopicId,
8068
+ creationLeaseEpoch,
8069
+ creationBinding,
8070
+ )
8071
+ ) {
8072
+ try {
8073
+ acceptedTopicArchiveAttempted = true;
8074
+ acceptedTopicCompensated = (await this.archiveTopic(sessionId, undefined, true)) === "settled";
8075
+ } catch {
7176
8076
  this.#superviseCompensationFence(sessionId);
7177
8077
  await this.#persistTopicsWithRetry().catch(() => undefined);
7178
8078
  }
7179
- } catch {
7180
- this.#superviseCompensationFence(sessionId);
7181
- await this.#persistTopicsWithRetry().catch(() => undefined);
7182
8079
  }
7183
8080
  }
7184
8081
  }
7185
- if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicDeleteAttempted) {
8082
+ if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicArchiveAttempted) {
7186
8083
  this.#superviseCompensationFence(sessionId);
7187
8084
  await this.#persistTopicsWithRetry().catch(() => undefined);
7188
8085
  }
@@ -7211,24 +8108,75 @@ export class TelegramNotificationDaemon {
7211
8108
  if (!this.topicPastOrphanGrace(sessionId)) return;
7212
8109
  const currentOwner = this.logicalSessionOwners.get(sessionId);
7213
8110
  if (currentOwner && this.#leaseAllows(currentOwner, sessionId)) return;
7214
- await this.deleteTopic(sessionId);
8111
+ await this.archiveTopic(sessionId);
7215
8112
  });
7216
8113
  }
7217
8114
 
7218
8115
  /** Best-effort delete of a session topic once its local notification endpoint shuts down. */
7219
- private async deleteTopic(
8116
+ /** Join all close, compensation, orphan, and restart callers for one session. */
8117
+ private archiveTopic(
7220
8118
  sessionId: string,
7221
8119
  socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
7222
- deleteFenceAlreadyPublished = false,
8120
+ archiveFenceAlreadyPublished = false,
7223
8121
  ): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
7224
- let record = deleteFenceAlreadyPublished ? this.topics.get(sessionId) : this.topics.beginDelete(sessionId);
7225
- // Authority epoch held for this delete. Captured before any dispatch so a
7226
- // concurrent scan/close re-fence of the same session cannot be settled by
7227
- // this delete's definite result.
7228
- const dispatchedAuthorityEpoch = this.topics.authorityEpoch(sessionId);
8122
+ const active = this.archiveFlights.get(sessionId);
8123
+ if (active) return active;
8124
+ const flight = this.#archiveTopicOnce(sessionId, socketLease, archiveFenceAlreadyPublished);
8125
+ this.archiveFlights.set(sessionId, flight);
8126
+ void flight.then(
8127
+ () => {
8128
+ if (this.archiveFlights.get(sessionId) === flight) this.archiveFlights.delete(sessionId);
8129
+ },
8130
+ () => {
8131
+ if (this.archiveFlights.get(sessionId) === flight) this.archiveFlights.delete(sessionId);
8132
+ },
8133
+ );
8134
+ return flight;
8135
+ }
8136
+
8137
+ /** One durable archive fence and at most one remote close dispatch per flight. */
8138
+ async #archiveTopicOnce(
8139
+ sessionId: string,
8140
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
8141
+ archiveFenceAlreadyPublished = false,
8142
+ ): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
8143
+ if (!(await this.pairedChatAllowsTopics())) return "pre_dispatch_cancelled";
8144
+ const existing = this.topics.get(sessionId);
8145
+ // A completed archive retains an inactive record as historical evidence.
8146
+ // Re-archiving it would duplicate the irreversible remote close request.
8147
+ if (existing?.authorityState === "inactive") return "settled";
8148
+ if (existing?.topicOrigin === "user_created") {
8149
+ if (existing.authorityState === "archive_pending") {
8150
+ const archiveSnapshot = this.topics.captureArchiveAuthority(sessionId);
8151
+ const authorityEpoch = existing.authorityEpoch ?? Number.MAX_SAFE_INTEGER;
8152
+ if (!this.topics.settleArchive(sessionId, existing.topicId, authorityEpoch)) return "post_dispatch_pending";
8153
+ try {
8154
+ await this.persistTopics();
8155
+ } catch {
8156
+ this.topics.restoreArchiveFence(archiveSnapshot);
8157
+ await this.#persistTopicsWithRetry().catch(() => undefined);
8158
+ return "post_dispatch_pending";
8159
+ }
8160
+ }
8161
+ return "settled";
8162
+ }
8163
+ const archiveSnapshot = this.topics.captureArchiveAuthority(sessionId);
8164
+ let record = archiveFenceAlreadyPublished
8165
+ ? existing
8166
+ : this.topics.beginArchive(sessionId, this.installationHostId, this.runtime.now());
7229
8167
  if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
7230
- await this.persistTopics();
8168
+ if (
8169
+ record &&
8170
+ !this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
8171
+ )
8172
+ return "pre_dispatch_cancelled";
8173
+ if (!archiveFenceAlreadyPublished) await this.persistTopics();
7231
8174
  if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
8175
+ if (
8176
+ record &&
8177
+ !this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
8178
+ )
8179
+ return "pre_dispatch_cancelled";
7232
8180
  await this.#revokeAskAuthority(sessionId);
7233
8181
  this.deleteMessageRoutes(sessionId);
7234
8182
  this.#clearModelChoiceAliases(sessionId);
@@ -7237,7 +8185,17 @@ export class TelegramNotificationDaemon {
7237
8185
  record = this.topics.get(sessionId);
7238
8186
  await this.persistTopics();
7239
8187
  if (!record) return "settled";
8188
+ if (
8189
+ !this.topics.archiveAuthorityAllows(
8190
+ sessionId,
8191
+ this.installationHostId,
8192
+ this.opts.chatId,
8193
+ this.runtime.now(),
8194
+ )
8195
+ )
8196
+ return "pre_dispatch_cancelled";
7240
8197
  }
8198
+ const dispatchedAuthorityEpoch = record.authorityEpoch ?? Number.MAX_SAFE_INTEGER;
7241
8199
  const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
7242
8200
  for (const item of removed) {
7243
8201
  if (item.payload.selectedAck)
@@ -7247,43 +8205,26 @@ export class TelegramNotificationDaemon {
7247
8205
  try {
7248
8206
  await this.flushPool();
7249
8207
  if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
7250
- if (record.topicOrigin === "user_created") {
7251
- // Phase 1: drop the record but keep the topic id quarantined. Routes are
7252
- // only republished by `commitSettledDelete` once the clear is durable.
7253
- const settled = this.topics.settleDelete(sessionId, record.topicId, dispatchedAuthorityEpoch);
7254
- if (!settled) {
7255
- await this.#persistTopicsWithRetry().catch(() => undefined);
7256
- return "post_dispatch_pending";
7257
- }
7258
- for (const k of [...this.liveMessages.keys()])
7259
- if (k.startsWith(`${sessionId}:`)) {
7260
- this.liveMessages.delete(k);
7261
- this.toolActivityOwners.delete(k);
7262
- }
7263
- this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
7264
- if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
7265
- });
7266
- this.pendingThreadedFrames.delete(sessionId);
7267
- try {
7268
- await this.persistTopics();
7269
- this.topics.commitSettledDelete(settled);
7270
- return "settled";
7271
- } catch {
7272
- this.topics.rollbackSettledDelete(settled);
7273
- await this.#persistTopicsWithRetry().catch(() => undefined);
7274
- return "post_dispatch_pending";
7275
- }
7276
- }
7277
- const res = (await this.botApi.call("deleteForumTopic", {
8208
+ if (
8209
+ !this.topics.archiveAuthorityAllows(
8210
+ sessionId,
8211
+ this.installationHostId,
8212
+ this.opts.chatId,
8213
+ this.runtime.now(),
8214
+ )
8215
+ )
8216
+ return "pre_dispatch_cancelled";
8217
+ const res = (await this.botApi.call("closeForumTopic", {
7278
8218
  chat_id: this.opts.chatId,
7279
8219
  message_thread_id: Number(record.topicId),
7280
8220
  })) as { ok?: boolean };
7281
- if (!topicDeleteSettled(res)) return "post_dispatch_pending";
7282
- const settled = this.topics.settleDelete(sessionId, record.topicId, dispatchedAuthorityEpoch);
7283
- if (!settled) {
7284
- await this.#persistTopicsWithRetry().catch(() => undefined);
8221
+ if (!topicArchiveSettled(res)) {
8222
+ this.topics.scheduleArchiveRetry(sessionId, this.runtime.now(), "archive result was not definitive");
8223
+ await this.persistTopics();
7285
8224
  return "post_dispatch_pending";
7286
8225
  }
8226
+ if (!this.topics.settleArchive(sessionId, record.topicId, dispatchedAuthorityEpoch))
8227
+ return "post_dispatch_pending";
7287
8228
  for (const k of [...this.liveMessages.keys()])
7288
8229
  if (k.startsWith(`${sessionId}:`)) {
7289
8230
  this.liveMessages.delete(k);
@@ -7295,32 +8236,89 @@ export class TelegramNotificationDaemon {
7295
8236
  this.pendingThreadedFrames.delete(sessionId);
7296
8237
  try {
7297
8238
  await this.persistTopics();
7298
- this.topics.commitSettledDelete(settled);
7299
8239
  return "settled";
7300
8240
  } catch {
7301
- this.topics.rollbackSettledDelete(settled);
8241
+ this.topics.restoreArchiveFence(archiveSnapshot);
7302
8242
  await this.#persistTopicsWithRetry().catch(() => undefined);
7303
8243
  return "post_dispatch_pending";
7304
8244
  }
7305
8245
  } catch {
7306
- // Once Telegram dispatch starts, retain the persisted deletion fence: the
8246
+ // Once Telegram dispatch starts, retain the persisted archive fence: the
7307
8247
  // remote result is ambiguous and may not restore stale routing authority.
8248
+ this.topics.scheduleArchiveRetry(sessionId, this.runtime.now(), "archive transport failed");
8249
+ await this.persistTopics().catch(() => undefined);
7308
8250
  return "post_dispatch_pending";
7309
8251
  }
7310
8252
  }
7311
8253
 
7312
8254
  /** Serialize a mutation and its durable snapshot so rollback precedes later writers. */
7313
8255
  #persistTopicMutation<T>(mutation: () => T, rollback: () => void): Promise<T> {
8256
+ if (this.validationMode()) return Promise.resolve().then(mutation);
7314
8257
  const pending = this.topicsPersistQueue.then(async () => {
7315
- const result = mutation();
8258
+ let result = mutation();
8259
+ const authority = this.opts.topicRegistryAuthority;
8260
+ let sharedCommitted = false;
8261
+ let latestWinner: TopicRegistryState | undefined;
7316
8262
  try {
7317
8263
  const snapshot = this.#topicStateForPersistence();
7318
- const paths = daemonPaths(this.opts.settings.getAgentDir());
7319
- await ensureDir(this.fsImpl, paths.dir);
7320
- await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot);
8264
+ if (authority) {
8265
+ for (let attempt = 0; attempt < 3; attempt++) {
8266
+ const expectedGeneration = this.topics.registryVersion();
8267
+ const snapshot = this.#topicStateForPersistence();
8268
+ const nextGeneration = expectedGeneration + 1;
8269
+ snapshot.registryGeneration = nextGeneration;
8270
+ let accepted = false;
8271
+ try {
8272
+ accepted = await authority.compareAndSet(expectedGeneration, snapshot);
8273
+ } catch {
8274
+ throw new Error("shared topic authority unavailable");
8275
+ }
8276
+ if (accepted) {
8277
+ this.topics.markRegistryPublished(nextGeneration);
8278
+ sharedCommitted = true;
8279
+ break;
8280
+ }
8281
+ const winner = parseTopicRegistryState(await authority.read().catch(() => undefined));
8282
+ if (!winner) throw new Error("shared topic authority conflict");
8283
+ latestWinner = winner;
8284
+ this.#replaceTopicAuthority(winner);
8285
+ if (attempt === 2) throw new Error("shared topic authority conflict");
8286
+ result = mutation();
8287
+ }
8288
+ }
8289
+ if (!authority) {
8290
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
8291
+ await ensureDir(this.fsImpl, paths.dir);
8292
+ const expectedDestination = await captureTopicRegistryDestination(
8293
+ this.fsImpl,
8294
+ path.join(paths.dir, "telegram-topics.json"),
8295
+ process.platform,
8296
+ );
8297
+ await writeTopicRegistryAtomic(
8298
+ this.fsImpl,
8299
+ path.join(paths.dir, "telegram-topics.json"),
8300
+ snapshot,
8301
+ process.platform,
8302
+ exactReplacePath,
8303
+ expectedDestination,
8304
+ );
8305
+ }
7321
8306
  return result;
7322
8307
  } catch (error) {
7323
- rollback();
8308
+ if (sharedCommitted && authority) {
8309
+ const committed = await authority.read().catch(() => undefined);
8310
+ const state = parseTopicRegistryState(committed);
8311
+ if (state) {
8312
+ this.topics.replace(state);
8313
+ this.closedEndpointKeys.clear();
8314
+ for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {}))
8315
+ if (binding) this.closedEndpointKeys.set(sessionId, binding);
8316
+ }
8317
+ } else if (latestWinner) {
8318
+ this.#replaceTopicAuthority(latestWinner);
8319
+ } else {
8320
+ rollback();
8321
+ }
7324
8322
  throw error;
7325
8323
  }
7326
8324
  });
@@ -7339,8 +8337,9 @@ export class TelegramNotificationDaemon {
7339
8337
  }
7340
8338
  }
7341
8339
 
7342
- #superviseCompensationFence(sessionId: string): void {
7343
- if (this.compensationFenceRetries.has(sessionId)) return;
8340
+ async #superviseCompensationFence(sessionId: string): Promise<void> {
8341
+ const existing = this.compensationFenceRetries.get(sessionId);
8342
+ if (existing) return await existing;
7344
8343
  const retry = this.effects.track(
7345
8344
  (async () => {
7346
8345
  for (;;) {
@@ -7354,35 +8353,143 @@ export class TelegramNotificationDaemon {
7354
8353
  })(),
7355
8354
  );
7356
8355
  this.compensationFenceRetries.set(sessionId, retry);
7357
- void retry.finally(() => this.compensationFenceRetries.delete(sessionId));
8356
+ try {
8357
+ await retry;
8358
+ } finally {
8359
+ if (this.compensationFenceRetries.get(sessionId) === retry) this.compensationFenceRetries.delete(sessionId);
8360
+ }
7358
8361
  }
7359
8362
 
7360
8363
  private persistTopics(): Promise<void> {
8364
+ if (this.validationMode()) return Promise.resolve();
7361
8365
  const pending = this.topicsPersistQueue.then(async () => {
7362
8366
  // Resolve implicit snapshots inside the serialization queue. Callers that
7363
8367
  // mutate the registry before waiting cannot overwrite a newer authority
7364
8368
  // binding with an invocation-time snapshot.
7365
8369
  const snapshot = this.#topicStateForPersistence();
7366
- const paths = daemonPaths(this.opts.settings.getAgentDir());
7367
- await ensureDir(this.fsImpl, paths.dir);
7368
- await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot);
8370
+ const authority = this.opts.topicRegistryAuthority;
8371
+ if (authority) {
8372
+ const expectedGeneration = this.topics.registryVersion();
8373
+ const nextGeneration = expectedGeneration + 1;
8374
+ snapshot.registryGeneration = nextGeneration;
8375
+ let accepted = false;
8376
+ try {
8377
+ accepted = await authority.compareAndSet(expectedGeneration, snapshot);
8378
+ } catch {
8379
+ throw new Error("shared topic authority unavailable");
8380
+ }
8381
+ if (!accepted) {
8382
+ let winner: TopicRegistryState | undefined;
8383
+ try {
8384
+ winner = parseTopicRegistryState(await authority.read());
8385
+ } catch {
8386
+ throw new Error("shared topic authority unavailable");
8387
+ }
8388
+ if (!winner) throw new Error("shared topic authority conflict");
8389
+ this.#replaceTopicAuthority(winner);
8390
+ throw new Error("shared topic authority conflict");
8391
+ }
8392
+ this.topics.markRegistryPublished(nextGeneration);
8393
+ }
8394
+ if (!authority) {
8395
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
8396
+ await ensureDir(this.fsImpl, paths.dir);
8397
+ const topicPath = path.join(paths.dir, "telegram-topics.json");
8398
+ const expectedDestination = await captureTopicRegistryDestination(this.fsImpl, topicPath, process.platform);
8399
+ await writeTopicRegistryAtomic(
8400
+ this.fsImpl,
8401
+ topicPath,
8402
+ snapshot,
8403
+ process.platform,
8404
+ exactReplacePath,
8405
+ expectedDestination,
8406
+ );
8407
+ }
7369
8408
  });
7370
8409
  this.topicsPersistQueue = pending.catch(() => undefined);
7371
8410
  return pending;
7372
8411
  }
7373
8412
 
8413
+ #acceptedCreateArchiveFenceAllows(
8414
+ sessionId: string,
8415
+ topicId: string,
8416
+ creationLeaseEpoch: number,
8417
+ binding: TopicEndpointBinding | undefined,
8418
+ ): boolean {
8419
+ const record = this.topics.get(sessionId);
8420
+ const bindingMatches = binding
8421
+ ? record?.chatId === binding.chatId &&
8422
+ record.endpointKey === binding.endpointKey &&
8423
+ record.endpointDigest === binding.endpointDigest &&
8424
+ record.endpointGeneration === binding.endpointGeneration
8425
+ : record?.chatId === String(this.opts.chatId) &&
8426
+ record.endpointKey === undefined &&
8427
+ record.endpointDigest === undefined;
8428
+ return (
8429
+ record?.topicId === topicId &&
8430
+ record.creationLeaseEpoch === creationLeaseEpoch &&
8431
+ bindingMatches &&
8432
+ this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
8433
+ );
8434
+ }
8435
+
8436
+ #replaceTopicAuthority(state: TopicRegistryState): void {
8437
+ this.topics.replace(state);
8438
+ this.closedEndpointKeys.clear();
8439
+ for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {}))
8440
+ if (binding) this.closedEndpointKeys.set(sessionId, binding);
8441
+ }
8442
+
7374
8443
  #topicStateForPersistence(): TopicRegistryState {
7375
- return { ...this.topics.serialize(), closedEndpoints: Object.fromEntries(this.closedEndpointKeys) };
8444
+ return {
8445
+ ...this.topics.serialize(),
8446
+ closedEndpoints: Object.fromEntries(this.closedEndpointKeys),
8447
+ ...(this.opts.topicRegistryAuthority ? {} : { installationHostId: this.installationHostId }),
8448
+ };
7376
8449
  }
7377
8450
 
7378
8451
  async loadTopics(): Promise<void> {
8452
+ if (this.validationMode()) return;
7379
8453
  const paths = daemonPaths(this.opts.settings.getAgentDir());
7380
- const raw = await readJson<TopicRegistryState>(this.fsImpl, path.join(paths.dir, "telegram-topics.json"));
8454
+ const topicPath = path.join(paths.dir, "telegram-topics.json");
8455
+ let raw = await readJson<unknown>(this.fsImpl, topicPath);
8456
+ if (this.opts.topicRegistryAuthority) {
8457
+ try {
8458
+ raw = await this.opts.topicRegistryAuthority.read();
8459
+ } catch {
8460
+ throw new Error("shared topic authority unavailable");
8461
+ }
8462
+ if (!raw) throw new Error("shared topic authority unavailable");
8463
+ }
8464
+ const legacySnapshot =
8465
+ !!raw && typeof raw === "object" && !Array.isArray(raw) && !Object.hasOwn(raw as object, "version");
8466
+ if (legacySnapshot && raw !== undefined) {
8467
+ const legacyDigest = crypto.createHash("sha256").update(JSON.stringify(raw)).digest("hex");
8468
+ const quarantinePath = path.join(paths.dir, `telegram-topics.legacy-quarantine.${legacyDigest}.json`);
8469
+ if ((await readJson<unknown>(this.fsImpl, quarantinePath)) === undefined) {
8470
+ await ensureDir(this.fsImpl, paths.dir);
8471
+ await writeTopicRegistryAtomic(this.fsImpl, quarantinePath, raw);
8472
+ }
8473
+ }
8474
+ const state = parseTopicRegistryState(raw);
7381
8475
  // Restore the full serialized registry (topicId + identitySent + name) so a
7382
8476
  // fresh daemon after reload does not resend identity headers or lose renames.
7383
- if (raw && typeof raw === "object") {
7384
- this.topics.load(raw);
7385
- for (const [sessionId, binding] of Object.entries(raw.closedEndpoints ?? {})) {
8477
+ if (state) {
8478
+ const missingHostId =
8479
+ !this.opts.topicRegistryAuthority &&
8480
+ (typeof state.installationHostId !== "string" || state.installationHostId.length === 0);
8481
+ const missingSessionUuid = Object.values(state.topics).some(
8482
+ record => !!record && typeof record === "object" && typeof record.sessionUuid !== "string",
8483
+ );
8484
+ this.topics.load(state);
8485
+ if (!this.opts.topicRegistryAuthority && typeof state.installationHostId === "string")
8486
+ this.installationHostId = state.installationHostId;
8487
+ let reconciledCreateClaim = false;
8488
+ for (const claim of this.topics.pendingCreateClaims()) {
8489
+ const topic = this.topics.get(claim.sessionId);
8490
+ reconciledCreateClaim = this.topics.reconcileCreateClaim(claim.sessionId, topic) || reconciledCreateClaim;
8491
+ }
8492
+ for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {})) {
7386
8493
  if (
7387
8494
  binding &&
7388
8495
  typeof binding.chatId === "string" &&
@@ -7393,14 +8500,14 @@ export class TelegramNotificationDaemon {
7393
8500
  )
7394
8501
  this.closedEndpointKeys.set(sessionId, binding);
7395
8502
  }
8503
+ if (legacySnapshot || missingHostId || missingSessionUuid || reconciledCreateClaim) await this.persistTopics();
7396
8504
  }
7397
8505
  }
7398
8506
 
7399
8507
  /**
7400
- * Rehydrate durable adoption intents after restart, then reconcile: a
7401
- * non-expired sidecar whose target session already has a committed topic
7402
- * record is a stale leftover (commit succeeded but sidecar cleanup failed)
7403
- * and is removed safely without touching the user topic.
8508
+ * Rehydrate durable adoption intents after restart. A sidecar whose topic
8509
+ * already committed is stale evidence and can be removed without touching
8510
+ * the retained user-created topic.
7404
8511
  */
7405
8512
  async loadAdoptionIntents(): Promise<void> {
7406
8513
  await this.#adoptionIntents.rehydrate();
@@ -7423,7 +8530,6 @@ export class TelegramNotificationDaemon {
7423
8530
  }
7424
8531
  }
7425
8532
 
7426
- /** Periodic sweep of expired adoption-intent sidecars (files only; no Telegram API). */
7427
8533
  private startAdoptionSweepTimer(): void {
7428
8534
  const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
7429
8535
  this.#adoptionSweepTimer = setIntervalImpl(() => {
@@ -7446,9 +8552,10 @@ export class TelegramNotificationDaemon {
7446
8552
  }
7447
8553
  }
7448
8554
 
7449
- /** Retry crash-interrupted topic deletes; only a definite Telegram result clears the durable fence. */
8555
+ /** Retry crash-interrupted topic archives only when the durable backoff permits it. */
7450
8556
  private async reconcilePendingTopicDeletes(): Promise<void> {
7451
- for (const sessionId of this.topics.deletePendingSessionIds()) await this.deleteTopic(sessionId);
8557
+ for (const sessionId of this.topics.archivePendingSessionIds(this.runtime.now()))
8558
+ await this.archiveTopic(sessionId);
7452
8559
  }
7453
8560
 
7454
8561
  /** Download one Telegram file with the Bot API's 20 MiB ceiling and one end-to-end deadline. */
@@ -7892,7 +8999,7 @@ export class TelegramNotificationDaemon {
7892
8999
  continue;
7893
9000
  }
7894
9001
  const topicId = topicLease?.topicId;
7895
- if (topicId && !(await this.pairedChatIsPrivate())) {
9002
+ if (topicId && !(await this.pairedChatAllowsTopics())) {
7896
9003
  this.pool.settle(item.itemId!, "rejected");
7897
9004
  this.failLegacyToolStart(toolActivity);
7898
9005
  continue;
@@ -8055,7 +9162,7 @@ export class TelegramNotificationDaemon {
8055
9162
  );
8056
9163
  // Index the sent rich message so an inbound reply to it can restore
8057
9164
  // the original markdown as context (Telegram does not echo it back).
8058
- if (richMessageId !== undefined) {
9165
+ if (richMessageId !== undefined && !this.validationMode()) {
8059
9166
  await this.replyStore.record({
8060
9167
  chatId: this.opts.chatId,
8061
9168
  messageId: richMessageId,
@@ -8362,29 +9469,41 @@ export class TelegramNotificationDaemon {
8362
9469
  }
8363
9470
 
8364
9471
  /**
8365
- * Resolve (and cache definitive resolution of) whether the paired `chatId` is
8366
- * a private chat. Topic and flat delivery are only safe in a private DM; an
8367
- * indeterminate `getChat` result fails closed for this attempt and is retried
8368
- * later.
9472
+ * Resolve and cache the outbound delivery boundary. The validation forum is
9473
+ * explicit and topic-only; all inbound and flat paths remain private-only.
8369
9474
  */
9475
+ private validationMode(): boolean {
9476
+ return this.opts.validationTestSupergroupChatId !== undefined;
9477
+ }
9478
+
8370
9479
  private async resolvePairedChatPrivacy(): Promise<PairedChatPrivacy> {
8371
- if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate ? "private" : "non-private";
9480
+ if (this.pairedChatPrivacy !== undefined) return this.pairedChatPrivacy;
9481
+ const chatId = this.opts.validationTestSupergroupChatId ?? this.opts.chatId;
8372
9482
  try {
8373
- const res = (await this.botApi.call("getChat", { chat_id: this.opts.chatId })) as {
9483
+ const res = (await this.botApi.call("getChat", { chat_id: chatId })) as {
8374
9484
  ok?: unknown;
8375
- result?: { type?: unknown };
9485
+ result?: { id?: unknown; type?: unknown; is_forum?: unknown };
8376
9486
  };
8377
9487
  if (res === undefined) return "indeterminate";
8378
9488
  if (res?.ok !== true) {
8379
9489
  logger.warn("notifications: getChat privacy check indeterminate (non-success response)");
8380
9490
  return "indeterminate";
8381
9491
  }
8382
- if (res.result?.type === "private") {
8383
- this.pairedChatPrivate = true;
9492
+ if (
9493
+ this.validationMode() &&
9494
+ String(res.result?.id) === this.opts.validationTestSupergroupChatId &&
9495
+ res.result?.type === "supergroup" &&
9496
+ res.result.is_forum === true
9497
+ ) {
9498
+ this.pairedChatPrivacy = "validation-forum";
9499
+ return "validation-forum";
9500
+ }
9501
+ if (!this.validationMode() && res.result?.type === "private") {
9502
+ this.pairedChatPrivacy = "private";
8384
9503
  return "private";
8385
9504
  }
8386
9505
  if (res.result?.type === "group" || res.result?.type === "supergroup" || res.result?.type === "channel") {
8387
- this.pairedChatPrivate = false;
9506
+ this.pairedChatPrivacy = "non-private";
8388
9507
  return "non-private";
8389
9508
  }
8390
9509
  logger.warn("notifications: getChat privacy check indeterminate (missing or invalid chat type)");
@@ -8395,7 +9514,12 @@ export class TelegramNotificationDaemon {
8395
9514
  }
8396
9515
  }
8397
9516
 
8398
- /** Keep existing outbound callers fail-closed for indeterminate privacy. */
9517
+ private async pairedChatAllowsTopics(): Promise<boolean> {
9518
+ const privacy = await this.resolvePairedChatPrivacy();
9519
+ return privacy === "private" || privacy === "validation-forum";
9520
+ }
9521
+
9522
+ /** Keep all flat delivery and inbound control paths private-only. */
8399
9523
  private async pairedChatIsPrivate(): Promise<boolean> {
8400
9524
  return (await this.resolvePairedChatPrivacy()) === "private";
8401
9525
  }
@@ -8594,14 +9718,18 @@ export class TelegramNotificationDaemon {
8594
9718
  );
8595
9719
  if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return false;
8596
9720
  try {
8597
- const response = await this.botApi.call("sendMessage", {
8598
- chat_id: this.opts.chatId,
8599
- message_thread_id: Number(topicId),
8600
- text: rendered.text,
8601
- parse_mode: TELEGRAM_PARSE_MODE,
8602
- reply_markup: { inline_keyboard },
8603
- ...(telegramDisableNotification(this.opts.sound, "ask") === true ? { disable_notification: true } : {}),
8604
- });
9721
+ const response = await this.botApi.call(
9722
+ "sendMessage",
9723
+ {
9724
+ chat_id: this.opts.chatId,
9725
+ message_thread_id: Number(topicId),
9726
+ text: rendered.text,
9727
+ parse_mode: TELEGRAM_PARSE_MODE,
9728
+ reply_markup: { inline_keyboard },
9729
+ ...(telegramDisableNotification(this.opts.sound, "ask") === true ? { disable_notification: true } : {}),
9730
+ },
9731
+ { noRetry: true },
9732
+ );
8605
9733
  if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
8606
9734
  for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
8607
9735
  logger.warn("notifications: failed to send model selection keyboard");
@@ -8689,10 +9817,13 @@ export class TelegramNotificationDaemon {
8689
9817
  endpointAuthority.sessionId === session.sessionId &&
8690
9818
  ownsLiveOpenEndpoint &&
8691
9819
  this.topics.matchesEndpoint(session.sessionId, endpointBinding);
9820
+ const inactiveSuccessor =
9821
+ this.topics.get(session.sessionId)?.authorityState === "inactive" &&
9822
+ this.topics.get(session.sessionId)?.endpointDigest !== endpointBinding.endpointDigest;
8692
9823
  const canBootstrapTransport =
8693
- endpointAuthority.state === "none" &&
9824
+ (endpointAuthority.state === "none" || inactiveSuccessor) &&
8694
9825
  ownsLiveOpenEndpoint &&
8695
- !this.topics.get(session.sessionId) &&
9826
+ (!this.topics.get(session.sessionId) || inactiveSuccessor) &&
8696
9827
  !this.preservedInitiatorTopics.has(session.sessionId);
8697
9828
  const replayCandidateSessionId =
8698
9829
  replayIdentitySessionId ?? (canResumeTransport || canBootstrapTransport ? session.sessionId : undefined);
@@ -8704,6 +9835,7 @@ export class TelegramNotificationDaemon {
8704
9835
  true,
8705
9836
  replayIdentitySessionId ? undefined : canBootstrapTransport ? "bootstrap" : "resume",
8706
9837
  );
9838
+ if (this.sessions.get(session.sessionId) !== session) return;
8707
9839
  if (!recovered) {
8708
9840
  if (session.hostGeneration === msg.generation && session.recoveryLease?.state !== "pending")
8709
9841
  this.dropSession(session, "recovery_rejected");
@@ -9116,10 +10248,8 @@ export class TelegramNotificationDaemon {
9116
10248
  abandonStaleToolStart();
9117
10249
  return;
9118
10250
  }
9119
- if (
9120
- this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
9121
- this.topics.get(logicalSessionId)?.bindingMalformed
9122
- ) {
10251
+ const topicRecord = this.topics.get(logicalSessionId);
10252
+ if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) {
9123
10253
  this.failLegacyToolStart(toolActivity);
9124
10254
  return;
9125
10255
  }
@@ -9132,10 +10262,8 @@ export class TelegramNotificationDaemon {
9132
10262
  (await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session));
9133
10263
  const topicLease = await this.topicAuthorityLease(logicalSessionId);
9134
10264
  if (!topicId || !topicLease || topicLease.topicId !== topicId) {
9135
- if (
9136
- this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
9137
- this.topics.get(logicalSessionId)?.bindingMalformed
9138
- ) {
10265
+ const topicRecord = this.topics.get(logicalSessionId);
10266
+ if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) {
9139
10267
  this.failLegacyToolStart(toolActivity);
9140
10268
  return;
9141
10269
  }
@@ -9195,12 +10323,13 @@ export class TelegramNotificationDaemon {
9195
10323
  const logicalSessionId = this.#logicalSessionId(session);
9196
10324
  const socketLease = this.#socketLease(session, logicalSessionId);
9197
10325
  if (!socketLease) return;
9198
- if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: logicalSessionId, actionId: msg.id });
9199
- if (
9200
- this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
9201
- this.topics.get(logicalSessionId)?.bindingMalformed
9202
- )
9203
- return;
10326
+ const pendingAction = msg.kind === "ask" ? { sessionId: logicalSessionId, actionId: msg.id } : undefined;
10327
+ if (pendingAction) {
10328
+ session.pending.set(msg.id, pendingAction);
10329
+ await this.reissuePendingAction(logicalSessionId, msg.id);
10330
+ }
10331
+ const topicRecord = this.topics.get(logicalSessionId);
10332
+ if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) return;
9204
10333
  const topicId = await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session);
9205
10334
  const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(logicalSessionId) : undefined;
9206
10335
  if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
@@ -9258,81 +10387,245 @@ export class TelegramNotificationDaemon {
9258
10387
  controls,
9259
10388
  summary: msg.summary,
9260
10389
  });
10390
+ const kind = msg.kind === "idle" ? "idle" : "ask";
10391
+ const callbackDispatchAuthorityIsCurrent = (): boolean =>
10392
+ this.#leaseTokenAllows(socketLease) &&
10393
+ (!topicLease || this.topicLeaseIsCurrent(topicLease)) &&
10394
+ (kind !== "ask" || (pendingAction !== undefined && session.pending.get(msg.id) === pendingAction));
10395
+ const createdAliases: Array<[string, CallbackRoute]> = [];
10396
+ const createAlias = (answer: CallbackRoute["answer"]): string => {
10397
+ const alias = this.aliasTable.allocate(candidate => this.reissueBacklog.has(candidate));
10398
+ createdAliases.push([
10399
+ alias,
10400
+ {
10401
+ sessionId: logicalSessionId,
10402
+ actionId: msg.id,
10403
+ answer,
10404
+ },
10405
+ ]);
10406
+ return alias;
10407
+ };
9261
10408
  const inline_keyboard = [
9262
- ...buildCompactChoiceGrid(displayOptions, (i: number) =>
9263
- this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
9264
- ),
10409
+ ...buildCompactChoiceGrid(displayOptions, (i: number) => createAlias(i)),
9265
10410
  ...controls.map(control => [
9266
10411
  {
9267
10412
  text: control.label,
9268
- callback_data: this.aliasTable.put({
9269
- sessionId: logicalSessionId,
9270
- actionId: msg.id,
9271
- answer: { controlId: control.id },
9272
- }),
10413
+ callback_data: createAlias({ controlId: control.id }),
9273
10414
  },
9274
10415
  ]),
9275
10416
  ];
9276
- const kind = msg.kind === "idle" ? "idle" : "ask";
10417
+ if (createdAliases.length > 0) {
10418
+ for (const [alias, route] of createdAliases) {
10419
+ if (!this.aliasTable.activate(alias, route)) throw new Error("callback alias reservation conflicted");
10420
+ }
10421
+ // Reserve every exposed callback token durably before Telegram can
10422
+ // accept a keyboard. A restart loads these non-receipt routes only
10423
+ // into the non-routable reissue backlog.
10424
+ await this.persistAliases();
10425
+ }
9277
10426
  // HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
9278
- // returns the last chunk's message_id (the reply-routable message).
10427
+ // returns the last chunk's authoritative message_id (the reply-routable message).
10428
+ let callbackDelivery: "accepted" | "rejected" | "ambiguous" = "rejected";
9279
10429
  const sendHtmlChunks = async (): Promise<number | undefined> => {
9280
10430
  const chunks = splitTelegramHtml(rendered.text);
9281
- let result: { result?: { message_id?: number } } | undefined;
10431
+ let acceptedMessageId: number | undefined;
9282
10432
  for (let i = 0; i < chunks.length; i++) {
9283
- if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
10433
+ const exposesCallbacks = i === chunks.length - 1 && inline_keyboard.length > 0;
10434
+ if (!callbackDispatchAuthorityIsCurrent()) return undefined;
10435
+ let result: { ok?: unknown; result?: { message_id?: unknown } } | undefined;
10436
+ try {
10437
+ result = (await this.botApi.call(
10438
+ "sendMessage",
10439
+ {
10440
+ chat_id: this.opts.chatId,
10441
+ ...threadField,
10442
+ text: chunks[i]!,
10443
+ parse_mode: TELEGRAM_PARSE_MODE,
10444
+ ...(telegramDisableNotification(this.opts.sound, kind, i === chunks.length - 1) === true
10445
+ ? { disable_notification: true }
10446
+ : {}),
10447
+ ...(exposesCallbacks ? { reply_markup: { inline_keyboard } } : {}),
10448
+ },
10449
+ { noRetry: true },
10450
+ )) as { ok?: unknown; result?: { message_id?: unknown } } | undefined;
10451
+ } catch {
10452
+ if (exposesCallbacks) callbackDelivery = "ambiguous";
9284
10453
  return undefined;
9285
- result = (await this.botApi.call("sendMessage", {
9286
- chat_id: this.opts.chatId,
9287
- ...threadField,
9288
- text: chunks[i]!,
9289
- parse_mode: TELEGRAM_PARSE_MODE,
9290
- ...(telegramDisableNotification(this.opts.sound, kind, i === chunks.length - 1) === true
9291
- ? { disable_notification: true }
9292
- : {}),
9293
- ...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
9294
- })) as { result?: { message_id?: number } } | undefined;
9295
- if (result === undefined) return undefined;
10454
+ }
10455
+ if (result?.ok !== true) {
10456
+ if (exposesCallbacks && result?.ok !== false) callbackDelivery = "ambiguous";
10457
+ return undefined;
10458
+ }
10459
+ const candidate = result.result?.message_id;
10460
+ if (typeof candidate !== "number" || !Number.isSafeInteger(candidate) || candidate <= 0) {
10461
+ if (exposesCallbacks) callbackDelivery = "ambiguous";
10462
+ return undefined;
10463
+ }
10464
+ if (exposesCallbacks) callbackDelivery = "accepted";
10465
+ acceptedMessageId = candidate;
9296
10466
  }
9297
- return result?.result?.message_id;
10467
+ return acceptedMessageId;
9298
10468
  };
10469
+ let messageId: number | undefined;
9299
10470
  if (this.opts.rich?.enabled !== false) {
9300
10471
  // Rich (default on): promote to sendRichMessage with a top-level
9301
10472
  // reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
9302
-
9303
- if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease))) return;
9304
- const outcome = await deliverRichActionWithFallback(
9305
- this.botApi,
9306
- {
9307
- chat_id: this.opts.chatId,
9308
- ...threadField,
9309
- ...(telegramDisableNotification(this.opts.sound, kind) === true
9310
- ? { disable_notification: true }
9311
- : {}),
9312
- },
9313
- {
9314
- markdown: buildActionMarkdown({
9315
- kind,
9316
- question: msg.question,
9317
- options: displayOptions,
9318
- recommendedIndex: msg.recommendedIndex,
9319
- summary: msg.summary,
9320
- }),
9321
- replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
9322
- },
9323
- AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
9324
- sendHtmlChunks,
9325
- logger,
9326
- );
9327
- // Only asks are reply-routable; idle pings register no route.
9328
- if (kind === "ask" && outcome.messageId !== undefined)
9329
- this.messageRoutes.set(String(outcome.messageId), { sessionId: logicalSessionId, actionId: msg.id });
10473
+ if (callbackDispatchAuthorityIsCurrent()) {
10474
+ const outcome = await deliverRichActionWithFallback(
10475
+ this.botApi,
10476
+ {
10477
+ chat_id: this.opts.chatId,
10478
+ ...threadField,
10479
+ ...(telegramDisableNotification(this.opts.sound, kind) === true
10480
+ ? { disable_notification: true }
10481
+ : {}),
10482
+ },
10483
+ {
10484
+ markdown: buildActionMarkdown({
10485
+ kind,
10486
+ question: msg.question,
10487
+ options: displayOptions,
10488
+ recommendedIndex: msg.recommendedIndex,
10489
+ summary: msg.summary,
10490
+ }),
10491
+ replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
10492
+ },
10493
+ AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
10494
+ sendHtmlChunks,
10495
+ logger,
10496
+ );
10497
+ messageId = outcome.messageId;
10498
+ if (createdAliases.length > 0 && messageId !== undefined) callbackDelivery = "accepted";
10499
+ else if (createdAliases.length > 0 && !outcome.usedFallback) callbackDelivery = "ambiguous";
10500
+ }
9330
10501
  } else {
9331
10502
  // Rich disabled: deliver through the HTML chunk path.
9332
- const messageId = await sendHtmlChunks();
9333
- // Only asks are reply-routable; idle pings register no route.
9334
- if (kind === "ask" && messageId !== undefined)
9335
- this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
10503
+ messageId = await sendHtmlChunks();
10504
+ }
10505
+ const pendingActionIsCurrent = pendingAction !== undefined && session.pending.get(msg.id) === pendingAction;
10506
+ const mayActivate =
10507
+ kind === "ask" &&
10508
+ messageId !== undefined &&
10509
+ pendingActionIsCurrent &&
10510
+ this.#leaseTokenAllows(socketLease) &&
10511
+ (!topicLease || this.topicLeaseIsCurrent(topicLease));
10512
+ if (mayActivate) {
10513
+ for (const [alias, route] of createdAliases) {
10514
+ if (
10515
+ !this.aliasTable.update(alias, {
10516
+ ...route,
10517
+ chatId: String(this.opts.chatId),
10518
+ messageId,
10519
+ ownerId: this.opts.ownerId,
10520
+ generation: DAEMON_GENERATION,
10521
+ })
10522
+ )
10523
+ throw new Error("callback alias receipt update conflicted");
10524
+ }
10525
+ this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
10526
+ const activation = this.#stageCallbackActivation(
10527
+ createdAliases.map(([alias]) => alias),
10528
+ pendingAction,
10529
+ socketLease,
10530
+ topicLease,
10531
+ );
10532
+ try {
10533
+ // A receipt is durable before an alias gets a routable socket lease.
10534
+ await this.persistAliases();
10535
+ } catch {
10536
+ for (const [alias] of createdAliases) {
10537
+ this.#stagedCallbackActivations.delete(alias);
10538
+ this.#revokeCallbackAlias(alias);
10539
+ }
10540
+ this.deleteMessageRoutes(logicalSessionId, msg.id);
10541
+ try {
10542
+ await this.persistAliases();
10543
+ } catch {
10544
+ // The in-memory backlog retains the accepted receipt for shutdown terminalization.
10545
+ }
10546
+ activation.resolve(false);
10547
+ throw new Error("callback alias receipt persistence failed");
10548
+ }
10549
+ if (
10550
+ session.pending.get(msg.id) !== pendingAction ||
10551
+ !this.#leaseTokenAllows(socketLease) ||
10552
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
10553
+ ) {
10554
+ for (const [alias] of createdAliases) {
10555
+ this.#revokeCallbackAlias(alias);
10556
+ this.#stagedCallbackActivations.delete(alias);
10557
+ }
10558
+ this.deleteMessageRoutes(logicalSessionId, msg.id);
10559
+ let revocationPersistenceError: unknown;
10560
+ try {
10561
+ await this.persistAliases();
10562
+ } catch (error) {
10563
+ revocationPersistenceError = error;
10564
+ } finally {
10565
+ activation.resolve(false);
10566
+ }
10567
+ try {
10568
+ await this.botApi.call("editMessageReplyMarkup", {
10569
+ chat_id: this.opts.chatId,
10570
+ message_id: messageId,
10571
+ reply_markup: { inline_keyboard: [] },
10572
+ });
10573
+ } catch {
10574
+ // The accepted receipt remains in memory for shutdown terminalization if persistence failed.
10575
+ }
10576
+ if (revocationPersistenceError !== undefined)
10577
+ throw new Error("callback alias revocation persistence failed", {
10578
+ cause: revocationPersistenceError,
10579
+ });
10580
+ return;
10581
+ }
10582
+ for (const [alias] of createdAliases) {
10583
+ this.#callbackAliasPending.set(alias, pendingAction);
10584
+ this.#callbackAliasLeases.set(alias, socketLease);
10585
+ if (topicLease) this.#callbackAliasTopics.set(alias, topicLease);
10586
+ this.#stagedCallbackActivations.delete(alias);
10587
+ }
10588
+ activation.resolve(true);
10589
+ } else if (kind === "ask" && messageId !== undefined) {
10590
+ // The send may have succeeded after authority ended. Persist every
10591
+ // receipt as revoked before best-effort remote terminalization.
10592
+ for (const [alias, route] of createdAliases) {
10593
+ const receipt = {
10594
+ ...route,
10595
+ chatId: String(this.opts.chatId),
10596
+ messageId,
10597
+ ownerId: this.opts.ownerId,
10598
+ generation: DAEMON_GENERATION,
10599
+ };
10600
+ if (this.aliasTable.update(alias, receipt)) {
10601
+ this.#revokeCallbackAlias(alias);
10602
+ continue;
10603
+ }
10604
+ const revoked = this.reissueBacklog.get(alias);
10605
+ if (
10606
+ revoked &&
10607
+ (revoked.sessionId !== route.sessionId ||
10608
+ revoked.actionId !== route.actionId ||
10609
+ revoked.answer !== route.answer)
10610
+ )
10611
+ throw new Error("callback alias revocation update conflicted");
10612
+ this.reissueBacklog.set(alias, receipt);
10613
+ }
10614
+ await this.persistAliases();
10615
+ try {
10616
+ const terminalized = (await this.botApi.call("editMessageReplyMarkup", {
10617
+ chat_id: this.opts.chatId,
10618
+ message_id: messageId,
10619
+ reply_markup: { inline_keyboard: [] },
10620
+ })) as { ok?: unknown } | undefined;
10621
+ if (terminalized?.ok === true) for (const [alias] of createdAliases) this.reissueBacklog.delete(alias);
10622
+ } catch {
10623
+ // The accepted receipt remains durably terminalizable even if its keyboard cannot be removed.
10624
+ }
10625
+ } else if (kind === "ask" && callbackDelivery === "ambiguous") {
10626
+ for (const [alias] of createdAliases) this.#revokeCallbackAlias(alias);
10627
+ } else if (kind === "ask") {
10628
+ for (const [alias] of createdAliases) this.aliasTable.delete(alias);
9336
10629
  }
9337
10630
  await this.persistAliases();
9338
10631
  } else if (msg.type === "action_resolved" && msg.id) {
@@ -9340,8 +10633,9 @@ export class TelegramNotificationDaemon {
9340
10633
  this.deleteMessageRoutes(this.#logicalSessionId(session), msg.id);
9341
10634
  for (const [alias, route] of this.aliasTable.entries()) {
9342
10635
  if (route.sessionId === this.#logicalSessionId(session) && route.actionId === msg.id)
9343
- this.aliasTable.delete(alias);
10636
+ this.#revokeCallbackAlias(alias);
9344
10637
  }
10638
+ await this.reissuePendingAction(this.#logicalSessionId(session), msg.id);
9345
10639
  await this.persistAliases();
9346
10640
  }
9347
10641
  }
@@ -9556,14 +10850,18 @@ export class TelegramNotificationDaemon {
9556
10850
  }),
9557
10851
  }));
9558
10852
  try {
9559
- const response = await this.botApi.call("sendMessage", {
9560
- chat_id: this.opts.chatId,
9561
- message_thread_id: threadId,
9562
- text: "Choose where to start the GJC session:",
9563
- reply_markup: {
9564
- inline_keyboard: aliases.map(choice => [{ text: choice.label, callback_data: choice.alias }]),
10853
+ const response = await this.botApi.call(
10854
+ "sendMessage",
10855
+ {
10856
+ chat_id: this.opts.chatId,
10857
+ message_thread_id: threadId,
10858
+ text: "Choose where to start the GJC session:",
10859
+ reply_markup: {
10860
+ inline_keyboard: aliases.map(choice => [{ text: choice.label, callback_data: choice.alias }]),
10861
+ },
9565
10862
  },
9566
- });
10863
+ { noRetry: true },
10864
+ );
9567
10865
  if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
9568
10866
  for (const choice of aliases) this.#adoptionPickerAliases.delete(choice.alias);
9569
10867
  return false;
@@ -9626,12 +10924,16 @@ export class TelegramNotificationDaemon {
9626
10924
  index => aliases[index]!.alias,
9627
10925
  );
9628
10926
  try {
9629
- const response = await this.botApi.call("sendMessage", {
9630
- chat_id: this.opts.chatId,
9631
- message_thread_id: threadId,
9632
- text: "Pick a recent work folder to start a session here:",
9633
- reply_markup: { inline_keyboard },
9634
- });
10927
+ const response = await this.botApi.call(
10928
+ "sendMessage",
10929
+ {
10930
+ chat_id: this.opts.chatId,
10931
+ message_thread_id: threadId,
10932
+ text: "Pick a recent work folder to start a session here:",
10933
+ reply_markup: { inline_keyboard },
10934
+ },
10935
+ { noRetry: true },
10936
+ );
9635
10937
  if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
9636
10938
  for (const a of aliases) this.#adoptionPickerAliases.delete(a.alias);
9637
10939
  return false;
@@ -10080,6 +11382,7 @@ export class TelegramNotificationDaemon {
10080
11382
  }
10081
11383
 
10082
11384
  private async processTelegramUpdate(update: unknown): Promise<TelegramUpdateOutcome> {
11385
+ if (this.validationMode()) return "consumed";
10083
11386
  const createdOutcome = await this.handleForumTopicCreatedUpdate(update);
10084
11387
  if (createdOutcome !== "not-topic") return createdOutcome;
10085
11388
  const topicOutcome = await this.handleForumTopicEdited(update);
@@ -10088,16 +11391,13 @@ export class TelegramNotificationDaemon {
10088
11391
  await this.handleTelegramUpdate(update);
10089
11392
  } catch (err) {
10090
11393
  logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
11394
+ return "retry";
10091
11395
  }
10092
11396
  return "consumed";
10093
11397
  }
10094
11398
 
10095
11399
  async handleTelegramUpdate(update: unknown): Promise<void> {
10096
- // A user-created forum topic (`forum_topic_created`) is consumed first: it
10097
- // authenticates the chat/user, shows the folder-source picker, and emits the
10098
- // "already starting" dedup notice for a topic with a live adoption intent.
10099
- // This mirrors the poll-loop ordering in processTelegramUpdate so direct
10100
- // (test/orchestration) callers observe identical created-topic handling.
11400
+ if (this.validationMode()) return;
10101
11401
  if ((await this.handleForumTopicCreatedUpdate(update)) !== "not-topic") return;
10102
11402
  if ((await this.handleForumTopicEdited(update)) !== "not-topic") return;
10103
11403
  // A raw path is accepted only after the explicit direct-entry choice. The exact
@@ -10259,7 +11559,17 @@ export class TelegramNotificationDaemon {
10259
11559
  // update_id dedupe are all enforced by decideThreadedInbound.
10260
11560
  const raw = update as {
10261
11561
  callback_query?: unknown;
10262
- message?: { text?: unknown; reply_to_message?: { message_id?: unknown } };
11562
+ message?: {
11563
+ text?: unknown;
11564
+ reply_to_message?: { message_id?: unknown };
11565
+ photo?: unknown;
11566
+ document?: unknown;
11567
+ video?: unknown;
11568
+ audio?: unknown;
11569
+ chat?: { id?: unknown };
11570
+ message_thread_id?: unknown;
11571
+ message_id?: unknown;
11572
+ };
10263
11573
  };
10264
11574
  // A reply to a known ask message routes to that ask (below). Any OTHER
10265
11575
  // message in a topic (plain text, or a reply to a non-ask message) is a
@@ -10269,6 +11579,23 @@ export class TelegramNotificationDaemon {
10269
11579
  typeof raw.message?.text === "string" ? parseBtwCommand(raw.message.text, this.botUsername) : undefined;
10270
11580
  const isAskReply =
10271
11581
  replyTo !== undefined && (this.messageRoutes.has(String(replyTo)) || this.messageRoutes.has(Number(replyTo)));
11582
+ if (
11583
+ reservedBtw?.kind === "question" &&
11584
+ (raw.message?.photo || raw.message?.document || raw.message?.video || raw.message?.audio) &&
11585
+ typeof raw.message?.message_thread_id === "number" &&
11586
+ typeof raw.message.message_id === "number" &&
11587
+ String(raw.message.chat?.id) === this.opts.chatId &&
11588
+ (await this.pairedChatIsPrivate())
11589
+ ) {
11590
+ if (!(await this.reserveSeenUpdateId((update as { update_id?: number }).update_id!))) return;
11591
+ await this.#sendBtwMessage({
11592
+ threadId: String(raw.message.message_thread_id),
11593
+ messageId: raw.message.message_id,
11594
+ text: BTW_USAGE_TEXT,
11595
+ isAuthoritative: () => true,
11596
+ });
11597
+ return;
11598
+ }
10272
11599
  const directControl =
10273
11600
  typeof raw.message?.text === "string"
10274
11601
  ? parseTelegramControlCommand(raw.message.text, this.botUsername)
@@ -10302,11 +11629,17 @@ export class TelegramNotificationDaemon {
10302
11629
  return undefined;
10303
11630
  return transportOwner.logicalSessionId;
10304
11631
  }
11632
+ if (
11633
+ reservedBtw?.kind === "question" &&
11634
+ (raw.message?.photo || raw.message?.document || raw.message?.video || raw.message?.audio)
11635
+ )
11636
+ return topicSessionId;
10305
11637
  return [...this.sessions.values()].some(
10306
11638
  session =>
10307
- session.sessionId === topicSessionId ||
10308
- this.#logicalSessionId(session) === topicSessionId ||
10309
- session.recoveryLease?.logicalSessionId === topicSessionId,
11639
+ (session.sessionId === topicSessionId ||
11640
+ this.#logicalSessionId(session) === topicSessionId ||
11641
+ session.recoveryLease?.logicalSessionId === topicSessionId) &&
11642
+ session.ws.readyState !== WebSocket.CLOSED,
10310
11643
  )
10311
11644
  ? undefined
10312
11645
  : topicSessionId;
@@ -10319,6 +11652,7 @@ export class TelegramNotificationDaemon {
10319
11652
  const preliminaryControl = inbound.attachment
10320
11653
  ? { kind: "none" as const }
10321
11654
  : parseTelegramControlCommand(inbound.text, this.botUsername);
11655
+ const reservedBtw = parseBtwCommand(inbound.text, this.botUsername);
10322
11656
  if (preliminaryControl.kind === "ignored") return;
10323
11657
  const session =
10324
11658
  this.logicalSessionOwners.get(inbound.sessionId) ??
@@ -10348,6 +11682,20 @@ export class TelegramNotificationDaemon {
10348
11682
  topicLeaseAllows();
10349
11683
  const routeLeaseAllows = (): boolean =>
10350
11684
  topicLeaseAllows() && (!session || (!!routeLease && this.#leaseTokenAllows(routeLease)));
11685
+ if (
11686
+ /^\/btw(?:\s|$)/i.test(inbound.text) &&
11687
+ inbound.attachment &&
11688
+ (!session || session.ws.readyState !== WebSocket.OPEN || !routeLease)
11689
+ ) {
11690
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
11691
+ await this.#sendBtwMessage({
11692
+ threadId: inbound.threadId,
11693
+ messageId: inbound.messageId,
11694
+ text: BTW_USAGE_TEXT,
11695
+ isAuthoritative: () => true,
11696
+ });
11697
+ return;
11698
+ }
10351
11699
  const reserveRouteUpdate = async (): Promise<boolean> => {
10352
11700
  if (!(await this.reserveSeenUpdateId(inbound.updateId))) return false;
10353
11701
  if (routeLeaseAllows()) return true;
@@ -10355,9 +11703,18 @@ export class TelegramNotificationDaemon {
10355
11703
  return false;
10356
11704
  };
10357
11705
 
10358
- if (session && !routeLease) return;
10359
- if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
10360
- if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
11706
+ if (session && !routeLease && !(reservedBtw?.kind === "question" && inbound.attachment)) return;
11707
+ if (
11708
+ preliminaryControl.kind === "invalid" &&
11709
+ session?.ws.readyState !== WebSocket.OPEN &&
11710
+ !(reservedBtw?.kind === "question" && inbound.attachment)
11711
+ )
11712
+ return;
11713
+ if (
11714
+ preliminaryControl.kind === "command" &&
11715
+ session?.ws.readyState !== WebSocket.OPEN &&
11716
+ !(reservedBtw?.kind === "question" && inbound.attachment)
11717
+ ) {
10361
11718
  if (await reserveRouteUpdate()) {
10362
11719
  try {
10363
11720
  await this.botApi.call("sendMessage", {
@@ -10374,18 +11731,22 @@ export class TelegramNotificationDaemon {
10374
11731
  }
10375
11732
  return;
10376
11733
  }
10377
- const reservedBtw = parseBtwCommand(inbound.text, this.botUsername);
10378
11734
  if (reservedBtw?.kind === "ignored") {
10379
11735
  await this.rememberSeenUpdateId(inbound.updateId);
10380
11736
  return;
10381
11737
  }
10382
11738
  if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
10383
- if (!(await reserveRouteUpdate())) return;
11739
+ const reserved =
11740
+ session?.ws.readyState === WebSocket.OPEN
11741
+ ? await reserveRouteUpdate()
11742
+ : await this.reserveSeenUpdateId(inbound.updateId);
11743
+ if (!reserved) return;
11744
+ if (session?.ws.readyState !== WebSocket.OPEN) await this.flushPool();
10384
11745
  await this.#sendBtwMessage({
10385
11746
  threadId: inbound.threadId,
10386
11747
  messageId: inbound.messageId,
10387
11748
  text: BTW_USAGE_TEXT,
10388
- isAuthoritative: routeLeaseAllows,
11749
+ isAuthoritative: session?.ws.readyState === WebSocket.OPEN ? routeLeaseAllows : () => true,
10389
11750
  });
10390
11751
  return;
10391
11752
  }
@@ -10627,19 +11988,102 @@ export class TelegramNotificationDaemon {
10627
11988
  const callbackId = (update as { callback_query?: { id?: unknown } }).callback_query?.id;
10628
11989
  if (await this.#handleAdoptionPickerCallback(update, callbackId)) return;
10629
11990
  if (await this.#handleModelChoiceCallback(update, callbackId)) return;
11991
+ const callbackAlias = (update as { callback_query?: { data?: unknown } }).callback_query?.data;
11992
+ const stagedActivation =
11993
+ typeof callbackAlias === "string" ? this.#stagedCallbackActivations.get(callbackAlias) : undefined;
11994
+ if (stagedActivation && !(await stagedActivation.settled)) {
11995
+ await this.sendStaleGuidance(callbackId);
11996
+ return;
11997
+ }
10630
11998
  const decision = routeInboundUpdate(update, {
10631
11999
  aliasTable: this.aliasTable,
10632
12000
  messageRoutes: this.messageRoutes,
10633
12001
  pairedChatId: this.opts.chatId,
10634
12002
  });
10635
12003
  if (decision.kind === "reply") {
12004
+ const callbackLease =
12005
+ typeof callbackAlias === "string" ? this.#callbackAliasLeases.get(callbackAlias) : undefined;
12006
+ const callbackPending =
12007
+ typeof callbackAlias === "string" ? this.#callbackAliasPending.get(callbackAlias) : undefined;
12008
+ const callbackTopic =
12009
+ typeof callbackAlias === "string" ? this.#callbackAliasTopics.get(callbackAlias) : undefined;
10636
12010
  const session = this.logicalSessionOwners.get(decision.sessionId) ?? this.sessions.get(decision.sessionId);
12011
+ const pendingAction = session?.pending.get(decision.actionId);
10637
12012
  if (
12013
+ (typeof callbackAlias === "string" &&
12014
+ (!callbackLease ||
12015
+ !callbackPending ||
12016
+ callbackPending !== pendingAction ||
12017
+ !this.#leaseTokenAllows(callbackLease) ||
12018
+ (callbackTopic
12019
+ ? !this.topicLeaseIsCurrent(callbackTopic)
12020
+ : this.topics.get(decision.sessionId) !== undefined))) ||
10638
12021
  session?.ws.readyState !== WebSocket.OPEN ||
10639
- !session.pending.has(decision.actionId) ||
10640
- !this.#leaseAllows(session, decision.sessionId) ||
10641
- (this.topics.get(decision.sessionId) !== undefined &&
10642
- !this.topicAuthorityLeaseFromRegistry(decision.sessionId))
12022
+ !pendingAction ||
12023
+ !this.#leaseAllows(session, decision.sessionId)
12024
+ ) {
12025
+ if (
12026
+ typeof callbackAlias === "string" &&
12027
+ callbackPending !== undefined &&
12028
+ callbackPending !== pendingAction &&
12029
+ this.#revokeCallbackAlias(callbackAlias)
12030
+ )
12031
+ await this.persistAliases();
12032
+ await this.sendStaleGuidance(callbackId);
12033
+ return;
12034
+ }
12035
+ const consumedAliases: Array<
12036
+ [
12037
+ string,
12038
+ CallbackRoute,
12039
+ { session: SessionSocket; token: number; logicalSessionId: string } | undefined,
12040
+ { sessionId: string; actionId: string } | undefined,
12041
+ TopicAuthorityLease | undefined,
12042
+ ]
12043
+ > = [];
12044
+ // Consume only controls from this exact rendered ask. Predecessor receipts
12045
+ // remain in the terminalization backlog until their authoritative outcome.
12046
+ for (const [alias, route] of this.aliasTable.entries()) {
12047
+ if (
12048
+ route.sessionId === decision.sessionId &&
12049
+ route.actionId === decision.actionId &&
12050
+ this.#callbackAliasPending.get(alias) === pendingAction
12051
+ ) {
12052
+ consumedAliases.push([
12053
+ alias,
12054
+ route,
12055
+ this.#callbackAliasLeases.get(alias),
12056
+ this.#callbackAliasPending.get(alias),
12057
+ this.#callbackAliasTopics.get(alias),
12058
+ ]);
12059
+ this.aliasTable.delete(alias);
12060
+ this.#callbackAliasLeases.delete(alias);
12061
+ this.#callbackAliasPending.delete(alias);
12062
+ this.#callbackAliasTopics.delete(alias);
12063
+ }
12064
+ }
12065
+ try {
12066
+ await this.persistAliases();
12067
+ } catch {
12068
+ for (const [alias, route, lease, pending, topic] of consumedAliases) {
12069
+ this.aliasTable.activate(alias, route);
12070
+ if (lease) this.#callbackAliasLeases.set(alias, lease);
12071
+ if (pending) this.#callbackAliasPending.set(alias, pending);
12072
+ if (topic) this.#callbackAliasTopics.set(alias, topic);
12073
+ }
12074
+ throw new Error("callback receipt consumption was not durably persisted");
12075
+ }
12076
+ if (
12077
+ session.ws.readyState !== WebSocket.OPEN ||
12078
+ session.pending.get(decision.actionId) !== pendingAction ||
12079
+ (typeof callbackAlias === "string" &&
12080
+ (!callbackPending ||
12081
+ callbackPending !== pendingAction ||
12082
+ !callbackLease ||
12083
+ !this.#leaseTokenAllows(callbackLease) ||
12084
+ (callbackTopic
12085
+ ? !this.topicLeaseIsCurrent(callbackTopic)
12086
+ : this.topics.get(decision.sessionId) !== undefined)))
10643
12087
  ) {
10644
12088
  await this.sendStaleGuidance(callbackId);
10645
12089
  return;
@@ -10694,6 +12138,12 @@ export class TelegramNotificationDaemon {
10694
12138
  // Runtime callers can bypass TypeScript's option type. Without a valid bot
10695
12139
  // token, there is no authenticated daemon identity or lifecycle authority.
10696
12140
  if (!validBotToken(this.opts.botToken)) return;
12141
+ if (this.validationMode()) {
12142
+ if (!/^-100\d+$/.test(this.opts.validationTestSupergroupChatId!))
12143
+ throw new Error("validation forum destination must be a negative -100... chat ID");
12144
+ if ((await this.resolvePairedChatPrivacy()) !== "validation-forum")
12145
+ throw new Error("validation forum destination must be the exact supergroup forum returned by getChat");
12146
+ }
10697
12147
  let ownershipProved = false;
10698
12148
  try {
10699
12149
  const renewed = await renewDaemonHeartbeat({
@@ -10713,19 +12163,22 @@ export class TelegramNotificationDaemon {
10713
12163
  if (!this.running) return;
10714
12164
  // Self-heal durable notification state before any scan/poll work so
10715
12165
  // `daemon reload` recovers dead roots + leak artifacts (#2956).
10716
- try {
10717
- await healTelegramDaemonNotificationState({
10718
- settings: this.opts.settings,
10719
- fs: this.fsImpl,
10720
- now: this.opts.now,
10721
- });
10722
- } catch (error) {
10723
- logger.warn(`notifications: startup self-heal failed: ${sanitizeDiagnostic(String(error))}`);
12166
+ if (!this.validationMode()) {
12167
+ try {
12168
+ await healTelegramDaemonNotificationState({
12169
+ settings: this.opts.settings,
12170
+ fs: this.fsImpl,
12171
+ now: this.opts.now,
12172
+ });
12173
+ } catch (error) {
12174
+ logger.warn(`notifications: startup self-heal failed: ${sanitizeDiagnostic(String(error))}`);
12175
+ }
10724
12176
  }
12177
+ await this.loadAliases();
10725
12178
  // Owner-only: start lifecycle control immediately after ownership proof,
10726
12179
  // before timers or pre-poll startup work can invalidate this run.
10727
12180
  // Best-effort; notification delivery remains available on failure.
10728
- await this.startLifecycleControl();
12181
+ if (!this.validationMode()) await this.startLifecycleControl();
10729
12182
  // A stop may arrive while lifecycle startup awaits its control endpoint.
10730
12183
  // Do not re-enable runtime work after that stop; close the partial server.
10731
12184
  if (!this.running) return;
@@ -10734,14 +12187,19 @@ export class TelegramNotificationDaemon {
10734
12187
  this.startFlushTimer();
10735
12188
  this.startScanTimer();
10736
12189
  this.startTypingTimer();
10737
- await this.refreshBotIdentity();
10738
- await this.registerBotCommands();
10739
- await this.loadAliases();
12190
+ if (!this.validationMode()) {
12191
+ await this.refreshBotIdentity();
12192
+ await this.registerBotCommands();
12193
+ }
10740
12194
  await this.loadTopics();
10741
- await this.loadAdoptionIntents();
10742
- this.startAdoptionSweepTimer();
10743
- await this.loadSeenUpdateIds();
10744
- await this.replyStore.load();
12195
+ if (!this.validationMode()) {
12196
+ await this.loadAdoptionIntents();
12197
+ this.startAdoptionSweepTimer();
12198
+ }
12199
+ if (!this.validationMode()) {
12200
+ await this.loadSeenUpdateIds();
12201
+ await this.replyStore.load();
12202
+ }
10745
12203
  await this.runScan();
10746
12204
  let idleSince = this.runtime.now();
10747
12205
  while (this.running) {
@@ -10807,37 +12265,43 @@ export class TelegramNotificationDaemon {
10807
12265
  }
10808
12266
  this.effects.beginShutdown();
10809
12267
  this.#deliveryAbort.abort();
12268
+ this.stopFlushTimer();
12269
+ this.stopScanTimer();
12270
+ this.stopTypingTimer();
12271
+ this.stopAdoptionSweepTimer();
12272
+ this.stopLifecycleControl();
10810
12273
  let persisted = false;
10811
- const shutdown = this.effects.allowTerminal(async () => {
10812
- if (toolShutdownError) throw toolShutdownError;
10813
- await this.#drainBtwTurns();
10814
- await this.toolTerminalizationChain;
10815
- this.stopFlushTimer();
10816
- this.stopScanTimer();
10817
- this.stopTypingTimer();
10818
- this.stopAdoptionSweepTimer();
10819
- this.stopLifecycleControl();
10820
- await this.cleanupAllAttachmentDirs();
10821
- await this.persistAliases();
10822
- await this.persistTopics();
10823
- await this.persistSeenUpdateIds();
10824
- await this.opts.control?.clear?.(this.opts.ownerId);
10825
- persisted = true;
10826
- });
10827
- const deadline = Promise.withResolvers<boolean>();
10828
- const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
10829
- const completed = await Promise.race([
10830
- shutdown.then(
10831
- () => true,
10832
- error => {
10833
- logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
10834
- return false;
10835
- },
10836
- ),
10837
- deadline.promise,
10838
- ]);
10839
- clearTimeout(deadlineTimer);
10840
- const quiesced = completed && heartbeatJoined && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
12274
+ const sessionEffectsQuiesced = await this.effects.join(BTW_SHUTDOWN_JOIN_MS);
12275
+ let completed = false;
12276
+ if (sessionEffectsQuiesced) {
12277
+ const shutdown = this.effects.allowTerminal(async () => {
12278
+ if (toolShutdownError) throw toolShutdownError;
12279
+ await this.#drainBtwTurns();
12280
+ await this.toolTerminalizationChain;
12281
+ await this.cleanupAllAttachmentDirs();
12282
+ if (!this.validationMode()) {
12283
+ await this.persistAliases();
12284
+ await this.persistSeenUpdateIds();
12285
+ await this.opts.control?.clear?.(this.opts.ownerId);
12286
+ }
12287
+ await this.persistTopics();
12288
+ persisted = true;
12289
+ });
12290
+ const deadline = Promise.withResolvers<boolean>();
12291
+ const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
12292
+ completed = await Promise.race([
12293
+ shutdown.then(
12294
+ () => true,
12295
+ error => {
12296
+ logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
12297
+ return false;
12298
+ },
12299
+ ),
12300
+ deadline.promise,
12301
+ ]);
12302
+ clearTimeout(deadlineTimer);
12303
+ }
12304
+ const quiesced = completed && heartbeatJoined && sessionEffectsQuiesced;
10841
12305
  if (!quiesced || !persisted) {
10842
12306
  logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
10843
12307
  } else {