@gajae-code/coding-agent 0.12.11 → 0.12.13

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 (140) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/types/cli/marketplace-hint.d.ts +28 -0
  3. package/dist/types/cli/notify-cli.d.ts +42 -1
  4. package/dist/types/cli/update-cli.d.ts +9 -3
  5. package/dist/types/cli.d.ts +10 -0
  6. package/dist/types/commands/notify.d.ts +6 -0
  7. package/dist/types/config/file-lock-gc.d.ts +13 -1
  8. package/dist/types/config/file-lock.d.ts +3 -0
  9. package/dist/types/config/model-registry.d.ts +6 -0
  10. package/dist/types/config/models-config-schema.d.ts +99 -0
  11. package/dist/types/coordinator/contract.d.ts +1 -1
  12. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  13. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  14. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  15. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  16. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  17. package/dist/types/gjc-runtime/deep-interview-state.d.ts +14 -0
  18. package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
  19. package/dist/types/main.d.ts +6 -2
  20. package/dist/types/modes/acp/acp-agent.d.ts +24 -1
  21. package/dist/types/modes/components/session-selector.d.ts +1 -1
  22. package/dist/types/modes/utils/host-status.d.ts +23 -0
  23. package/dist/types/runtime-mcp/types.d.ts +2 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  25. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  26. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  27. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  28. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  29. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  30. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  31. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  32. package/dist/types/sdk/bus/html-format.d.ts +10 -0
  33. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  34. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  35. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  36. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  37. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  38. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  39. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +28 -7
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -17
  41. package/dist/types/sdk/bus/telegram-reference.d.ts +20 -1
  42. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  43. package/dist/types/sdk/host/host.d.ts +52 -0
  44. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  45. package/dist/types/sdk/session-activation.d.ts +41 -0
  46. package/dist/types/sdk/session.d.ts +18 -0
  47. package/dist/types/sdk/startup-capability.d.ts +8 -1
  48. package/dist/types/session/agent-session.d.ts +7 -2
  49. package/dist/types/session/artifacts.d.ts +2 -2
  50. package/dist/types/session/session-manager.d.ts +40 -6
  51. package/dist/types/tools/index.d.ts +15 -0
  52. package/dist/types/utils/npm-registry.d.ts +71 -0
  53. package/examples/extensions/README.md +38 -4
  54. package/examples/extensions/ooo-bridge.ts +15 -0
  55. package/package.json +8 -8
  56. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  57. package/src/async/job-manager.ts +9 -2
  58. package/src/cli/fast-help.ts +1 -0
  59. package/src/cli/marketplace-hint.ts +39 -0
  60. package/src/cli/notify-cli.ts +226 -2
  61. package/src/cli/plugin-cli.ts +10 -0
  62. package/src/cli/update-cli.ts +90 -23
  63. package/src/cli.ts +23 -0
  64. package/src/commands/notify.ts +27 -3
  65. package/src/commands/sdk.ts +1 -0
  66. package/src/config/file-lock-gc.ts +65 -26
  67. package/src/config/file-lock.ts +61 -16
  68. package/src/config/model-profiles.ts +46 -30
  69. package/src/config/model-registry.ts +17 -5
  70. package/src/config/models-config-schema.ts +9 -4
  71. package/src/coordinator/contract.ts +1 -0
  72. package/src/coordinator-mcp/question-state.ts +2 -0
  73. package/src/coordinator-mcp/server.ts +358 -7
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  75. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  76. package/src/extensibility/extensions/index.ts +1 -0
  77. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  78. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  79. package/src/extensibility/extensions/runner.ts +15 -2
  80. package/src/extensibility/extensions/types.ts +2 -0
  81. package/src/gjc-runtime/deep-interview-recorder.ts +9 -5
  82. package/src/gjc-runtime/deep-interview-state.ts +27 -3
  83. package/src/gjc-runtime/gc-render.ts +8 -1
  84. package/src/gjc-runtime/gc-runtime.ts +15 -1
  85. package/src/gjc-runtime/launch-tmux.ts +53 -1
  86. package/src/internal-urls/docs-index.generated.ts +14 -13
  87. package/src/main.ts +214 -70
  88. package/src/modes/DESIGN.md +8 -5
  89. package/src/modes/acp/acp-agent.ts +123 -7
  90. package/src/modes/components/footer.ts +10 -18
  91. package/src/modes/components/model-selector.ts +52 -5
  92. package/src/modes/components/session-selector.ts +26 -3
  93. package/src/modes/components/status-line/gh.ts +23 -2
  94. package/src/modes/controllers/event-controller.ts +3 -0
  95. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  96. package/src/modes/controllers/input-controller.ts +11 -0
  97. package/src/modes/controllers/selector-controller.ts +8 -2
  98. package/src/modes/interactive-mode.ts +7 -1
  99. package/src/modes/print-mode.ts +10 -0
  100. package/src/modes/utils/host-status.ts +38 -0
  101. package/src/prompts/system/project-prompt.md +19 -0
  102. package/src/runtime-mcp/types.ts +2 -0
  103. package/src/sdk/broker/lifecycle.ts +60 -3
  104. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  105. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  106. package/src/sdk/bus/chat-daemon-control.ts +16 -3
  107. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  108. package/src/sdk/bus/control-plane-frames.ts +51 -0
  109. package/src/sdk/bus/conversation-store.ts +29 -1
  110. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  111. package/src/sdk/bus/html-format.ts +19 -3
  112. package/src/sdk/bus/index.ts +91 -12
  113. package/src/sdk/bus/notification-service.ts +28 -1
  114. package/src/sdk/bus/rich-render.ts +3 -2
  115. package/src/sdk/bus/slack-daemon.ts +99 -4
  116. package/src/sdk/bus/slack-live-provider.ts +32 -2
  117. package/src/sdk/bus/slack-provider.ts +11 -0
  118. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  119. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  120. package/src/sdk/bus/telegram-daemon-contract.ts +28 -7
  121. package/src/sdk/bus/telegram-daemon.ts +2007 -509
  122. package/src/sdk/bus/telegram-reference.ts +114 -15
  123. package/src/sdk/bus/topic-registry.ts +674 -181
  124. package/src/sdk/host/host.ts +180 -1
  125. package/src/sdk/lifecycle-session.ts +8 -2
  126. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  127. package/src/sdk/session-activation.ts +127 -0
  128. package/src/sdk/session.ts +144 -29
  129. package/src/sdk/startup-capability.ts +10 -1
  130. package/src/session/agent-session.ts +220 -13
  131. package/src/session/artifacts.ts +56 -13
  132. package/src/session/internal/managed-session-storage.ts +228 -7
  133. package/src/session/session-manager.ts +225 -60
  134. package/src/setup/provider-presets.json +3 -3
  135. package/src/task/index.ts +92 -44
  136. package/src/thinking-metadata.ts +2 -1
  137. package/src/tools/ask.ts +10 -0
  138. package/src/tools/index.ts +15 -0
  139. package/src/tools/todo-write.ts +4 -4
  140. 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";
@@ -47,6 +48,8 @@ import {
47
48
  buildCompactChoiceGrid,
48
49
  code,
49
50
  markdownToTelegramHtml,
51
+ SELECTION_MARK_CHECKED,
52
+ SELECTION_MARK_UNCHECKED,
50
53
  splitTelegramHtml,
51
54
  TELEGRAM_MESSAGE_LIMIT,
52
55
  TELEGRAM_PARSE_MODE,
@@ -107,7 +110,13 @@ import {
107
110
  } from "./telegram-reference";
108
111
  import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
109
112
  import { renderThreadedFrame, supportsTelegramPhotoUpload, type ThreadedSend } from "./threaded-render";
110
- import { type TopicEndpointBinding, TopicRegistry, type TopicRegistryState } from "./topic-registry";
113
+ import {
114
+ parseTopicRegistryState,
115
+ type TopicEndpointBinding,
116
+ TopicRegistry,
117
+ type TopicRegistryCasAuthority,
118
+ type TopicRegistryState,
119
+ } from "./topic-registry";
111
120
 
112
121
  export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
113
122
  /** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */
@@ -146,15 +155,27 @@ export interface DaemonState {
146
155
  servingEpoch?: number;
147
156
  stoppedAt?: number;
148
157
  }
158
+ interface ExactFileStat {
159
+ dev: bigint;
160
+ ino: bigint;
161
+ nlink: bigint;
162
+ size: bigint;
163
+ mtimeNs: bigint;
164
+ isFile(): boolean;
165
+ }
166
+
149
167
  export interface TelegramDaemonFs {
150
168
  mkdir(path: string, opts?: fs.MakeDirectoryOptions): Promise<void>;
151
169
  readFile(path: string, encoding: BufferEncoding): Promise<string>;
152
170
  writeFile(path: string, data: string, opts?: fs.WriteFileOptions): Promise<void>;
153
171
  rename(oldPath: string, newPath: string): Promise<void>;
154
172
  unlink(path: string): Promise<void>;
155
- open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
173
+ open(path: string, flags: string, mode?: number): Promise<{ sync?: () => Promise<void>; close(): Promise<void> }>;
156
174
  readdir(path: string): Promise<string[]>;
157
175
  chmod(path: string, mode: number): Promise<void>;
176
+ /** Crash-atomic persistence seams. Implementations without them fail closed. */
177
+ fsyncFile?(path: string): Promise<void>;
178
+ fsyncDirectory?(path: string): Promise<void>;
158
179
  stat?(path: string): Promise<{
159
180
  mtimeMs: number;
160
181
  size?: number;
@@ -163,8 +184,9 @@ export interface TelegramDaemonFs {
163
184
  /** Hard-link count; required to prove a staging temp has no second name. */
164
185
  nlink?: number;
165
186
  ctimeMs?: number;
166
- isDirectory?: () => boolean;
187
+ isDirectory?(): boolean;
167
188
  }>;
189
+ lstat?(path: string, opts: { bigint: true }): Promise<ExactFileStat>;
168
190
  readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
169
191
  exactUnlink?(
170
192
  path: string,
@@ -233,6 +255,22 @@ function negotiateToolActivityCapability(
233
255
 
234
256
  const nodeFs: TelegramDaemonFs = {
235
257
  ...(fs.promises as unknown as TelegramDaemonFs),
258
+ fsyncFile: async file => {
259
+ const handle = await fs.promises.open(file, "r+");
260
+ try {
261
+ await handle.sync();
262
+ } finally {
263
+ await handle.close();
264
+ }
265
+ },
266
+ fsyncDirectory: async directory => {
267
+ const handle = await fs.promises.open(directory, "r");
268
+ try {
269
+ await handle.sync();
270
+ } finally {
271
+ await handle.close();
272
+ }
273
+ },
236
274
  readEndpointFile: readNotificationEndpointFile,
237
275
  exactUnlink: async (file, identity, quarantineName) =>
238
276
  exactUnlinkNotificationFile(
@@ -347,6 +385,10 @@ const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar va
347
385
  type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
348
386
  type TelegramFileDownload = { bytes: Buffer } | { failure: "download_failed" | "too_large" };
349
387
  class ThreadedModeCapabilityRefusal extends Error {}
388
+ /** Telegram answered `createForumTopic` with an explicit `ok: false` rejection. */
389
+ class TopicCreationRejected extends Error {}
390
+ /** The transport suppressed `createForumTopic` (no response body to classify). */
391
+ class TopicCreationSuppressed extends Error {}
350
392
 
351
393
  async function prepareTelegramImageAttachment(frame: Record<string, unknown>): Promise<Record<string, unknown>> {
352
394
  if (frame.type !== "image_attachment") return frame;
@@ -386,7 +428,7 @@ function isThreadedModeCapabilityRefusal(response: unknown): boolean {
386
428
  return (
387
429
  ok === false &&
388
430
  typeof description === "string" &&
389
- /(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics?)/i.test(
431
+ /(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics?|chat is not a forum)/i.test(
390
432
  description,
391
433
  )
392
434
  );
@@ -479,12 +521,15 @@ function endpointGenerationKey(url: string, token: string): string {
479
521
  function topicRenameApplied(response: unknown): boolean {
480
522
  return !!response && typeof response === "object" && (response as { ok?: unknown }).ok === true;
481
523
  }
482
- function topicDeleteSettled(response: unknown): boolean {
524
+ function topicArchiveSettled(response: unknown): boolean {
483
525
  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);
526
+ const result = response as { ok?: unknown; result?: unknown; error_code?: unknown; description?: unknown };
527
+ if (result.ok === true && result.result === true) return true;
528
+ if (result.ok !== false || result.error_code !== 400 || typeof result.description !== "string") return false;
529
+ const description = result.description.trim();
530
+ return /^(?:Bad Request: )?(?:TOPIC_NOT_FOUND|THREAD_NOT_FOUND|topic (?:already|is already) closed|message thread (?:not found|is not modified))$/i.test(
531
+ description,
532
+ );
488
533
  }
489
534
 
490
535
  /**
@@ -589,11 +634,428 @@ async function readJson<T>(fsImpl: TelegramDaemonFs, file: string): Promise<T |
589
634
  }
590
635
  }
591
636
 
592
- async function writeJsonAtomic(fsImpl: TelegramDaemonFs, file: string, data: unknown): Promise<void> {
637
+ async function writeJsonAtomic(
638
+ fsImpl: TelegramDaemonFs,
639
+ file: string,
640
+ data: unknown,
641
+ options: { durable?: boolean } = {},
642
+ ): Promise<void> {
593
643
  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);
644
+ let renamed = false;
645
+ try {
646
+ await fsImpl.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
647
+ await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
648
+ if (options.durable) await syncTelegramFile(fsImpl, tmp);
649
+ await fsImpl.rename(tmp, file);
650
+ renamed = true;
651
+ if (options.durable) await syncTelegramDirectory(fsImpl, path.dirname(file));
652
+ } catch (error) {
653
+ if (renamed) throw error;
654
+ let cleanupError: unknown;
655
+ try {
656
+ await fsImpl.unlink(tmp);
657
+ } catch (candidate) {
658
+ if ((candidate as NodeJS.ErrnoException).code !== "ENOENT") cleanupError = candidate;
659
+ }
660
+ if (cleanupError) throw new AggregateError([error, cleanupError], "Atomic JSON write and cleanup failed");
661
+ throw error;
662
+ }
663
+ }
664
+
665
+ async function syncTelegramFile(fsImpl: TelegramDaemonFs, file: string): Promise<void> {
666
+ const handle = await fsImpl.open(file, "r+");
667
+ let syncError: unknown;
668
+ try {
669
+ if (typeof handle.sync !== "function")
670
+ throw new Error("Telegram alias durability requires filesystem sync support");
671
+ await handle.sync();
672
+ } catch (error) {
673
+ syncError = error;
674
+ }
675
+ let closeError: unknown;
676
+ try {
677
+ await handle.close();
678
+ } catch (error) {
679
+ closeError = error;
680
+ }
681
+ if (syncError && closeError)
682
+ throw new AggregateError([syncError, closeError], "Telegram alias file sync and close failed");
683
+ if (syncError) throw syncError;
684
+ if (closeError) throw closeError;
685
+ }
686
+
687
+ async function syncTelegramDirectory(fsImpl: TelegramDaemonFs, directory: string): Promise<void> {
688
+ let handle: Awaited<ReturnType<TelegramDaemonFs["open"]>>;
689
+ try {
690
+ handle = await fsImpl.open(directory, "r");
691
+ } catch (error) {
692
+ if (process.platform === "win32" && isUnsupportedTelegramDirectoryBarrier(error)) return;
693
+ throw error;
694
+ }
695
+ let syncError: unknown;
696
+ try {
697
+ if (typeof handle.sync !== "function")
698
+ throw new Error("Telegram alias durability requires filesystem sync support");
699
+ await handle.sync();
700
+ } catch (error) {
701
+ if (!(process.platform === "win32" && isUnsupportedTelegramDirectoryBarrier(error))) syncError = error;
702
+ }
703
+ let closeError: unknown;
704
+ try {
705
+ await handle.close();
706
+ } catch (error) {
707
+ closeError = error;
708
+ }
709
+ if (syncError && closeError)
710
+ throw new AggregateError([syncError, closeError], "Telegram alias directory sync and close failed");
711
+ if (syncError) throw syncError;
712
+ if (closeError) throw closeError;
713
+ }
714
+
715
+ function isUnsupportedTelegramDirectoryBarrier(error: unknown): boolean {
716
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
717
+ return code === "EINVAL" || code === "EISDIR" || code === "ENOTSUP" || code === "EPERM";
718
+ }
719
+
720
+ export class TopicRegistryDurabilityUnavailableError extends Error {
721
+ readonly code = "durability_unavailable";
722
+
723
+ constructor(cause: unknown) {
724
+ super("topic registry durability is unavailable", { cause });
725
+ this.name = "TopicRegistryDurabilityUnavailableError";
726
+ }
727
+ }
728
+
729
+ type TopicRegistryExactReplace = (
730
+ sourcePath: string,
731
+ destinationPath: string,
732
+ expectedSource: NativeExactFileIdentity,
733
+ expectedDestination: NativeExactFileIdentity,
734
+ ) => NativeExactUnlinkResult;
735
+
736
+ function sameExactFileStat(left: ExactFileStat, right: ExactFileStat): boolean {
737
+ return (
738
+ left.dev === right.dev &&
739
+ left.ino === right.ino &&
740
+ left.nlink === right.nlink &&
741
+ left.size === right.size &&
742
+ left.mtimeNs === right.mtimeNs
743
+ );
744
+ }
745
+
746
+ async function captureExactFileSnapshot(
747
+ fsImpl: TelegramDaemonFs,
748
+ file: string,
749
+ ): Promise<{ bytes: string; identity: NativeExactFileIdentity }> {
750
+ if (!fsImpl.lstat)
751
+ throw new TopicRegistryDurabilityUnavailableError(
752
+ new Error("topic registry persistence requires no-follow file identity"),
753
+ );
754
+ const before = await fsImpl.lstat(file, { bigint: true });
755
+ const bytes = await fsImpl.readFile(file, "utf8");
756
+ const after = await fsImpl.lstat(file, { bigint: true });
757
+ const parentStat = await fsImpl.lstat(path.dirname(file), { bigint: true });
758
+ if (!before.isFile() || !after.isFile() || before.nlink !== 1n || !sameExactFileStat(before, after))
759
+ throw new TopicRegistryDurabilityUnavailableError(
760
+ new Error("topic registry persistence requires a stable single-link regular file"),
761
+ );
762
+ return {
763
+ bytes,
764
+ identity: {
765
+ dev: after.dev,
766
+ ino: after.ino,
767
+ nlink: after.nlink,
768
+ parentDev: parentStat.dev,
769
+ parentIno: parentStat.ino,
770
+ size: after.size,
771
+ mtimeNs: after.mtimeNs,
772
+ sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
773
+ },
774
+ };
775
+ }
776
+ async function captureTopicRegistryDestination(
777
+ fsImpl: TelegramDaemonFs,
778
+ file: string,
779
+ platform: NodeJS.Platform,
780
+ ): Promise<NativeExactFileIdentity | undefined> {
781
+ if (platform !== "win32") return undefined;
782
+ try {
783
+ return (await captureExactFileSnapshot(fsImpl, file)).identity;
784
+ } catch (error) {
785
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
786
+ throw error;
787
+ }
788
+ }
789
+
790
+ /** Publish topic authority only after its staged bytes and replacement are durable. */
791
+ export async function writeTopicRegistryAtomic(
792
+ fsImpl: TelegramDaemonFs,
793
+ file: string,
794
+ data: unknown,
795
+ platform: NodeJS.Platform = process.platform,
796
+ exactReplace: TopicRegistryExactReplace = exactReplacePath,
797
+ expectedDestination?: NativeExactFileIdentity,
798
+ ): Promise<void> {
799
+ if (!fsImpl.fsyncFile)
800
+ throw new TopicRegistryDurabilityUnavailableError(
801
+ new Error("topic registry persistence requires file fsync crash-atomicity"),
802
+ );
803
+ if (platform !== "win32" && !fsImpl.fsyncDirectory)
804
+ throw new TopicRegistryDurabilityUnavailableError(
805
+ new Error("topic registry persistence requires directory fsync crash-atomicity"),
806
+ );
807
+ const parent = path.dirname(file);
808
+ const serialized = `${JSON.stringify(data, null, 2)}\n`;
809
+ let destinationExists = true;
810
+ try {
811
+ await fsImpl.readFile(file, "utf8");
812
+ } catch (error) {
813
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
814
+ destinationExists = false;
815
+ }
816
+ if (!destinationExists && expectedDestination)
817
+ throw new TopicRegistryDurabilityUnavailableError(
818
+ new Error("topic registry destination disappeared after generation validation"),
819
+ );
820
+ if (!destinationExists) {
821
+ try {
822
+ await fsImpl.writeFile(file, serialized, { mode: 0o600, flag: "wx" });
823
+ } catch (error) {
824
+ if ((error as NodeJS.ErrnoException).code === "EEXIST")
825
+ throw new TopicRegistryDurabilityUnavailableError(
826
+ new Error("topic registry appeared during exclusive creation"),
827
+ );
828
+ throw error;
829
+ }
830
+ await fsImpl.chmod(file, 0o600).catch(() => undefined);
831
+ await fsImpl.fsyncFile(file);
832
+ if (platform !== "win32") await fsImpl.fsyncDirectory!(parent);
833
+ const written = await fsImpl.readFile(file, "utf8");
834
+ if (written !== serialized)
835
+ throw new TopicRegistryDurabilityUnavailableError(
836
+ new Error("exclusive topic registry creation verification failed"),
837
+ );
838
+ return;
839
+ }
840
+
841
+ const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
842
+ try {
843
+ await fsImpl.writeFile(tmp, serialized, { mode: 0o600, flag: "wx" });
844
+ await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
845
+ await fsImpl.fsyncFile(tmp);
846
+ if (platform === "win32") {
847
+ if (typeof exactReplace !== "function")
848
+ throw new TopicRegistryDurabilityUnavailableError(
849
+ new Error("native Windows exact replacement is unavailable"),
850
+ );
851
+ if (!expectedDestination)
852
+ throw new TopicRegistryDurabilityUnavailableError(
853
+ new Error("native Windows replacement requires the validated destination identity"),
854
+ );
855
+ const expectedSource = (await captureExactFileSnapshot(fsImpl, tmp)).identity;
856
+ const outcome = exactReplace(tmp, file, expectedSource, expectedDestination);
857
+ if (!outcome.ok)
858
+ throw new TopicRegistryDurabilityUnavailableError(
859
+ new Error(`native Windows topic replacement failed: ${outcome.code ?? "unknown"}`),
860
+ );
861
+ } else {
862
+ await fsImpl.rename(tmp, file);
863
+ await fsImpl.fsyncDirectory!(parent);
864
+ }
865
+ const written = await fsImpl.readFile(file, "utf8");
866
+ if (written !== serialized)
867
+ throw new TopicRegistryDurabilityUnavailableError(new Error("topic registry replacement verification failed"));
868
+ } catch (error) {
869
+ await fsImpl.unlink(tmp).catch(() => undefined);
870
+ throw error;
871
+ }
872
+ }
873
+ /**
874
+ * Shared-volume topic authority backed by the existing cross-process file lock.
875
+ * A missing authority is the only valid bootstrap state; malformed and future
876
+ * snapshots are never interpreted as empty state.
877
+ */
878
+ export class FilesystemTopicRegistryCasAuthority implements TopicRegistryCasAuthority {
879
+ private readonly fsImpl: TelegramDaemonFs;
880
+ private readonly platform: NodeJS.Platform;
881
+ private readonly exactReplace: TopicRegistryExactReplace;
882
+ private readonly installationHostId: string;
883
+
884
+ constructor(
885
+ private readonly file: string,
886
+ input: {
887
+ installationHostId: string;
888
+ fs?: TelegramDaemonFs;
889
+ platform?: NodeJS.Platform;
890
+ exactReplace?: TopicRegistryExactReplace;
891
+ },
892
+ ) {
893
+ if (!input.installationHostId) throw new Error("installationHostId must be non-empty");
894
+ this.installationHostId = input.installationHostId;
895
+ this.fsImpl = input.fs ?? nodeFs;
896
+ this.platform = input.platform ?? process.platform;
897
+ this.exactReplace = input.exactReplace ?? exactReplacePath;
898
+ }
899
+
900
+ async read(): Promise<TopicRegistryState> {
901
+ return await withFileLock(this.file, async () => await this.readLocked(), {
902
+ staleMs: 10_000,
903
+ ownerHostId: this.installationHostId,
904
+ });
905
+ }
906
+
907
+ async compareAndSet(expectedGeneration: number, next: TopicRegistryState): Promise<boolean> {
908
+ if (!Number.isSafeInteger(expectedGeneration) || expectedGeneration < 0)
909
+ throw new Error("invalid expected topic registry generation");
910
+ if (
911
+ next.version !== 2 ||
912
+ next.registryGeneration !== expectedGeneration + 1 ||
913
+ parseTopicRegistryState(next) === undefined
914
+ )
915
+ throw new Error("invalid next topic registry generation");
916
+ return await withFileLock(
917
+ this.file,
918
+ async () => {
919
+ const { state: current, legacyRaw, expectedDestination } = await this.#readLockedWithLegacy();
920
+ if (current.registryGeneration !== expectedGeneration) return false;
921
+ await ensureDir(this.fsImpl, path.dirname(this.file));
922
+ if (legacyRaw !== undefined) await this.#quarantineLegacyLocked(legacyRaw);
923
+ await writeTopicRegistryAtomic(
924
+ this.fsImpl,
925
+ this.file,
926
+ next,
927
+ this.platform,
928
+ this.exactReplace,
929
+ expectedDestination,
930
+ );
931
+ return true;
932
+ },
933
+ { staleMs: 10_000, ownerHostId: this.installationHostId },
934
+ );
935
+ }
936
+
937
+ private async readLocked(): Promise<TopicRegistryState> {
938
+ return (await this.#readLockedWithLegacy()).state;
939
+ }
940
+
941
+ async #readLockedWithLegacy(): Promise<{
942
+ state: TopicRegistryState;
943
+ legacyRaw?: unknown;
944
+ expectedDestination?: NativeExactFileIdentity;
945
+ }> {
946
+ let raw: unknown;
947
+ let expectedDestination: NativeExactFileIdentity | undefined;
948
+ try {
949
+ if (this.platform === "win32") {
950
+ const snapshot = await captureExactFileSnapshot(this.fsImpl, this.file);
951
+ raw = JSON.parse(snapshot.bytes);
952
+ expectedDestination = snapshot.identity;
953
+ } else {
954
+ raw = JSON.parse(await this.fsImpl.readFile(this.file, "utf8"));
955
+ }
956
+ } catch (error) {
957
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
958
+ return { state: { version: 2, registryGeneration: 0, topics: {} } };
959
+ throw new Error("shared topic authority is malformed or unavailable");
960
+ }
961
+ const legacyRaw =
962
+ raw && typeof raw === "object" && !Array.isArray(raw) && !Object.hasOwn(raw as object, "version")
963
+ ? raw
964
+ : undefined;
965
+ const state = parseTopicRegistryState(raw);
966
+ const generation = state?.registryGeneration;
967
+ if (state?.version !== 2 || generation === undefined || !Number.isSafeInteger(generation) || generation < 0)
968
+ throw new Error("shared topic authority is malformed or unsupported");
969
+ return {
970
+ state: { ...state, registryGeneration: generation },
971
+ ...(legacyRaw === undefined ? {} : { legacyRaw }),
972
+ ...(expectedDestination === undefined ? {} : { expectedDestination }),
973
+ };
974
+ }
975
+
976
+ async #quarantineLegacyLocked(raw: unknown): Promise<void> {
977
+ const digest = crypto.createHash("sha256").update(JSON.stringify(raw)).digest("hex");
978
+ const quarantinePath = `${this.file}.legacy-quarantine.${digest}.json`;
979
+ try {
980
+ await this.fsImpl.readFile(quarantinePath, "utf8");
981
+ return;
982
+ } catch (error) {
983
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
984
+ throw new Error("shared topic authority is malformed or unavailable");
985
+ }
986
+ await writeTopicRegistryAtomic(this.fsImpl, quarantinePath, raw, this.platform, this.exactReplace);
987
+ }
988
+ }
989
+
990
+ export interface MachineIdentityDeps {
991
+ platform?: NodeJS.Platform;
992
+ readFile?: (file: string) => Promise<string>;
993
+ runCommand?: (command: string, args: readonly string[]) => { exitCode: number; stdout: Uint8Array };
994
+ }
995
+
996
+ function normalizedMachineIdentity(value: string, pattern: RegExp): string | undefined {
997
+ const normalized = value.trim().toLowerCase();
998
+ if (!pattern.test(normalized) || /^0+$/.test(normalized.replace(/-/g, ""))) return undefined;
999
+ return normalized;
1000
+ }
1001
+
1002
+ /** @internal */
1003
+ export function parseWindowsMachineGuid(output: string): string | undefined {
1004
+ const match = /^\s*MachineGuid\s+REG_\w+\s+(\S+)\s*$/im.exec(output);
1005
+ return match
1006
+ ? normalizedMachineIdentity(match[1], /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
1007
+ : undefined;
1008
+ }
1009
+
1010
+ /** @internal */
1011
+ export function parseMacPlatformUuid(output: string): string | undefined {
1012
+ const match = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(output);
1013
+ return match
1014
+ ? normalizedMachineIdentity(match[1], /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
1015
+ : undefined;
1016
+ }
1017
+
1018
+ function hashMachineIdentity(rawId: string): string {
1019
+ return crypto
1020
+ .createHash("sha256")
1021
+ .update("gajae-code:telegram-daemon:machine-identity:v1\0")
1022
+ .update(rawId)
1023
+ .digest("hex");
1024
+ }
1025
+
1026
+ /** Loads a verified machine-local identity without persisting the underlying machine ID. */
1027
+ export async function loadInstallationHostId(deps: MachineIdentityDeps = {}): Promise<string> {
1028
+ const platform = deps.platform ?? process.platform;
1029
+ const readFile = deps.readFile ?? (async (file: string) => await fs.promises.readFile(file, "utf8"));
1030
+ const runCommand =
1031
+ deps.runCommand ??
1032
+ ((command: string, args: readonly string[]) =>
1033
+ Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "ignore" }));
1034
+
1035
+ let rawId: string | undefined;
1036
+ if (platform === "linux") {
1037
+ for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
1038
+ try {
1039
+ const value = normalizedMachineIdentity(await readFile(file), /^[0-9a-f]{32}$/);
1040
+ if (!value) throw new Error("machine-local identity is unavailable or malformed");
1041
+ rawId = value;
1042
+ break;
1043
+ } catch (error) {
1044
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1045
+ }
1046
+ }
1047
+ } else if (platform === "win32") {
1048
+ const result = runCommand("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]);
1049
+ if (result.exitCode === 0) rawId = parseWindowsMachineGuid(new TextDecoder().decode(result.stdout));
1050
+ } else if (platform === "darwin") {
1051
+ const result = runCommand("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
1052
+ if (result.exitCode === 0) rawId = parseMacPlatformUuid(new TextDecoder().decode(result.stdout));
1053
+ } else {
1054
+ throw new Error(`machine-local identity is unsupported on ${platform}`);
1055
+ }
1056
+
1057
+ if (!rawId) throw new Error("machine-local identity is unavailable or malformed");
1058
+ return hashMachineIdentity(rawId);
597
1059
  }
598
1060
 
599
1061
  function validDaemonPid(pid: unknown): pid is number {
@@ -2184,7 +2646,8 @@ export function isCurrentCompatibleOwner(input: {
2184
2646
  state?.ownershipPhase === "ready" &&
2185
2647
  typeof state.acquisitionId === "string" &&
2186
2648
  state.acquisitionId.length > 0 &&
2187
- (state.servingEpoch === undefined ? 1 : state.servingEpoch) >= SERVING_EPOCH,
2649
+ state.generation === DAEMON_GENERATION &&
2650
+ state.servingEpoch === SERVING_EPOCH,
2188
2651
  );
2189
2652
  }
2190
2653
 
@@ -3122,6 +3585,8 @@ export interface TelegramSpawnOwnerInput {
3122
3585
  roots?: string[];
3123
3586
  tokenFingerprint: string;
3124
3587
  chatId: string;
3588
+ /** Ephemeral outbound-only validation destination for this owner launch. */
3589
+ validationTestSupergroupChatId?: string;
3125
3590
  }
3126
3591
 
3127
3592
  export interface TelegramSpawnAcquisition {
@@ -3149,7 +3614,12 @@ export type TelegramSpawnOwnerResult =
3149
3614
  * Source mode prepends the entry script so the respawn loads edited source;
3150
3615
  * a compiled binary self-spawns its own subcommand directly.
3151
3616
  */
3152
- export function buildTelegramDaemonSpawnArgs(input: { execPath?: string; ownerId: string; agentDir: string }): {
3617
+ export function buildTelegramDaemonSpawnArgs(input: {
3618
+ execPath?: string;
3619
+ ownerId: string;
3620
+ agentDir: string;
3621
+ validationTestSupergroupChatId?: string;
3622
+ }): {
3153
3623
  command: string;
3154
3624
  args: string[];
3155
3625
  runtime: DaemonRuntimeInfo;
@@ -3163,6 +3633,9 @@ export function buildTelegramDaemonSpawnArgs(input: { execPath?: string; ownerId
3163
3633
  input.ownerId,
3164
3634
  "--agent-dir",
3165
3635
  input.agentDir,
3636
+ ...(input.validationTestSupergroupChatId !== undefined
3637
+ ? ["--validation-test-supergroup-chat-id", input.validationTestSupergroupChatId]
3638
+ : []),
3166
3639
  ];
3167
3640
  const runtime: DaemonRuntimeInfo = {
3168
3641
  mode: rt.mode,
@@ -3238,6 +3711,7 @@ export async function spawnTelegramDaemonOwner(
3238
3711
  execPath,
3239
3712
  ownerId: provisionalAcquisition.ownerId,
3240
3713
  agentDir,
3714
+ validationTestSupergroupChatId: input.validationTestSupergroupChatId,
3241
3715
  });
3242
3716
  const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
3243
3717
  const child = spawnImpl(command, args, {
@@ -3419,13 +3893,17 @@ async function ensureTelegramDaemonRunningDetailedOnce(
3419
3893
  // unproven cooperative handoff into destructive cleanup or a replacement spawn.
3420
3894
  if ((deps.platform ?? process.platform) === "win32") {
3421
3895
  const parentState: unknown = await readDaemonState(input.settings, deps.fs);
3422
- if (isParentDaemonState(parentState) && (deps.pidAlive ?? defaultPidAlive)(parentState.pid))
3896
+ if (
3897
+ isParentDaemonState(parentState) &&
3898
+ (deps.pidAlive ?? defaultPidAlive)(parentState.pid) &&
3899
+ (!hasSafeDaemonStateShape(parentState) || ownerProvenanceMatches(parentState, deps.pidIncarnation))
3900
+ )
3423
3901
  return "blocked_identity";
3424
3902
  }
3425
3903
  // Windows can retain dead launcher metadata without an ownership lock; reclaim
3426
3904
  // its dead discovery records before the replacement can publish a new owner.
3427
3905
  if ((deps.platform ?? process.platform) === "win32" && !deps.fs) {
3428
- const preflight = await reclaimDeadDaemonOwner({
3906
+ let preflight = await reclaimDeadDaemonOwner({
3429
3907
  settings: input.settings,
3430
3908
  endpointDir: path.join(root, "sdk"),
3431
3909
  fs: deps.fs,
@@ -3433,6 +3911,28 @@ async function ensureTelegramDaemonRunningDetailedOnce(
3433
3911
  pidAlive: deps.pidAlive,
3434
3912
  pidIncarnation: deps.pidIncarnation,
3435
3913
  });
3914
+ // A just-reused PID can be reported alive for the first probe. Recheck once
3915
+ // before acquisition only when stale discovery records need fenced cleanup.
3916
+ // reclaimDeadDaemonOwner still requires exact state, lock, incarnation,
3917
+ // transition-lock, and endpoint-identity proofs before deleting anything.
3918
+ const endpointNames =
3919
+ preflight.reason === "not-confirmed-dead"
3920
+ ? await (deps.fs ?? nodeFs).readdir(path.join(root, "sdk")).catch(() => undefined)
3921
+ : undefined;
3922
+ if (
3923
+ !preflight.recovered &&
3924
+ preflight.reason === "not-confirmed-dead" &&
3925
+ endpointNames?.some(name => name.endsWith(".json"))
3926
+ ) {
3927
+ preflight = await reclaimDeadDaemonOwner({
3928
+ settings: input.settings,
3929
+ endpointDir: path.join(root, "sdk"),
3930
+ fs: deps.fs,
3931
+ now: deps.now,
3932
+ pidAlive: deps.pidAlive,
3933
+ pidIncarnation: deps.pidIncarnation,
3934
+ });
3935
+ }
3436
3936
  if (!preflight.recovered && preflight.reason !== "not-confirmed-dead") {
3437
3937
  logger.warn(
3438
3938
  `notifications: startup recovery unsafe (${preflight.reason}); run \`gjc notify recovery\` for diagnostics`,
@@ -3849,7 +4349,7 @@ export class TelegramBotTransport implements BotApi {
3849
4349
  }
3850
4350
  }
3851
4351
 
3852
- type PairedChatPrivacy = "private" | "non-private" | "indeterminate";
4352
+ type PairedChatPrivacy = "private" | "validation-forum" | "non-private" | "indeterminate";
3853
4353
 
3854
4354
  export type TelegramUpdateOutcome = "consumed" | "retry";
3855
4355
 
@@ -4065,6 +4565,12 @@ export interface TelegramDaemonOptions {
4065
4565
  ownerId: string;
4066
4566
  botToken: string;
4067
4567
  chatId: string;
4568
+ /**
4569
+ * Exact ephemeral forum destination supplied to the daemon-internal validation
4570
+ * command. Production ownership, pairing, and durable topic authority remain
4571
+ * bound to `chatId`.
4572
+ */
4573
+ validationTestSupergroupChatId?: string;
4068
4574
  apiBase?: string;
4069
4575
  fetchImpl?: typeof fetch;
4070
4576
  fs?: TelegramDaemonFs;
@@ -4116,6 +4622,20 @@ export interface TelegramDaemonOptions {
4116
4622
  * built-in `{repo}/{branch} - {title}` composition and its fallbacks.
4117
4623
  */
4118
4624
  topics?: { nameTemplate?: string };
4625
+ /**
4626
+ * Optional compare-and-set store for installations that share topic state
4627
+ * across hosts. When configured, every publication is fenced by it.
4628
+ */
4629
+ topicRegistryAuthority?: TopicRegistryCasAuthority;
4630
+ /** Stable host-local identity. Required whenever a shared authority is configured. */
4631
+ installationHostId?: string;
4632
+ }
4633
+
4634
+ interface StagedCallbackActivation {
4635
+ pending: { sessionId: string; actionId: string };
4636
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
4637
+ topicLease?: TopicAuthorityLease;
4638
+ settled: Promise<boolean>;
4119
4639
  }
4120
4640
 
4121
4641
  interface SessionSocket {
@@ -4226,7 +4746,8 @@ interface PendingThreadedFrame {
4226
4746
  send: ThreadedSend;
4227
4747
  msg: Record<string, unknown>;
4228
4748
  logicalSessionId: string;
4229
- socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
4749
+ session: SessionSocket;
4750
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string };
4230
4751
  toolActivity?: ToolActivityOwner;
4231
4752
  }
4232
4753
 
@@ -4246,6 +4767,37 @@ interface BotApiCallResult {
4246
4767
  response: unknown;
4247
4768
  outcome: BotApiCallOutcome;
4248
4769
  }
4770
+ function createBotApiAdapter(
4771
+ call: (
4772
+ method: string,
4773
+ body: unknown,
4774
+ callOpts?: { signal?: AbortSignal; noRetry?: boolean },
4775
+ ) => Promise<BotApiCallResult>,
4776
+ ): BotApi {
4777
+ return {
4778
+ call: async (method, body, callOpts) => (await call(method, body, callOpts)).response,
4779
+ };
4780
+ }
4781
+ function createBotApiPipeline(
4782
+ rawBotApi: BotApi,
4783
+ callBotApi: (
4784
+ rawBotApi: BotApi,
4785
+ method: string,
4786
+ body: unknown,
4787
+ callOpts?: { signal?: AbortSignal; noRetry?: boolean },
4788
+ ) => Promise<BotApiCallResult>,
4789
+ ): {
4790
+ classified: (
4791
+ method: string,
4792
+ body: unknown,
4793
+ callOpts?: { signal?: AbortSignal; noRetry?: boolean },
4794
+ ) => Promise<BotApiCallResult>;
4795
+ api: BotApi;
4796
+ } {
4797
+ const classified = (method: string, body: unknown, callOpts?: { signal?: AbortSignal; noRetry?: boolean }) =>
4798
+ callBotApi(rawBotApi, method, body, callOpts);
4799
+ return { classified, api: createBotApiAdapter(classified) };
4800
+ }
4249
4801
 
4250
4802
  function classifyBotApiCallOutcome(response: unknown, cooldownSuppressed = false): BotApiCallOutcome {
4251
4803
  if (cooldownSuppressed) return { kind: "retryable", retryAfterMs: 0 };
@@ -4329,6 +4881,7 @@ interface PendingBtwDelivery {
4329
4881
 
4330
4882
  class TelegramEffectSupervisor {
4331
4883
  #stopping = false;
4884
+ #admitting = true;
4332
4885
  readonly #abort = new AbortController();
4333
4886
  readonly #terminalContext = new AsyncLocalStorage<boolean>();
4334
4887
  readonly #pending = new Set<Promise<unknown>>();
@@ -4350,6 +4903,16 @@ class TelegramEffectSupervisor {
4350
4903
  return this.track(api.call(method, body, { ...opts, signal }));
4351
4904
  }
4352
4905
 
4906
+ admit<T>(effect: () => Promise<T>): Promise<T> {
4907
+ if (!this.#admitting)
4908
+ return Promise.reject(Object.assign(new Error("Daemon is stopping"), { name: "AbortError" }));
4909
+ try {
4910
+ return this.track(effect());
4911
+ } catch (error) {
4912
+ return Promise.reject(error);
4913
+ }
4914
+ }
4915
+
4353
4916
  track<T>(effect: Promise<T>): Promise<T> {
4354
4917
  this.#pending.add(effect);
4355
4918
  void effect.then(
@@ -4363,7 +4926,12 @@ class TelegramEffectSupervisor {
4363
4926
  return this.#stopping;
4364
4927
  }
4365
4928
 
4929
+ closeAdmission(): void {
4930
+ this.#admitting = false;
4931
+ }
4932
+
4366
4933
  beginShutdown(): void {
4934
+ this.closeAdmission();
4367
4935
  this.#stopping = true;
4368
4936
  this.#abort.abort("daemon_shutdown");
4369
4937
  }
@@ -4388,7 +4956,18 @@ class TelegramEffectSupervisor {
4388
4956
 
4389
4957
  export class TelegramNotificationDaemon {
4390
4958
  readonly aliasTable: AliasTable;
4959
+ /** Callback aliases are routable only by the immutable socket lease that rendered them. */
4960
+ #callbackAliasLeases = new Map<string, { session: SessionSocket; token: number; logicalSessionId: string }>();
4961
+ /** Each active alias is bound to the exact pending ask object that rendered it. */
4962
+ #callbackAliasPending = new Map<string, { sessionId: string; actionId: string }>();
4963
+ /** Active callbacks preserve the exact topic authority under which their keyboard was rendered. */
4964
+ #callbackAliasTopics = new Map<string, TopicAuthorityLease>();
4965
+ /** Exposed aliases wait for their accepted receipt persistence before routing. */
4966
+ #stagedCallbackActivations = new Map<string, StagedCallbackActivation>();
4391
4967
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
4968
+ /** Restart-revoked aliases retained only to terminalize their old keyboards on an authoritative replay. */
4969
+ private readonly reissueBacklog = new Map<string, CallbackRoute>();
4970
+ private aliasPersistenceQueue: Promise<void> = Promise.resolve();
4392
4971
  /** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
4393
4972
  private readonly liveMessages = new Map<string, number>();
4394
4973
  /** Endpoint-bound ownership for visible or dispatching tool bubbles. */
@@ -4432,6 +5011,8 @@ export class TelegramNotificationDaemon {
4432
5011
  private readonly botApi: BotApi;
4433
5012
  private readonly effects = new TelegramEffectSupervisor();
4434
5013
  private readonly topics = new TopicRegistry();
5014
+ /** Stable host-local identity; never persisted in shared topic authority. */
5015
+ private installationHostId: string;
4435
5016
  /**
4436
5017
  * Telegram may accept createForumTopic while returning an unusable success
4437
5018
  * payload. Remember that ambiguity per endpoint so later frames cannot repeat
@@ -4446,6 +5027,11 @@ export class TelegramNotificationDaemon {
4446
5027
  private recoveryBindingClaimQueue: Promise<void> = Promise.resolve();
4447
5028
  /** Durable compensation fences retry under supervision until persistence succeeds. */
4448
5029
  private readonly compensationFenceRetries = new Map<string, Promise<void>>();
5030
+ /** All archive paths for one session share one durable fence and remote dispatch. */
5031
+ private readonly archiveFlights = new Map<
5032
+ string,
5033
+ Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled">
5034
+ >();
4449
5035
 
4450
5036
  /** Daemon edit attempts that can race an accepted user service message. */
4451
5037
  private readonly daemonRenameAttempts = new Map<string, number>();
@@ -4486,8 +5072,10 @@ export class TelegramNotificationDaemon {
4486
5072
  private threadedFallbackNoticeSent = false;
4487
5073
  /** Sessions whose identity header was already sent flat (Threaded Mode off). */
4488
5074
  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;
5075
+ /** Cached delivery boundary for the private owner chat or validation forum. */
5076
+ private pairedChatPrivacy: PairedChatPrivacy | undefined;
5077
+ /** Latched once Telegram confirms this chat cannot host forum topics. */
5078
+ private topicCapabilityRefused = false;
4491
5079
  /** Bot username from getMe, cached once at owner startup for group/forum command targeting. */
4492
5080
  private botUsername: string | undefined;
4493
5081
  /** Sessions whose agent loop is currently busy (drives the typing indicator). */
@@ -4555,6 +5143,7 @@ export class TelegramNotificationDaemon {
4555
5143
  */
4556
5144
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
4557
5145
  this.stopRequested = true;
5146
+ this.effects.closeAdmission();
4558
5147
 
4559
5148
  const toolShutdown = this.beginToolActivityShutdown();
4560
5149
  void toolShutdown
@@ -4919,7 +5508,8 @@ export class TelegramNotificationDaemon {
4919
5508
  const outcome = classifyBotApiCallOutcome(undefined, true);
4920
5509
  return { response: undefined, outcome };
4921
5510
  }
4922
- const response = await this.effects.call(rawBotApi, method, body, callOpts);
5511
+ const outboundBody = this.validationTopicDestination(method, body);
5512
+ const response = await this.effects.call(rawBotApi, method, outboundBody, callOpts);
4923
5513
  const outcome = classifyBotApiCallOutcome(response);
4924
5514
  if (outcome.kind === "retryable")
4925
5515
  this.botCooldownUntil = Math.max(
@@ -4928,6 +5518,20 @@ export class TelegramNotificationDaemon {
4928
5518
  );
4929
5519
  return { response, outcome };
4930
5520
  }
5521
+ private validationTopicDestination(method: string, body: unknown): unknown {
5522
+ if (
5523
+ !this.validationMode() ||
5524
+ method === "getChat" ||
5525
+ !body ||
5526
+ typeof body !== "object" ||
5527
+ String((body as { chat_id?: unknown }).chat_id) !== String(this.opts.chatId)
5528
+ )
5529
+ return body;
5530
+ return {
5531
+ ...(body as Record<string, unknown>),
5532
+ chat_id: this.opts.validationTestSupergroupChatId!,
5533
+ };
5534
+ }
4931
5535
 
4932
5536
  private readonly callBotApiClassified: (
4933
5537
  method: string,
@@ -4948,6 +5552,9 @@ export class TelegramNotificationDaemon {
4948
5552
  constructor(private readonly opts: TelegramDaemonOptions) {
4949
5553
  this.fsImpl = opts.fs ?? nodeFs;
4950
5554
  this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
5555
+ if (opts.topicRegistryAuthority && !opts.installationHostId)
5556
+ throw new Error("shared topic authority requires a host-local installation identity");
5557
+ this.installationHostId = opts.installationHostId ?? crypto.randomUUID();
4951
5558
  this.aliasTable = createAliasTable();
4952
5559
  this.#adoptionIntentTtlMs = opts.adoptionIntentTtlMs ?? DEFAULT_ADOPTION_INTENT_TTL_MS;
4953
5560
  const adoptionFs: AdoptionIntentFs = {
@@ -4981,10 +5588,9 @@ export class TelegramNotificationDaemon {
4981
5588
  fetchImpl: opts.fetchImpl,
4982
5589
  setTimeoutImpl: opts.setTimeoutImpl,
4983
5590
  });
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
- };
5591
+ const botApiPipeline = createBotApiPipeline(rawBotApi, this.callBotApi.bind(this));
5592
+ this.callBotApiClassified = botApiPipeline.classified;
5593
+ this.botApi = botApiPipeline.api;
4988
5594
  this.runtime = new NotificationOperatorRuntime({
4989
5595
  now: opts.now,
4990
5596
  setTimeoutImpl: opts.setTimeoutImpl,
@@ -5058,7 +5664,6 @@ export class TelegramNotificationDaemon {
5058
5664
  if (!socketLease) return;
5059
5665
  const topicLease = this.topicAuthorityLeaseFromRegistry(logicalSessionId);
5060
5666
  if (
5061
- this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
5062
5667
  this.topics.get(logicalSessionId)?.bindingMalformed ||
5063
5668
  (mode === "recovery" && (!topicLease || msg.sessionId !== logicalSessionId))
5064
5669
  ) {
@@ -5217,40 +5822,49 @@ export class TelegramNotificationDaemon {
5217
5822
  if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5218
5823
 
5219
5824
  const closedBinding = this.#endpointBinding(session);
5220
- const closeTopicAuthority = this.topics.captureDeleteAuthority(logicalSessionId);
5825
+ const closedBindingMatches = () => {
5826
+ const current = this.closedEndpointKeys.get(session.sessionId);
5827
+ return (
5828
+ current?.chatId === closedBinding.chatId &&
5829
+ current.endpointKey === closedBinding.endpointKey &&
5830
+ current.endpointDigest === closedBinding.endpointDigest &&
5831
+ current.endpointGeneration === closedBinding.endpointGeneration
5832
+ );
5833
+ };
5834
+ const closeTopicAuthority = this.topics.captureArchiveAuthority(logicalSessionId);
5221
5835
  const previousClosedBinding = this.closedEndpointKeys.get(session.sessionId);
5222
5836
  await this.#persistTopicMutation(
5223
5837
  () => {
5224
5838
  this.closedEndpointKeys.set(session.sessionId, closedBinding);
5225
- this.topics.beginDelete(logicalSessionId);
5839
+ this.topics.beginArchive(logicalSessionId, this.installationHostId, this.runtime.now());
5226
5840
  },
5227
5841
  () => {
5228
- this.topics.restoreDeleteAuthority(closeTopicAuthority);
5842
+ this.topics.restoreArchiveAuthority(closeTopicAuthority);
5229
5843
  if (previousClosedBinding === undefined) this.closedEndpointKeys.delete(session.sessionId);
5230
- else if (this.closedEndpointKeys.get(session.sessionId) === closedBinding)
5844
+ else if (closedBindingMatches())
5231
5845
  this.closedEndpointKeys.set(session.sessionId, previousClosedBinding);
5232
5846
  },
5233
5847
  );
5234
5848
  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)
5849
+ this.sessions.get(session.sessionId) !== session ||
5850
+ (socketLease &&
5851
+ (session.recoveryLease?.token !== socketLease.token ||
5852
+ session.recoveryLease.state !== "authorized" ||
5853
+ this.logicalSessionOwners.get(logicalSessionId) !== session))
5240
5854
  ) {
5241
5855
  // A replacement won after the close fence committed. Restore the exact
5242
5856
  // pre-close authority and remove the predecessor tombstone together.
5243
5857
  const restoreCloseAuthority = (): Promise<boolean> =>
5244
5858
  this.#persistTopicMutation(
5245
5859
  () => {
5246
- const restored = this.topics.restoreDeleteAuthority(closeTopicAuthority);
5247
- if (!restored) throw new Error("close authority changed before compensation");
5248
- this.closedEndpointKeys.delete(session.sessionId);
5860
+ this.topics.restoreArchiveAuthority(closeTopicAuthority);
5861
+ if (closedBindingMatches()) this.closedEndpointKeys.delete(session.sessionId);
5249
5862
  return true;
5250
5863
  },
5251
5864
  () => {
5252
- this.topics.restoreDeleteFence(closeTopicAuthority);
5253
- this.closedEndpointKeys.set(session.sessionId, closedBinding);
5865
+ this.topics.restoreArchiveFence(closeTopicAuthority);
5866
+ if (this.sessions.get(session.sessionId) === session)
5867
+ this.closedEndpointKeys.set(session.sessionId, closedBinding);
5254
5868
  },
5255
5869
  );
5256
5870
  try {
@@ -5260,7 +5874,7 @@ export class TelegramNotificationDaemon {
5260
5874
  }
5261
5875
  return;
5262
5876
  }
5263
- const deleteOutcome = await this.deleteTopic(logicalSessionId, socketLease, true);
5877
+ const deleteOutcome = await this.archiveTopic(logicalSessionId, socketLease, true);
5264
5878
  if (
5265
5879
  deleteOutcome === "pre_dispatch_cancelled" &&
5266
5880
  socketLease &&
@@ -5268,12 +5882,12 @@ export class TelegramNotificationDaemon {
5268
5882
  ) {
5269
5883
  await this.#persistTopicMutation(
5270
5884
  () => {
5271
- if (!this.topics.restoreDeleteAuthority(closeTopicAuthority))
5885
+ if (!this.topics.restoreArchiveAuthority(closeTopicAuthority))
5272
5886
  throw new Error("close authority changed before compensation");
5273
5887
  this.closedEndpointKeys.delete(session.sessionId);
5274
5888
  },
5275
5889
  () => {
5276
- this.topics.restoreDeleteFence(closeTopicAuthority);
5890
+ this.topics.restoreArchiveFence(closeTopicAuthority);
5277
5891
  this.closedEndpointKeys.set(session.sessionId, closedBinding);
5278
5892
  },
5279
5893
  );
@@ -5286,17 +5900,40 @@ export class TelegramNotificationDaemon {
5286
5900
  }
5287
5901
 
5288
5902
  async loadAliases(): Promise<void> {
5903
+ if (this.validationMode()) return;
5289
5904
  const raw = await readJson<unknown>(this.fsImpl, daemonPaths(this.opts.settings.getAgentDir()).aliases);
5290
- if (raw) this.aliasTable.load(raw);
5905
+ if (!raw) return;
5906
+ const persisted = raw as { revokedRoutes?: unknown };
5907
+ const revoked = createAliasTable();
5908
+ revoked.load({ version: 2, next: 1, routes: persisted.revokedRoutes });
5909
+ for (const [alias, route] of revoked.entries()) this.reissueBacklog.set(alias, route);
5910
+ this.aliasTable.load(raw);
5911
+ for (const [alias, route] of this.aliasTable.entries()) this.reissueBacklog.set(alias, route);
5912
+ this.aliasTable.clear();
5913
+ await this.persistAliases();
5291
5914
  }
5292
5915
 
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());
5916
+ persistAliases(): Promise<void> {
5917
+ if (this.validationMode()) return Promise.resolve();
5918
+ const pending = this.aliasPersistenceQueue.then(async () => {
5919
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
5920
+ await ensureDir(this.fsImpl, paths.dir);
5921
+ await writeJsonAtomic(
5922
+ this.fsImpl,
5923
+ paths.aliases,
5924
+ {
5925
+ ...this.aliasTable.serialize(),
5926
+ revokedRoutes: Object.fromEntries(this.reissueBacklog),
5927
+ },
5928
+ { durable: true },
5929
+ );
5930
+ });
5931
+ this.aliasPersistenceQueue = pending.catch(() => undefined);
5932
+ return pending;
5297
5933
  }
5298
5934
 
5299
5935
  async loadSeenUpdateIds(): Promise<void> {
5936
+ if (this.validationMode()) return;
5300
5937
  const raw = await readJson<{ updateIds?: unknown }>(
5301
5938
  this.fsImpl,
5302
5939
  daemonPaths(this.opts.settings.getAgentDir()).seenUpdates,
@@ -5312,6 +5949,7 @@ export class TelegramNotificationDaemon {
5312
5949
  }
5313
5950
 
5314
5951
  async persistSeenUpdateIds(): Promise<void> {
5952
+ if (this.validationMode()) return;
5315
5953
  const paths = daemonPaths(this.opts.settings.getAgentDir());
5316
5954
  await ensureDir(this.fsImpl, paths.dir);
5317
5955
  await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
@@ -5341,6 +5979,12 @@ export class TelegramNotificationDaemon {
5341
5979
  }
5342
5980
  }
5343
5981
  private async reserveSeenUpdateId(updateId: number): Promise<boolean> {
5982
+ if (this.validationMode()) {
5983
+ if (this.dispatchState.seenUpdateIds.has(updateId)) return false;
5984
+ this.dispatchState.seenUpdateIds.add(updateId);
5985
+ this.pruneSeenUpdateIds();
5986
+ return true;
5987
+ }
5344
5988
  if (!Number.isSafeInteger(updateId) || updateId < 0) return false;
5345
5989
  const candidate = new Set(this.dispatchState.seenUpdateIds);
5346
5990
  candidate.add(updateId);
@@ -5362,6 +6006,10 @@ export class TelegramNotificationDaemon {
5362
6006
  }
5363
6007
 
5364
6008
  private async releaseSeenUpdateId(updateId: number): Promise<void> {
6009
+ if (this.validationMode()) {
6010
+ this.dispatchState.seenUpdateIds.delete(updateId);
6011
+ return;
6012
+ }
5365
6013
  if (!this.dispatchState.seenUpdateIds.has(updateId)) return;
5366
6014
  const candidate = new Set(this.dispatchState.seenUpdateIds);
5367
6015
  candidate.delete(updateId);
@@ -5447,28 +6095,31 @@ export class TelegramNotificationDaemon {
5447
6095
  } catch {}
5448
6096
  }
5449
6097
  }
5450
- if (permanentlyMissingRoots.length > 0) {
6098
+ if (!this.validationMode()) {
6099
+ if (permanentlyMissingRoots.length > 0) {
6100
+ try {
6101
+ await pruneMissingNotificationRoots({
6102
+ settings: this.opts.settings,
6103
+ fs: this.fsImpl,
6104
+ candidates: permanentlyMissingRoots,
6105
+ });
6106
+ } catch (error) {
6107
+ logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`);
6108
+ }
6109
+ }
6110
+ // Best-effort periodic reap of retained exact-unlink quarantines (#2956).
5451
6111
  try {
5452
- await pruneMissingNotificationRoots({
6112
+ await reapStaleNotificationArtifacts({
5453
6113
  settings: this.opts.settings,
5454
6114
  fs: this.fsImpl,
5455
- candidates: permanentlyMissingRoots,
6115
+ now: this.opts.now,
6116
+ pidAlive: this.opts.pidAlive,
5456
6117
  });
5457
6118
  } catch (error) {
5458
- logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`);
6119
+ logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
5459
6120
  }
5460
6121
  }
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
- }
6122
+
5472
6123
  if (allRootsReadable) {
5473
6124
  for (const sessionId of this.topics.sessionIds()) {
5474
6125
  const owner = this.logicalSessionOwners.get(sessionId);
@@ -5521,7 +6172,7 @@ export class TelegramNotificationDaemon {
5521
6172
  replayQueue: [],
5522
6173
  };
5523
6174
  this.sessions.set(sessionId, session);
5524
- if (this.topics.get(sessionId)) this.preservedInitiatorTopics.add(sessionId);
6175
+ if (this.topics.get(sessionId)?.authorityState === "active") this.preservedInitiatorTopics.add(sessionId);
5525
6176
 
5526
6177
  // Bidirectional capability advertisement: announce client_ping_pong once the
5527
6178
  // socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
@@ -5566,7 +6217,9 @@ export class TelegramNotificationDaemon {
5566
6217
  void (async () => {
5567
6218
  if (this.#logicalSessionId(session) !== sessionId) return;
5568
6219
  const topic = this.topics.get(sessionId);
5569
- if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
6220
+ if (!topic || topic.bindingMalformed) return;
6221
+ if (topic.authorityState === "disconnect_grace" && !(await this.#renewTopicLease(sessionId))) return;
6222
+ if (topic.authorityState !== "active") return;
5570
6223
  const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
5571
6224
  if (topicLease?.topicId === topic.topicId) await this.flushPendingThreadedFrames(sessionId, topicLease);
5572
6225
  })().catch(err =>
@@ -5579,11 +6232,13 @@ export class TelegramNotificationDaemon {
5579
6232
  // Identity guard: a delayed frame from a superseded socket must not act
5580
6233
  // through the replacement session.
5581
6234
  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
- });
6235
+ void this.effects
6236
+ .admit(() => this.handleSessionMessage(session, JSON.parse(String(ev.data))))
6237
+ .catch(err => {
6238
+ // Surface frame-handling failures (e.g. a rejected ask sendMessage) to
6239
+ // the daemon log instead of an invisible unhandled rejection.
6240
+ logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
6241
+ });
5587
6242
  });
5588
6243
  ws.addEventListener("close", () => {
5589
6244
  this.dropSession(session, "socket_closed");
@@ -5610,6 +6265,11 @@ export class TelegramNotificationDaemon {
5610
6265
  this.dropSession(session, "liveness_timeout");
5611
6266
  return;
5612
6267
  }
6268
+ const logicalSessionId = this.#logicalSessionId(session);
6269
+ if (session.logicalSessionIdTrusted)
6270
+ void this.#renewTopicLease(logicalSessionId).then(renewed => {
6271
+ if (!renewed) this.dropSession(session, "topic_lease_lost");
6272
+ });
5613
6273
  if (session.ws.readyState === WebSocket.OPEN) {
5614
6274
  const nonce = `${session.sessionId}:${t}:${Math.random().toString(36).slice(2)}`;
5615
6275
  session.awaitingNonce = nonce;
@@ -5753,12 +6413,24 @@ export class TelegramNotificationDaemon {
5753
6413
  }
5754
6414
 
5755
6415
  private dropSession(session: SessionSocket, reason: string): void {
5756
- // A dropped socket must lose authority before any teardown-triggered asynchronous
6416
+ // Capture the exact callback lease before revoking recovery authority. A
6417
+ // predecessor transport can remain keyed by its own session id after a
6418
+ // successor becomes the logical owner, so logical-session revocation
6419
+ // would incorrectly delete the successor's freshly accepted aliases.
6420
+ const callbackLease = {
6421
+ session,
6422
+ token: session.recoveryLease?.token ?? 0,
6423
+ logicalSessionId: this.#logicalSessionId(session),
6424
+ };
6425
+ // A dropped socket must lose authority before any teardown-triggered asynchronous
5757
6426
  // work can observe it. Its immutable lease token remains captured by queued work,
5758
6427
  // but is no longer usable.
5759
6428
  if (session.recoveryLease) session.recoveryLease = { ...session.recoveryLease, state: "rejected" };
5760
6429
  const isCurrentSession = this.sessions.get(session.sessionId) === session;
5761
- if (isCurrentSession) this.droppedSessions.add(session);
6430
+ if (isCurrentSession) {
6431
+ this.revokeCallbackAliases(callbackLease);
6432
+ this.droppedSessions.add(session);
6433
+ }
5762
6434
  if (isCurrentSession) this.cancelLegacyToolStartsForSession(session);
5763
6435
  if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
5764
6436
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
@@ -5771,6 +6443,7 @@ export class TelegramNotificationDaemon {
5771
6443
  } else {
5772
6444
  void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
5773
6445
  }
6446
+ if (isCurrentSession && session.logicalSessionIdTrusted) this.#releaseTopicLease(this.#logicalSessionId(session));
5774
6447
  if (isCurrentSession || reason === "session_closed") {
5775
6448
  this.deleteMessageRoutes(session.sessionId);
5776
6449
  }
@@ -6094,6 +6767,82 @@ export class TelegramNotificationDaemon {
6094
6767
  }
6095
6768
  }
6096
6769
  }
6770
+ #revokeCallbackAlias(alias: string): CallbackRoute | undefined {
6771
+ const route = this.aliasTable.get(alias);
6772
+ if (!route) return undefined;
6773
+ this.reissueBacklog.set(alias, route);
6774
+ this.aliasTable.delete(alias);
6775
+ this.#callbackAliasLeases.delete(alias);
6776
+ this.#callbackAliasPending.delete(alias);
6777
+ this.#callbackAliasTopics.delete(alias);
6778
+ return route;
6779
+ }
6780
+ #stageCallbackActivation(
6781
+ aliases: Iterable<string>,
6782
+ pending: { sessionId: string; actionId: string },
6783
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string },
6784
+ topicLease: TopicAuthorityLease | undefined,
6785
+ ): PromiseWithResolvers<boolean> {
6786
+ const activation = Promise.withResolvers<boolean>();
6787
+ const staged: StagedCallbackActivation = { pending, socketLease, topicLease, settled: activation.promise };
6788
+ for (const alias of aliases) this.#stagedCallbackActivations.set(alias, staged);
6789
+ return activation;
6790
+ }
6791
+
6792
+ revokeCallbackAliases(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): void {
6793
+ const actions = new Set<string>();
6794
+ for (const [alias, aliasLease] of this.#callbackAliasLeases) {
6795
+ if (
6796
+ aliasLease.session === socketLease.session &&
6797
+ aliasLease.token === socketLease.token &&
6798
+ aliasLease.logicalSessionId === socketLease.logicalSessionId
6799
+ ) {
6800
+ const route = this.#revokeCallbackAlias(alias);
6801
+ if (route) actions.add(route.actionId);
6802
+ }
6803
+ }
6804
+ if (actions.size === 0) return;
6805
+ const terminalization = this.effects.track(
6806
+ (async () => {
6807
+ await this.persistAliases();
6808
+ for (const actionId of actions) await this.reissuePendingAction(socketLease.logicalSessionId, actionId);
6809
+ })(),
6810
+ );
6811
+ void terminalization.catch(() => undefined);
6812
+ }
6813
+
6814
+ private async reissuePendingAction(sessionId: string, actionId: string): Promise<void> {
6815
+ const oldEntries = [...this.reissueBacklog.entries()].filter(
6816
+ ([, route]) => route.sessionId === sessionId && route.actionId === actionId,
6817
+ );
6818
+ const messageIds = new Set<number>();
6819
+ for (const [, route] of oldEntries) {
6820
+ if (String(route.chatId) === String(this.opts.chatId) && Number.isSafeInteger(route.messageId))
6821
+ messageIds.add(route.messageId!);
6822
+ }
6823
+ const terminalizedMessageIds = new Set<number>();
6824
+ for (const messageId of messageIds) {
6825
+ try {
6826
+ const terminalized = (await this.botApi.call("editMessageReplyMarkup", {
6827
+ chat_id: this.opts.chatId,
6828
+ message_id: messageId,
6829
+ reply_markup: { inline_keyboard: [] },
6830
+ })) as { ok?: unknown } | undefined;
6831
+ if (terminalized?.ok === true) terminalizedMessageIds.add(messageId);
6832
+ } catch {
6833
+ // Retain the durable receipt so a later replay can retry terminalization.
6834
+ }
6835
+ }
6836
+ for (const [alias, route] of oldEntries) {
6837
+ if (
6838
+ String(route.chatId) === String(this.opts.chatId) &&
6839
+ Number.isSafeInteger(route.messageId) &&
6840
+ terminalizedMessageIds.has(route.messageId!)
6841
+ )
6842
+ this.reissueBacklog.delete(alias);
6843
+ }
6844
+ await this.persistAliases();
6845
+ }
6097
6846
 
6098
6847
  #logicalSessionId(session: SessionSocket): string {
6099
6848
  return session.logicalSessionId ?? session.sessionId;
@@ -6123,8 +6872,10 @@ export class TelegramNotificationDaemon {
6123
6872
  lease.binding.endpointDigest === session.endpointDigest &&
6124
6873
  lease.binding.endpointGeneration === session.hostGeneration &&
6125
6874
  (!record ||
6126
- (record.authorityState !== "delete_pending" &&
6875
+ (record.authorityState === "active" &&
6127
6876
  !record.bindingMalformed &&
6877
+ record.leaseOwner === this.installationHostId &&
6878
+ (record.leaseExpiresAt ?? 0) > this.runtime.now() &&
6128
6879
  record.chatId === lease.binding.chatId &&
6129
6880
  record.endpointKey === lease.binding.endpointKey &&
6130
6881
  record.endpointDigest === lease.binding.endpointDigest &&
@@ -6208,14 +6959,86 @@ export class TelegramNotificationDaemon {
6208
6959
  ? undefined
6209
6960
  : { session, token: 0, logicalSessionId };
6210
6961
  }
6962
+ async #renewTopicLease(sessionId: string): Promise<boolean> {
6963
+ const record = this.topics.get(sessionId);
6964
+ if (!record) return true;
6965
+ const previous = {
6966
+ leaseOwner: record.leaseOwner,
6967
+ leaseHeartbeatAt: record.leaseHeartbeatAt,
6968
+ leaseExpiresAt: record.leaseExpiresAt,
6969
+ authorityState: record.authorityState,
6970
+ orphanedAt: record.orphanedAt,
6971
+ disconnectGraceExpiresAt: record.disconnectGraceExpiresAt,
6972
+ };
6973
+ return this.#persistTopicMutation(
6974
+ () =>
6975
+ this.topics.acquireLease(
6976
+ sessionId,
6977
+ this.installationHostId,
6978
+ this.runtime.now(),
6979
+ HEARTBEAT_TTL_MS,
6980
+ ORPHAN_TOPIC_GRACE_MS,
6981
+ ),
6982
+ () => {
6983
+ const current = this.topics.get(sessionId);
6984
+ if (!current) return;
6985
+ Object.assign(current, previous);
6986
+ if (previous.leaseOwner === undefined) delete current.leaseOwner;
6987
+ if (previous.leaseHeartbeatAt === undefined) delete current.leaseHeartbeatAt;
6988
+ if (previous.leaseExpiresAt === undefined) delete current.leaseExpiresAt;
6989
+ if (previous.orphanedAt === undefined) delete current.orphanedAt;
6990
+ if (previous.disconnectGraceExpiresAt === undefined) delete current.disconnectGraceExpiresAt;
6991
+ },
6992
+ );
6993
+ }
6994
+ #releaseTopicLease(sessionId: string): void {
6995
+ const record = this.topics.get(sessionId);
6996
+ if (!record) return;
6997
+ const previous = {
6998
+ leaseHeartbeatAt: record.leaseHeartbeatAt,
6999
+ leaseExpiresAt: record.leaseExpiresAt,
7000
+ authorityState: record.authorityState,
7001
+ orphanedAt: record.orphanedAt,
7002
+ disconnectGraceExpiresAt: record.disconnectGraceExpiresAt,
7003
+ };
7004
+ void this.#persistTopicMutation(
7005
+ () => {
7006
+ const owner = this.logicalSessionOwners.get(sessionId);
7007
+ return owner === undefined
7008
+ ? this.topics.releaseLeaseToGrace(
7009
+ sessionId,
7010
+ this.installationHostId,
7011
+ this.runtime.now(),
7012
+ ORPHAN_TOPIC_GRACE_MS,
7013
+ )
7014
+ : false;
7015
+ },
7016
+ () => {
7017
+ const current = this.topics.get(sessionId);
7018
+ if (!current) return;
7019
+ Object.assign(current, previous);
7020
+ if (previous.leaseHeartbeatAt === undefined) delete current.leaseHeartbeatAt;
7021
+ if (previous.leaseExpiresAt === undefined) delete current.leaseExpiresAt;
7022
+ if (previous.orphanedAt === undefined) delete current.orphanedAt;
7023
+ if (previous.disconnectGraceExpiresAt === undefined) delete current.disconnectGraceExpiresAt;
7024
+ },
7025
+ ).catch(() => undefined);
7026
+ }
6211
7027
 
6212
7028
  #authorizeLease(session: SessionSocket, logicalSessionId: string, binding: TopicEndpointBinding): void {
6213
7029
  const previousSessionId = this.#logicalSessionId(session);
6214
7030
  if (previousSessionId !== logicalSessionId && this.logicalSessionOwners.get(previousSessionId) === session)
6215
7031
  this.logicalSessionOwners.delete(previousSessionId);
6216
7032
  const previousOwner = this.logicalSessionOwners.get(logicalSessionId);
6217
- if (previousOwner && previousOwner !== session && previousOwner.recoveryLease)
7033
+ if (previousOwner && previousOwner !== session && previousOwner.recoveryLease) {
7034
+ const predecessorLease = {
7035
+ session: previousOwner,
7036
+ token: previousOwner.recoveryLease.token,
7037
+ logicalSessionId,
7038
+ };
6218
7039
  previousOwner.recoveryLease = { ...previousOwner.recoveryLease, state: "rejected" };
7040
+ this.revokeCallbackAliases(predecessorLease);
7041
+ }
6219
7042
  this.logicalSessionOwners.set(logicalSessionId, session);
6220
7043
  this.preservedInitiatorTopics.delete(logicalSessionId);
6221
7044
  session.logicalSessionId = logicalSessionId;
@@ -6229,8 +7052,18 @@ export class TelegramNotificationDaemon {
6229
7052
  }
6230
7053
 
6231
7054
  async #revokeAskAuthority(sessionId: string): Promise<void> {
7055
+ const actionIds = new Set<string>();
6232
7056
  for (const [alias, route] of this.aliasTable.entries()) {
6233
- if (route.sessionId === sessionId) this.aliasTable.delete(alias);
7057
+ if (route.sessionId !== sessionId) continue;
7058
+ this.reissueBacklog.set(alias, route);
7059
+ this.aliasTable.delete(alias);
7060
+ this.#callbackAliasLeases.delete(alias);
7061
+ this.#callbackAliasPending.delete(alias);
7062
+ this.#callbackAliasTopics.delete(alias);
7063
+ actionIds.add(route.actionId);
7064
+ }
7065
+ for (const route of this.reissueBacklog.values()) {
7066
+ if (route.sessionId === sessionId) actionIds.add(route.actionId);
6234
7067
  }
6235
7068
  for (const session of this.sessions.values()) {
6236
7069
  if (session.sessionId === sessionId || this.#logicalSessionId(session) === sessionId) {
@@ -6240,6 +7073,7 @@ export class TelegramNotificationDaemon {
6240
7073
  }
6241
7074
  }
6242
7075
  await this.persistAliases();
7076
+ for (const actionId of actionIds) await this.reissuePendingAction(sessionId, actionId);
6243
7077
  }
6244
7078
 
6245
7079
  #clearModelChoiceAliasesForSocket(session: SessionSocket): void {
@@ -6378,84 +7212,123 @@ export class TelegramNotificationDaemon {
6378
7212
  const binding = this.#endpointBinding(session);
6379
7213
  const pendingToken = this.nextSocketLeaseToken++;
6380
7214
  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" &&
7215
+ let claim: { previousSessionId: string; hadDurableTopic: boolean } | undefined;
7216
+ try {
7217
+ claim = await this.#withRecoveryBindingClaim(async () => {
7218
+ const existing = this.topics.get(candidateSessionId);
7219
+ const hadDurableTopic = existing !== undefined;
7220
+ const previousBinding = existing
7221
+ ? {
7222
+ chatId: existing.chatId,
7223
+ endpointKey: existing.endpointKey,
7224
+ endpointDigest: existing.endpointDigest,
7225
+ endpointGeneration: existing.endpointGeneration,
7226
+ endpointIncarnation: existing.endpointIncarnation,
7227
+ }
7228
+ : undefined;
7229
+ let retiredTopicState: TopicRegistryState | undefined;
7230
+ const outcome = await this.#persistTopicMutation(
7231
+ () => {
7232
+ if (allowEndpointRotation && this.topics.get(candidateSessionId)?.authorityState === "inactive") {
7233
+ retiredTopicState = this.topics.serialize();
7234
+ this.topics.retireInactiveEndpointForSuccessor(candidateSessionId, binding);
7235
+ }
7236
+ return this.topics.get(candidateSessionId)
7237
+ ? this.topics.bindEndpoint(
7238
+ candidateSessionId,
7239
+ binding,
7240
+ this.#activeEndpointKeysFor(candidateSessionId, session),
7241
+ allowEndpointRotation,
7242
+ )
7243
+ : "unchanged";
7244
+ },
7245
+ () => {
7246
+ if (retiredTopicState) this.topics.replace(retiredTopicState);
7247
+ else if (previousBinding)
7248
+ this.topics.restoreEndpointBinding(candidateSessionId, binding, previousBinding);
7249
+ },
7250
+ ).catch(() => "rejected" as const);
7251
+ if (outcome === "rejected") {
7252
+ if (session.recoveryLease?.token === pendingToken)
7253
+ session.recoveryLease = {
7254
+ state: "rejected",
7255
+ logicalSessionId: candidateSessionId,
7256
+ binding,
7257
+ token: pendingToken,
7258
+ };
7259
+ return undefined;
7260
+ }
7261
+ if (!(await this.#renewTopicLease(candidateSessionId))) {
7262
+ if (session.recoveryLease?.token === pendingToken)
7263
+ session.recoveryLease = {
7264
+ state: "rejected",
7265
+ logicalSessionId: candidateSessionId,
7266
+ binding,
7267
+ token: pendingToken,
7268
+ };
7269
+ return undefined;
7270
+ }
7271
+ const endpointAuthority = this.#endpointAuthority(binding, session);
7272
+ const identitylessAdmissionAllows =
7273
+ identitylessAdmission === undefined ||
7274
+ (identitylessAdmission === "bootstrap"
7275
+ ? (endpointAuthority.state === "none" && !this.topics.get(candidateSessionId)) ||
7276
+ (endpointAuthority.state === "unique" &&
7277
+ endpointAuthority.sessionId === candidateSessionId &&
7278
+ this.topics.matchesEndpoint(candidateSessionId, binding))
7279
+ : endpointAuthority.state === "unique" &&
6423
7280
  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;
7281
+ this.topics.matchesEndpoint(candidateSessionId, binding));
7282
+ if (
7283
+ session.recoveryLease?.token !== pendingToken ||
7284
+ session.recoveryLease.state !== "pending" ||
7285
+ !this.#ownsLiveOpenEndpoint(session, binding) ||
7286
+ !identitylessAdmissionAllows
7287
+ ) {
7288
+ if (session.recoveryLease?.token === pendingToken)
7289
+ session.recoveryLease = {
7290
+ state: "rejected",
7291
+ logicalSessionId: candidateSessionId,
7292
+ binding,
7293
+ token: pendingToken,
7294
+ };
7295
+ return undefined;
7296
+ }
7297
+ const previousSessionId = this.#logicalSessionId(session);
7298
+ if (previousSessionId !== candidateSessionId) await this.#terminalizeBtwTurnsForSession(session, true);
7299
+ if (!this.#ownsLiveOpenEndpoint(session, binding)) {
7300
+ if (session.recoveryLease?.token === pendingToken)
7301
+ session.recoveryLease = {
7302
+ state: "rejected",
7303
+ logicalSessionId: candidateSessionId,
7304
+ binding,
7305
+ token: pendingToken,
7306
+ };
7307
+ return undefined;
7308
+ }
7309
+ this.#authorizeLease(session, candidateSessionId, binding);
7310
+ return { previousSessionId, hadDurableTopic };
7311
+ });
7312
+ } catch {
7313
+ if (session.recoveryLease?.token === pendingToken)
7314
+ session.recoveryLease = {
7315
+ state: "rejected",
7316
+ logicalSessionId: candidateSessionId,
7317
+ binding,
7318
+ token: pendingToken,
7319
+ };
7320
+ return false;
7321
+ }
7322
+ if (!claim) {
7323
+ if (session.recoveryLease?.token === pendingToken)
7324
+ session.recoveryLease = {
7325
+ state: "rejected",
7326
+ logicalSessionId: candidateSessionId,
7327
+ binding,
7328
+ token: pendingToken,
7329
+ };
7330
+ return false;
7331
+ }
6459
7332
  const { previousSessionId } = claim;
6460
7333
  if (preserveTransportTopic && previousSessionId !== candidateSessionId) {
6461
7334
  this.legacyTopicOwners.set(previousSessionId, session);
@@ -6464,7 +7337,7 @@ export class TelegramNotificationDaemon {
6464
7337
  if (candidateSessionId === session.sessionId)
6465
7338
  void (async () => {
6466
7339
  const topic = this.topics.get(candidateSessionId);
6467
- if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
7340
+ if (topic?.authorityState !== "active" || topic.bindingMalformed) return;
6468
7341
  const topicLease = this.topicAuthorityLeaseFromRegistry(candidateSessionId);
6469
7342
  if (topicLease?.topicId === topic.topicId)
6470
7343
  await this.flushPendingThreadedFrames(candidateSessionId, topicLease);
@@ -6539,12 +7412,12 @@ export class TelegramNotificationDaemon {
6539
7412
  const identityKey = this.topicIdentityKey(msg);
6540
7413
  const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
6541
7414
  const rememberedTopic = remembered ? this.topics.get(remembered) : undefined;
6542
- if (remembered && rememberedTopic && rememberedTopic.authorityState !== "delete_pending") return remembered;
7415
+ if (remembered && rememberedTopic && rememberedTopic.authorityState === "active") return remembered;
6543
7416
  if (!identityKey) return undefined;
6544
7417
  const base = this.topicIdentityBase(msg);
6545
7418
  for (const sessionId of this.topics.sessionIds()) {
6546
7419
  const topic = this.topics.get(sessionId);
6547
- if (topic?.authorityState === "delete_pending") continue;
7420
+ if (topic?.authorityState !== "active") continue;
6548
7421
  const nameMatchesLegacyIdentity =
6549
7422
  base !== undefined && (topic?.name === base || topic?.name?.startsWith(`${base} - `));
6550
7423
  if (topic?.identityKey === identityKey || nameMatchesLegacyIdentity) {
@@ -6823,21 +7696,29 @@ export class TelegramNotificationDaemon {
6823
7696
  }
6824
7697
 
6825
7698
  private async topicAuthorityLease(sessionId: string): Promise<TopicAuthorityLease | undefined> {
6826
- if (!(await this.pairedChatIsPrivate())) return undefined;
7699
+ if (!(await this.pairedChatAllowsTopics())) return undefined;
6827
7700
  return this.topicAuthorityLeaseFromRegistry(sessionId);
6828
7701
  }
6829
7702
 
6830
7703
  private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
6831
7704
  const topic = this.topics.get(sessionId);
6832
- if (!topic || !this.topics.isActiveUnambiguous(sessionId) || topic.bindingMalformed) return undefined;
7705
+ if (
7706
+ !topic ||
7707
+ !this.topics.isActiveUnambiguous(sessionId) ||
7708
+ topic.bindingMalformed ||
7709
+ topic.leaseOwner !== this.installationHostId ||
7710
+ (topic.leaseExpiresAt ?? 0) <= this.runtime.now()
7711
+ )
7712
+ return undefined;
6833
7713
  return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
6834
7714
  }
6835
-
6836
7715
  private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
6837
7716
  const topic = this.topics.get(lease.sessionId);
6838
7717
  return (
6839
7718
  this.topics.isActiveUnambiguous(lease.sessionId) &&
6840
7719
  !topic?.bindingMalformed &&
7720
+ topic?.leaseOwner === this.installationHostId &&
7721
+ (topic.leaseExpiresAt ?? 0) > this.runtime.now() &&
6841
7722
  topic?.topicId === lease.topicId &&
6842
7723
  (topic.authorityEpoch ?? 0) === lease.authorityEpoch
6843
7724
  );
@@ -6884,7 +7765,7 @@ export class TelegramNotificationDaemon {
6884
7765
  ): void {
6885
7766
  const logicalSessionId = this.#logicalSessionId(session);
6886
7767
  const socketLease = this.#socketLease(session, logicalSessionId);
6887
- if (!socketLease) {
7768
+ if (!socketLease && !session.logicalSessionIdTrusted) {
6888
7769
  this.failLegacyToolStart(toolActivity);
6889
7770
  return;
6890
7771
  }
@@ -6898,6 +7779,7 @@ export class TelegramNotificationDaemon {
6898
7779
  send,
6899
7780
  msg,
6900
7781
  logicalSessionId,
7782
+ session,
6901
7783
  socketLease,
6902
7784
  ...(toolActivity ? { toolActivity } : {}),
6903
7785
  });
@@ -6913,15 +7795,17 @@ export class TelegramNotificationDaemon {
6913
7795
  if (!frames || frames.length === 0) return;
6914
7796
  this.pendingThreadedFrames.delete(sessionId);
6915
7797
  for (const frame of frames) {
7798
+ const socketLease = frame.socketLease ?? this.#socketLease(frame.session, sessionId);
6916
7799
  if (
6917
7800
  frame.logicalSessionId !== sessionId ||
6918
- !this.#leaseTokenAllows(frame.socketLease) ||
7801
+ !socketLease ||
7802
+ !this.#leaseTokenAllows(socketLease) ||
6919
7803
  (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled !== true)
6920
7804
  ) {
6921
7805
  this.failLegacyToolStart(frame.toolActivity);
6922
7806
  continue;
6923
7807
  }
6924
- await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, frame.socketLease);
7808
+ await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, socketLease);
6925
7809
  }
6926
7810
  }
6927
7811
 
@@ -6937,15 +7821,16 @@ export class TelegramNotificationDaemon {
6937
7821
  session?: SessionSocket,
6938
7822
  creationLease?: { session: SessionSocket; token: number; logicalSessionId: string },
6939
7823
  ): Promise<string | undefined> {
6940
- if (!(await this.pairedChatIsPrivate())) return undefined;
7824
+ if (!(await this.pairedChatAllowsTopics())) return undefined;
6941
7825
  if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
6942
7826
  const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
6943
7827
  if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
6944
7828
  const creationEndpointKey = session?.endpointDigest ?? session?.endpointKey ?? "unbound";
6945
- if (this.#malformedTopicCreateEndpoints.get(sessionId) === creationEndpointKey)
7829
+ const malformedCreateEndpoint = this.#malformedTopicCreateEndpoints.get(sessionId);
7830
+ if (malformedCreateEndpoint === "unbound" || malformedCreateEndpoint === creationEndpointKey)
6946
7831
  throw new Error("createForumTopic: invalid message_thread_id");
6947
7832
  const existing = this.topics.get(sessionId);
6948
- if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
7833
+ if (existing && (existing.authorityState !== "active" || existing.bindingMalformed)) return undefined;
6949
7834
  if (existing) return existing.topicId;
6950
7835
  if (
6951
7836
  session &&
@@ -6958,9 +7843,7 @@ export class TelegramNotificationDaemon {
6958
7843
  const creationLeaseEpoch = this.topics.authorityEpoch(sessionId);
6959
7844
  let acceptedTopicId: string | undefined;
6960
7845
  let acceptedTopicCompensated = false;
6961
- let acceptedTopicDeleteAttempted = false;
6962
- let creationSuppressed = false;
6963
- /** User-created topicId adopted in this callback, if any (for sidecar reconciliation). */
7846
+ let acceptedTopicArchiveAttempted = false;
6964
7847
  let adoptedTopicId: number | undefined;
6965
7848
  const adoptionIntentCandidate = this.#adoptionIntents.bySession(sessionId);
6966
7849
  try {
@@ -6999,10 +7882,10 @@ export class TelegramNotificationDaemon {
6999
7882
  const res = (await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name })) as
7000
7883
  | { result?: { message_thread_id?: unknown } }
7001
7884
  | undefined;
7002
- if (res === undefined) {
7003
- creationSuppressed = true;
7004
- return undefined;
7005
- }
7885
+ // Classification must travel with the rejection, not through
7886
+ // closure flags: `getOrCreateTopic` shares one in-flight create
7887
+ // promise, so every other awaiter observes only the error.
7888
+ if (res === undefined) throw new TopicCreationSuppressed();
7006
7889
  if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
7007
7890
  const response = res as {
7008
7891
  ok?: unknown;
@@ -7010,57 +7893,52 @@ export class TelegramNotificationDaemon {
7010
7893
  };
7011
7894
  const tid = response.result?.message_thread_id;
7012
7895
  if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) {
7013
- if (response.ok === true) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
7896
+ if (response.ok === false) throw new TopicCreationRejected();
7897
+ this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
7014
7898
  throw new Error("createForumTopic: invalid message_thread_id");
7015
7899
  }
7016
7900
  acceptedTopicId = String(tid);
7017
7901
  this.#malformedTopicCreateEndpoints.delete(sessionId);
7018
7902
  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;
7903
+ if (
7904
+ !this.topics.fenceAcceptedCreateForLease(
7905
+ sessionId,
7906
+ acceptedTopicId,
7907
+ creationLeaseEpoch,
7908
+ this.installationHostId,
7909
+ this.opts.now,
7910
+ name,
7911
+ creationBinding,
7912
+ undefined,
7913
+ String(this.opts.chatId),
7914
+ )
7915
+ )
7916
+ throw new Error("topic authority was revoked during creation");
7031
7917
  try {
7032
7918
  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);
7919
+ } catch (error) {
7920
+ this.topics.scheduleArchiveRetry(
7921
+ sessionId,
7922
+ this.runtime.now(),
7923
+ "archive fence persistence failed",
7924
+ );
7925
+ try {
7926
+ await this.#persistTopicsWithRetry();
7927
+ } catch {
7928
+ await this.#superviseCompensationFence(sessionId);
7063
7929
  }
7930
+ throw error;
7931
+ }
7932
+ if (
7933
+ this.#acceptedCreateArchiveFenceAllows(
7934
+ sessionId,
7935
+ acceptedTopicId,
7936
+ creationLeaseEpoch,
7937
+ creationBinding,
7938
+ )
7939
+ ) {
7940
+ acceptedTopicArchiveAttempted = true;
7941
+ acceptedTopicCompensated = (await this.archiveTopic(sessionId, undefined, true)) === "settled";
7064
7942
  }
7065
7943
  throw new Error("topic authority was revoked during creation");
7066
7944
  }
@@ -7073,9 +7951,8 @@ export class TelegramNotificationDaemon {
7073
7951
  session,
7074
7952
  adoptionIntentCandidate ? "user_created" : undefined,
7075
7953
  );
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.
7954
+ // Adoption commit success removes only the sidecar. The committed
7955
+ // user-created topic remains retained even when sidecar cleanup fails.
7079
7956
  if (adoptedTopicId !== undefined) {
7080
7957
  if (adoptionIntentCandidate)
7081
7958
  logger.info(
@@ -7090,6 +7967,10 @@ export class TelegramNotificationDaemon {
7090
7967
  );
7091
7968
  }
7092
7969
  }
7970
+ // Publish the durable host lease before rechecking the captured socket
7971
+ // lease. The recheck consults the registry once a record exists, so the
7972
+ // newly-created record must already authorize this host.
7973
+ if (!(await this.#renewTopicLease(sessionId))) return undefined;
7093
7974
  // getOrCreateTopic deduplicates callers, so an accepted create can be
7094
7975
  // observed by a successor after its initiating socket was revoked. Check
7095
7976
  // the immutable lease again before exposing that record to frame delivery.
@@ -7111,78 +7992,105 @@ export class TelegramNotificationDaemon {
7111
7992
  sessionId,
7112
7993
  rec.topicId,
7113
7994
  creationLeaseEpoch,
7995
+ this.installationHostId,
7114
7996
  this.opts.now,
7115
7997
  name,
7116
7998
  creationBinding,
7999
+ undefined,
8000
+ String(this.opts.chatId),
7117
8001
  )
7118
- )
7119
- await this.deleteTopic(sessionId, undefined, true);
8002
+ ) {
8003
+ try {
8004
+ await this.persistTopics();
8005
+ } catch {
8006
+ this.#superviseCompensationFence(sessionId);
8007
+ return undefined;
8008
+ }
8009
+ if (this.#acceptedCreateArchiveFenceAllows(sessionId, rec.topicId, creationLeaseEpoch, creationBinding))
8010
+ await this.archiveTopic(sessionId, undefined, true);
8011
+ }
7120
8012
  return undefined;
7121
8013
  }
7122
8014
  return rec.topicId;
7123
8015
  } 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);
8016
+ if (adoptedTopicId !== undefined) this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId);
8017
+ if (adoptionIntentCandidate) this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch);
8018
+ if (
8019
+ err instanceof TopicCreationSuppressed ||
8020
+ err instanceof TopicCreationRejected ||
8021
+ err instanceof ThreadedModeCapabilityRefusal
8022
+ ) {
8023
+ if (err instanceof ThreadedModeCapabilityRefusal) this.topicCapabilityRefused = true;
8024
+ if (this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch)) await this.persistTopics();
8025
+ return undefined;
8026
+ }
8027
+ const revokedAcceptedRecord = acceptedTopicId ? this.topics.get(sessionId) : undefined;
8028
+ if (
8029
+ revokedAcceptedRecord !== undefined &&
8030
+ revokedAcceptedRecord.topicId === acceptedTopicId &&
8031
+ revokedAcceptedRecord.authorityState === "archive_pending" &&
8032
+ !this.topics.archiveAuthorityAllows(
8033
+ sessionId,
8034
+ this.installationHostId,
8035
+ this.opts.chatId,
8036
+ this.runtime.now(),
8037
+ )
8038
+ ) {
8039
+ if (
8040
+ revokedAcceptedRecord.chatId === undefined &&
8041
+ revokedAcceptedRecord.endpointKey === undefined &&
8042
+ revokedAcceptedRecord.endpointDigest === undefined
8043
+ )
8044
+ revokedAcceptedRecord.chatId = String(this.opts.chatId);
8045
+ this.topics.beginArchive(sessionId, this.installationHostId, this.runtime.now());
8046
+ await this.persistTopics();
7129
8047
  }
7130
8048
  if (
7131
8049
  acceptedTopicId &&
7132
8050
  !acceptedTopicCompensated &&
7133
- !acceptedTopicDeleteAttempted &&
7134
- this.topics.get(sessionId)?.authorityState !== "delete_pending"
8051
+ !acceptedTopicArchiveAttempted &&
8052
+ this.topics.get(sessionId)?.authorityState !== "archive_pending"
7135
8053
  ) {
7136
8054
  // A failed initial commit must never make compensation conditional on
7137
8055
  // 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;
8056
+ if (
8057
+ this.topics.fenceAcceptedCreateForLease(
8058
+ sessionId,
8059
+ acceptedTopicId,
8060
+ creationLeaseEpoch,
8061
+ this.installationHostId,
8062
+ this.opts.now,
8063
+ name,
8064
+ creationBinding,
8065
+ undefined,
8066
+ String(this.opts.chatId),
8067
+ )
8068
+ ) {
7150
8069
  try {
7151
8070
  await this.#persistTopicsWithRetry();
7152
8071
  } catch {
7153
- this.#superviseCompensationFence(sessionId);
8072
+ await this.#superviseCompensationFence(sessionId);
7154
8073
  }
7155
8074
 
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 {
8075
+ if (
8076
+ this.#acceptedCreateArchiveFenceAllows(
8077
+ sessionId,
8078
+ acceptedTopicId,
8079
+ creationLeaseEpoch,
8080
+ creationBinding,
8081
+ )
8082
+ ) {
8083
+ try {
8084
+ acceptedTopicArchiveAttempted = true;
8085
+ acceptedTopicCompensated = (await this.archiveTopic(sessionId, undefined, true)) === "settled";
8086
+ } catch {
7176
8087
  this.#superviseCompensationFence(sessionId);
7177
8088
  await this.#persistTopicsWithRetry().catch(() => undefined);
7178
8089
  }
7179
- } catch {
7180
- this.#superviseCompensationFence(sessionId);
7181
- await this.#persistTopicsWithRetry().catch(() => undefined);
7182
8090
  }
7183
8091
  }
7184
8092
  }
7185
- if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicDeleteAttempted) {
8093
+ if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicArchiveAttempted) {
7186
8094
  this.#superviseCompensationFence(sessionId);
7187
8095
  await this.#persistTopicsWithRetry().catch(() => undefined);
7188
8096
  }
@@ -7211,24 +8119,75 @@ export class TelegramNotificationDaemon {
7211
8119
  if (!this.topicPastOrphanGrace(sessionId)) return;
7212
8120
  const currentOwner = this.logicalSessionOwners.get(sessionId);
7213
8121
  if (currentOwner && this.#leaseAllows(currentOwner, sessionId)) return;
7214
- await this.deleteTopic(sessionId);
8122
+ await this.archiveTopic(sessionId);
7215
8123
  });
7216
8124
  }
7217
8125
 
7218
8126
  /** Best-effort delete of a session topic once its local notification endpoint shuts down. */
7219
- private async deleteTopic(
8127
+ /** Join all close, compensation, orphan, and restart callers for one session. */
8128
+ private archiveTopic(
8129
+ sessionId: string,
8130
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
8131
+ archiveFenceAlreadyPublished = false,
8132
+ ): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
8133
+ const active = this.archiveFlights.get(sessionId);
8134
+ if (active) return active;
8135
+ const flight = this.#archiveTopicOnce(sessionId, socketLease, archiveFenceAlreadyPublished);
8136
+ this.archiveFlights.set(sessionId, flight);
8137
+ void flight.then(
8138
+ () => {
8139
+ if (this.archiveFlights.get(sessionId) === flight) this.archiveFlights.delete(sessionId);
8140
+ },
8141
+ () => {
8142
+ if (this.archiveFlights.get(sessionId) === flight) this.archiveFlights.delete(sessionId);
8143
+ },
8144
+ );
8145
+ return flight;
8146
+ }
8147
+
8148
+ /** One durable archive fence and at most one remote close dispatch per flight. */
8149
+ async #archiveTopicOnce(
7220
8150
  sessionId: string,
7221
8151
  socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
7222
- deleteFenceAlreadyPublished = false,
8152
+ archiveFenceAlreadyPublished = false,
7223
8153
  ): 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);
8154
+ if (!(await this.pairedChatAllowsTopics())) return "pre_dispatch_cancelled";
8155
+ const existing = this.topics.get(sessionId);
8156
+ // A completed archive retains an inactive record as historical evidence.
8157
+ // Re-archiving it would duplicate the irreversible remote close request.
8158
+ if (existing?.authorityState === "inactive") return "settled";
8159
+ if (existing?.topicOrigin === "user_created") {
8160
+ if (existing.authorityState === "archive_pending") {
8161
+ const archiveSnapshot = this.topics.captureArchiveAuthority(sessionId);
8162
+ const authorityEpoch = existing.authorityEpoch ?? Number.MAX_SAFE_INTEGER;
8163
+ if (!this.topics.settleArchive(sessionId, existing.topicId, authorityEpoch)) return "post_dispatch_pending";
8164
+ try {
8165
+ await this.persistTopics();
8166
+ } catch {
8167
+ this.topics.restoreArchiveFence(archiveSnapshot);
8168
+ await this.#persistTopicsWithRetry().catch(() => undefined);
8169
+ return "post_dispatch_pending";
8170
+ }
8171
+ }
8172
+ return "settled";
8173
+ }
8174
+ const archiveSnapshot = this.topics.captureArchiveAuthority(sessionId);
8175
+ let record = archiveFenceAlreadyPublished
8176
+ ? existing
8177
+ : this.topics.beginArchive(sessionId, this.installationHostId, this.runtime.now());
7229
8178
  if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
7230
- await this.persistTopics();
8179
+ if (
8180
+ record &&
8181
+ !this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
8182
+ )
8183
+ return "pre_dispatch_cancelled";
8184
+ if (!archiveFenceAlreadyPublished) await this.persistTopics();
7231
8185
  if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
8186
+ if (
8187
+ record &&
8188
+ !this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
8189
+ )
8190
+ return "pre_dispatch_cancelled";
7232
8191
  await this.#revokeAskAuthority(sessionId);
7233
8192
  this.deleteMessageRoutes(sessionId);
7234
8193
  this.#clearModelChoiceAliases(sessionId);
@@ -7237,7 +8196,17 @@ export class TelegramNotificationDaemon {
7237
8196
  record = this.topics.get(sessionId);
7238
8197
  await this.persistTopics();
7239
8198
  if (!record) return "settled";
8199
+ if (
8200
+ !this.topics.archiveAuthorityAllows(
8201
+ sessionId,
8202
+ this.installationHostId,
8203
+ this.opts.chatId,
8204
+ this.runtime.now(),
8205
+ )
8206
+ )
8207
+ return "pre_dispatch_cancelled";
7240
8208
  }
8209
+ const dispatchedAuthorityEpoch = record.authorityEpoch ?? Number.MAX_SAFE_INTEGER;
7241
8210
  const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
7242
8211
  for (const item of removed) {
7243
8212
  if (item.payload.selectedAck)
@@ -7247,43 +8216,26 @@ export class TelegramNotificationDaemon {
7247
8216
  try {
7248
8217
  await this.flushPool();
7249
8218
  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", {
8219
+ if (
8220
+ !this.topics.archiveAuthorityAllows(
8221
+ sessionId,
8222
+ this.installationHostId,
8223
+ this.opts.chatId,
8224
+ this.runtime.now(),
8225
+ )
8226
+ )
8227
+ return "pre_dispatch_cancelled";
8228
+ const res = (await this.botApi.call("closeForumTopic", {
7278
8229
  chat_id: this.opts.chatId,
7279
8230
  message_thread_id: Number(record.topicId),
7280
8231
  })) 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);
8232
+ if (!topicArchiveSettled(res)) {
8233
+ this.topics.scheduleArchiveRetry(sessionId, this.runtime.now(), "archive result was not definitive");
8234
+ await this.persistTopics();
7285
8235
  return "post_dispatch_pending";
7286
8236
  }
8237
+ if (!this.topics.settleArchive(sessionId, record.topicId, dispatchedAuthorityEpoch))
8238
+ return "post_dispatch_pending";
7287
8239
  for (const k of [...this.liveMessages.keys()])
7288
8240
  if (k.startsWith(`${sessionId}:`)) {
7289
8241
  this.liveMessages.delete(k);
@@ -7295,32 +8247,89 @@ export class TelegramNotificationDaemon {
7295
8247
  this.pendingThreadedFrames.delete(sessionId);
7296
8248
  try {
7297
8249
  await this.persistTopics();
7298
- this.topics.commitSettledDelete(settled);
7299
8250
  return "settled";
7300
8251
  } catch {
7301
- this.topics.rollbackSettledDelete(settled);
8252
+ this.topics.restoreArchiveFence(archiveSnapshot);
7302
8253
  await this.#persistTopicsWithRetry().catch(() => undefined);
7303
8254
  return "post_dispatch_pending";
7304
8255
  }
7305
8256
  } catch {
7306
- // Once Telegram dispatch starts, retain the persisted deletion fence: the
8257
+ // Once Telegram dispatch starts, retain the persisted archive fence: the
7307
8258
  // remote result is ambiguous and may not restore stale routing authority.
8259
+ this.topics.scheduleArchiveRetry(sessionId, this.runtime.now(), "archive transport failed");
8260
+ await this.persistTopics().catch(() => undefined);
7308
8261
  return "post_dispatch_pending";
7309
8262
  }
7310
8263
  }
7311
8264
 
7312
8265
  /** Serialize a mutation and its durable snapshot so rollback precedes later writers. */
7313
8266
  #persistTopicMutation<T>(mutation: () => T, rollback: () => void): Promise<T> {
8267
+ if (this.validationMode()) return Promise.resolve().then(mutation);
7314
8268
  const pending = this.topicsPersistQueue.then(async () => {
7315
- const result = mutation();
8269
+ let result = mutation();
8270
+ const authority = this.opts.topicRegistryAuthority;
8271
+ let sharedCommitted = false;
8272
+ let latestWinner: TopicRegistryState | undefined;
7316
8273
  try {
7317
8274
  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);
8275
+ if (authority) {
8276
+ for (let attempt = 0; attempt < 3; attempt++) {
8277
+ const expectedGeneration = this.topics.registryVersion();
8278
+ const snapshot = this.#topicStateForPersistence();
8279
+ const nextGeneration = expectedGeneration + 1;
8280
+ snapshot.registryGeneration = nextGeneration;
8281
+ let accepted = false;
8282
+ try {
8283
+ accepted = await authority.compareAndSet(expectedGeneration, snapshot);
8284
+ } catch {
8285
+ throw new Error("shared topic authority unavailable");
8286
+ }
8287
+ if (accepted) {
8288
+ this.topics.markRegistryPublished(nextGeneration);
8289
+ sharedCommitted = true;
8290
+ break;
8291
+ }
8292
+ const winner = parseTopicRegistryState(await authority.read().catch(() => undefined));
8293
+ if (!winner) throw new Error("shared topic authority conflict");
8294
+ latestWinner = winner;
8295
+ this.#replaceTopicAuthority(winner);
8296
+ if (attempt === 2) throw new Error("shared topic authority conflict");
8297
+ result = mutation();
8298
+ }
8299
+ }
8300
+ if (!authority) {
8301
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
8302
+ await ensureDir(this.fsImpl, paths.dir);
8303
+ const expectedDestination = await captureTopicRegistryDestination(
8304
+ this.fsImpl,
8305
+ path.join(paths.dir, "telegram-topics.json"),
8306
+ process.platform,
8307
+ );
8308
+ await writeTopicRegistryAtomic(
8309
+ this.fsImpl,
8310
+ path.join(paths.dir, "telegram-topics.json"),
8311
+ snapshot,
8312
+ process.platform,
8313
+ exactReplacePath,
8314
+ expectedDestination,
8315
+ );
8316
+ }
7321
8317
  return result;
7322
8318
  } catch (error) {
7323
- rollback();
8319
+ if (sharedCommitted && authority) {
8320
+ const committed = await authority.read().catch(() => undefined);
8321
+ const state = parseTopicRegistryState(committed);
8322
+ if (state) {
8323
+ this.topics.replace(state);
8324
+ this.closedEndpointKeys.clear();
8325
+ for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {}))
8326
+ if (binding) this.closedEndpointKeys.set(sessionId, binding);
8327
+ }
8328
+ } else if (latestWinner) {
8329
+ this.#replaceTopicAuthority(latestWinner);
8330
+ } else {
8331
+ rollback();
8332
+ }
7324
8333
  throw error;
7325
8334
  }
7326
8335
  });
@@ -7339,8 +8348,9 @@ export class TelegramNotificationDaemon {
7339
8348
  }
7340
8349
  }
7341
8350
 
7342
- #superviseCompensationFence(sessionId: string): void {
7343
- if (this.compensationFenceRetries.has(sessionId)) return;
8351
+ async #superviseCompensationFence(sessionId: string): Promise<void> {
8352
+ const existing = this.compensationFenceRetries.get(sessionId);
8353
+ if (existing) return await existing;
7344
8354
  const retry = this.effects.track(
7345
8355
  (async () => {
7346
8356
  for (;;) {
@@ -7354,35 +8364,143 @@ export class TelegramNotificationDaemon {
7354
8364
  })(),
7355
8365
  );
7356
8366
  this.compensationFenceRetries.set(sessionId, retry);
7357
- void retry.finally(() => this.compensationFenceRetries.delete(sessionId));
8367
+ try {
8368
+ await retry;
8369
+ } finally {
8370
+ if (this.compensationFenceRetries.get(sessionId) === retry) this.compensationFenceRetries.delete(sessionId);
8371
+ }
7358
8372
  }
7359
8373
 
7360
8374
  private persistTopics(): Promise<void> {
8375
+ if (this.validationMode()) return Promise.resolve();
7361
8376
  const pending = this.topicsPersistQueue.then(async () => {
7362
8377
  // Resolve implicit snapshots inside the serialization queue. Callers that
7363
8378
  // mutate the registry before waiting cannot overwrite a newer authority
7364
8379
  // binding with an invocation-time snapshot.
7365
8380
  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);
8381
+ const authority = this.opts.topicRegistryAuthority;
8382
+ if (authority) {
8383
+ const expectedGeneration = this.topics.registryVersion();
8384
+ const nextGeneration = expectedGeneration + 1;
8385
+ snapshot.registryGeneration = nextGeneration;
8386
+ let accepted = false;
8387
+ try {
8388
+ accepted = await authority.compareAndSet(expectedGeneration, snapshot);
8389
+ } catch {
8390
+ throw new Error("shared topic authority unavailable");
8391
+ }
8392
+ if (!accepted) {
8393
+ let winner: TopicRegistryState | undefined;
8394
+ try {
8395
+ winner = parseTopicRegistryState(await authority.read());
8396
+ } catch {
8397
+ throw new Error("shared topic authority unavailable");
8398
+ }
8399
+ if (!winner) throw new Error("shared topic authority conflict");
8400
+ this.#replaceTopicAuthority(winner);
8401
+ throw new Error("shared topic authority conflict");
8402
+ }
8403
+ this.topics.markRegistryPublished(nextGeneration);
8404
+ }
8405
+ if (!authority) {
8406
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
8407
+ await ensureDir(this.fsImpl, paths.dir);
8408
+ const topicPath = path.join(paths.dir, "telegram-topics.json");
8409
+ const expectedDestination = await captureTopicRegistryDestination(this.fsImpl, topicPath, process.platform);
8410
+ await writeTopicRegistryAtomic(
8411
+ this.fsImpl,
8412
+ topicPath,
8413
+ snapshot,
8414
+ process.platform,
8415
+ exactReplacePath,
8416
+ expectedDestination,
8417
+ );
8418
+ }
7369
8419
  });
7370
8420
  this.topicsPersistQueue = pending.catch(() => undefined);
7371
8421
  return pending;
7372
8422
  }
7373
8423
 
8424
+ #acceptedCreateArchiveFenceAllows(
8425
+ sessionId: string,
8426
+ topicId: string,
8427
+ creationLeaseEpoch: number,
8428
+ binding: TopicEndpointBinding | undefined,
8429
+ ): boolean {
8430
+ const record = this.topics.get(sessionId);
8431
+ const bindingMatches = binding
8432
+ ? record?.chatId === binding.chatId &&
8433
+ record.endpointKey === binding.endpointKey &&
8434
+ record.endpointDigest === binding.endpointDigest &&
8435
+ record.endpointGeneration === binding.endpointGeneration
8436
+ : record?.chatId === String(this.opts.chatId) &&
8437
+ record.endpointKey === undefined &&
8438
+ record.endpointDigest === undefined;
8439
+ return (
8440
+ record?.topicId === topicId &&
8441
+ record.creationLeaseEpoch === creationLeaseEpoch &&
8442
+ bindingMatches &&
8443
+ this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
8444
+ );
8445
+ }
8446
+
8447
+ #replaceTopicAuthority(state: TopicRegistryState): void {
8448
+ this.topics.replace(state);
8449
+ this.closedEndpointKeys.clear();
8450
+ for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {}))
8451
+ if (binding) this.closedEndpointKeys.set(sessionId, binding);
8452
+ }
8453
+
7374
8454
  #topicStateForPersistence(): TopicRegistryState {
7375
- return { ...this.topics.serialize(), closedEndpoints: Object.fromEntries(this.closedEndpointKeys) };
8455
+ return {
8456
+ ...this.topics.serialize(),
8457
+ closedEndpoints: Object.fromEntries(this.closedEndpointKeys),
8458
+ ...(this.opts.topicRegistryAuthority ? {} : { installationHostId: this.installationHostId }),
8459
+ };
7376
8460
  }
7377
8461
 
7378
8462
  async loadTopics(): Promise<void> {
8463
+ if (this.validationMode()) return;
7379
8464
  const paths = daemonPaths(this.opts.settings.getAgentDir());
7380
- const raw = await readJson<TopicRegistryState>(this.fsImpl, path.join(paths.dir, "telegram-topics.json"));
8465
+ const topicPath = path.join(paths.dir, "telegram-topics.json");
8466
+ let raw = await readJson<unknown>(this.fsImpl, topicPath);
8467
+ if (this.opts.topicRegistryAuthority) {
8468
+ try {
8469
+ raw = await this.opts.topicRegistryAuthority.read();
8470
+ } catch {
8471
+ throw new Error("shared topic authority unavailable");
8472
+ }
8473
+ if (!raw) throw new Error("shared topic authority unavailable");
8474
+ }
8475
+ const legacySnapshot =
8476
+ !!raw && typeof raw === "object" && !Array.isArray(raw) && !Object.hasOwn(raw as object, "version");
8477
+ if (legacySnapshot && raw !== undefined) {
8478
+ const legacyDigest = crypto.createHash("sha256").update(JSON.stringify(raw)).digest("hex");
8479
+ const quarantinePath = path.join(paths.dir, `telegram-topics.legacy-quarantine.${legacyDigest}.json`);
8480
+ if ((await readJson<unknown>(this.fsImpl, quarantinePath)) === undefined) {
8481
+ await ensureDir(this.fsImpl, paths.dir);
8482
+ await writeTopicRegistryAtomic(this.fsImpl, quarantinePath, raw);
8483
+ }
8484
+ }
8485
+ const state = parseTopicRegistryState(raw);
7381
8486
  // Restore the full serialized registry (topicId + identitySent + name) so a
7382
8487
  // 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 ?? {})) {
8488
+ if (state) {
8489
+ const missingHostId =
8490
+ !this.opts.topicRegistryAuthority &&
8491
+ (typeof state.installationHostId !== "string" || state.installationHostId.length === 0);
8492
+ const missingSessionUuid = Object.values(state.topics).some(
8493
+ record => !!record && typeof record === "object" && typeof record.sessionUuid !== "string",
8494
+ );
8495
+ this.topics.load(state);
8496
+ if (!this.opts.topicRegistryAuthority && typeof state.installationHostId === "string")
8497
+ this.installationHostId = state.installationHostId;
8498
+ let reconciledCreateClaim = false;
8499
+ for (const claim of this.topics.pendingCreateClaims()) {
8500
+ const topic = this.topics.get(claim.sessionId);
8501
+ reconciledCreateClaim = this.topics.reconcileCreateClaim(claim.sessionId, topic) || reconciledCreateClaim;
8502
+ }
8503
+ for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {})) {
7386
8504
  if (
7387
8505
  binding &&
7388
8506
  typeof binding.chatId === "string" &&
@@ -7393,14 +8511,14 @@ export class TelegramNotificationDaemon {
7393
8511
  )
7394
8512
  this.closedEndpointKeys.set(sessionId, binding);
7395
8513
  }
8514
+ if (legacySnapshot || missingHostId || missingSessionUuid || reconciledCreateClaim) await this.persistTopics();
7396
8515
  }
7397
8516
  }
7398
8517
 
7399
8518
  /**
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.
8519
+ * Rehydrate durable adoption intents after restart. A sidecar whose topic
8520
+ * already committed is stale evidence and can be removed without touching
8521
+ * the retained user-created topic.
7404
8522
  */
7405
8523
  async loadAdoptionIntents(): Promise<void> {
7406
8524
  await this.#adoptionIntents.rehydrate();
@@ -7423,7 +8541,6 @@ export class TelegramNotificationDaemon {
7423
8541
  }
7424
8542
  }
7425
8543
 
7426
- /** Periodic sweep of expired adoption-intent sidecars (files only; no Telegram API). */
7427
8544
  private startAdoptionSweepTimer(): void {
7428
8545
  const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
7429
8546
  this.#adoptionSweepTimer = setIntervalImpl(() => {
@@ -7446,9 +8563,10 @@ export class TelegramNotificationDaemon {
7446
8563
  }
7447
8564
  }
7448
8565
 
7449
- /** Retry crash-interrupted topic deletes; only a definite Telegram result clears the durable fence. */
8566
+ /** Retry crash-interrupted topic archives only when the durable backoff permits it. */
7450
8567
  private async reconcilePendingTopicDeletes(): Promise<void> {
7451
- for (const sessionId of this.topics.deletePendingSessionIds()) await this.deleteTopic(sessionId);
8568
+ for (const sessionId of this.topics.archivePendingSessionIds(this.runtime.now()))
8569
+ await this.archiveTopic(sessionId);
7452
8570
  }
7453
8571
 
7454
8572
  /** Download one Telegram file with the Bot API's 20 MiB ceiling and one end-to-end deadline. */
@@ -7892,7 +9010,7 @@ export class TelegramNotificationDaemon {
7892
9010
  continue;
7893
9011
  }
7894
9012
  const topicId = topicLease?.topicId;
7895
- if (topicId && !(await this.pairedChatIsPrivate())) {
9013
+ if (topicId && !(await this.pairedChatAllowsTopics())) {
7896
9014
  this.pool.settle(item.itemId!, "rejected");
7897
9015
  this.failLegacyToolStart(toolActivity);
7898
9016
  continue;
@@ -8055,7 +9173,7 @@ export class TelegramNotificationDaemon {
8055
9173
  );
8056
9174
  // Index the sent rich message so an inbound reply to it can restore
8057
9175
  // the original markdown as context (Telegram does not echo it back).
8058
- if (richMessageId !== undefined) {
9176
+ if (richMessageId !== undefined && !this.validationMode()) {
8059
9177
  await this.replyStore.record({
8060
9178
  chatId: this.opts.chatId,
8061
9179
  messageId: richMessageId,
@@ -8362,29 +9480,41 @@ export class TelegramNotificationDaemon {
8362
9480
  }
8363
9481
 
8364
9482
  /**
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.
9483
+ * Resolve and cache the outbound delivery boundary. The validation forum is
9484
+ * explicit and topic-only; all inbound and flat paths remain private-only.
8369
9485
  */
9486
+ private validationMode(): boolean {
9487
+ return this.opts.validationTestSupergroupChatId !== undefined;
9488
+ }
9489
+
8370
9490
  private async resolvePairedChatPrivacy(): Promise<PairedChatPrivacy> {
8371
- if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate ? "private" : "non-private";
9491
+ if (this.pairedChatPrivacy !== undefined) return this.pairedChatPrivacy;
9492
+ const chatId = this.opts.validationTestSupergroupChatId ?? this.opts.chatId;
8372
9493
  try {
8373
- const res = (await this.botApi.call("getChat", { chat_id: this.opts.chatId })) as {
9494
+ const res = (await this.botApi.call("getChat", { chat_id: chatId })) as {
8374
9495
  ok?: unknown;
8375
- result?: { type?: unknown };
9496
+ result?: { id?: unknown; type?: unknown; is_forum?: unknown };
8376
9497
  };
8377
9498
  if (res === undefined) return "indeterminate";
8378
9499
  if (res?.ok !== true) {
8379
9500
  logger.warn("notifications: getChat privacy check indeterminate (non-success response)");
8380
9501
  return "indeterminate";
8381
9502
  }
8382
- if (res.result?.type === "private") {
8383
- this.pairedChatPrivate = true;
9503
+ if (
9504
+ this.validationMode() &&
9505
+ String(res.result?.id) === this.opts.validationTestSupergroupChatId &&
9506
+ res.result?.type === "supergroup" &&
9507
+ res.result.is_forum === true
9508
+ ) {
9509
+ this.pairedChatPrivacy = "validation-forum";
9510
+ return "validation-forum";
9511
+ }
9512
+ if (!this.validationMode() && res.result?.type === "private") {
9513
+ this.pairedChatPrivacy = "private";
8384
9514
  return "private";
8385
9515
  }
8386
9516
  if (res.result?.type === "group" || res.result?.type === "supergroup" || res.result?.type === "channel") {
8387
- this.pairedChatPrivate = false;
9517
+ this.pairedChatPrivacy = "non-private";
8388
9518
  return "non-private";
8389
9519
  }
8390
9520
  logger.warn("notifications: getChat privacy check indeterminate (missing or invalid chat type)");
@@ -8395,7 +9525,13 @@ export class TelegramNotificationDaemon {
8395
9525
  }
8396
9526
  }
8397
9527
 
8398
- /** Keep existing outbound callers fail-closed for indeterminate privacy. */
9528
+ private async pairedChatAllowsTopics(): Promise<boolean> {
9529
+ if (this.topicCapabilityRefused) return false;
9530
+ const privacy = await this.resolvePairedChatPrivacy();
9531
+ return privacy === "private" || privacy === "validation-forum";
9532
+ }
9533
+
9534
+ /** Keep all flat delivery and inbound control paths private-only. */
8399
9535
  private async pairedChatIsPrivate(): Promise<boolean> {
8400
9536
  return (await this.resolvePairedChatPrivacy()) === "private";
8401
9537
  }
@@ -8432,7 +9568,11 @@ export class TelegramNotificationDaemon {
8432
9568
  private startFlushTimer(): void {
8433
9569
  this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
8434
9570
  if (!this.running || this.pool.pending === 0) return;
8435
- void this.flushPool();
9571
+ // `flushPool` only swallows failures for the shared chain; the promise it
9572
+ // returns still rejects, and an unhandled rejection here exits the daemon.
9573
+ void this.flushPool().catch(error => {
9574
+ logger.warn(`notifications: queue flush failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`);
9575
+ });
8436
9576
  });
8437
9577
  }
8438
9578
 
@@ -8472,11 +9612,27 @@ export class TelegramNotificationDaemon {
8472
9612
  this.runtime.stopInterval("telegram-owner-heartbeat");
8473
9613
  }
8474
9614
 
8475
- /** Run a root scan, guarding against overlapping scans from the timer + loop. */
9615
+ /**
9616
+ * Run a root scan, guarding against overlapping scans from the timer + loop.
9617
+ *
9618
+ * A reconciliation pass is retried every scan interval, so a failed one must
9619
+ * never tear the owner down. Both callers are fatal boundaries: the timer
9620
+ * fires and forgets, and the run loop awaits without a handler. A rejection
9621
+ * escaping either one (a shared topic authority whose lock or compare-and-set
9622
+ * is momentarily unavailable is the observed case) reaches the process-level
9623
+ * handler, which exits the daemon: every session topic is then left as an
9624
+ * unarchived shell that answers nothing, and the sessions that were still
9625
+ * live lose notifications too. Report the failure and let the next scan
9626
+ * retry instead.
9627
+ */
8476
9628
  private async runScan(): Promise<void> {
8477
- await this.runtime.runExclusive("telegram-scan", async () => {
8478
- await this.scanRoots();
8479
- });
9629
+ try {
9630
+ await this.runtime.runExclusive("telegram-scan", async () => {
9631
+ await this.scanRoots();
9632
+ });
9633
+ } catch (error) {
9634
+ logger.warn(`notifications: session scan failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`);
9635
+ }
8480
9636
  }
8481
9637
 
8482
9638
  private startScanTimer(): void {
@@ -8594,14 +9750,18 @@ export class TelegramNotificationDaemon {
8594
9750
  );
8595
9751
  if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return false;
8596
9752
  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
- });
9753
+ const response = await this.botApi.call(
9754
+ "sendMessage",
9755
+ {
9756
+ chat_id: this.opts.chatId,
9757
+ message_thread_id: Number(topicId),
9758
+ text: rendered.text,
9759
+ parse_mode: TELEGRAM_PARSE_MODE,
9760
+ reply_markup: { inline_keyboard },
9761
+ ...(telegramDisableNotification(this.opts.sound, "ask") === true ? { disable_notification: true } : {}),
9762
+ },
9763
+ { noRetry: true },
9764
+ );
8605
9765
  if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
8606
9766
  for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
8607
9767
  logger.warn("notifications: failed to send model selection keyboard");
@@ -8689,10 +9849,13 @@ export class TelegramNotificationDaemon {
8689
9849
  endpointAuthority.sessionId === session.sessionId &&
8690
9850
  ownsLiveOpenEndpoint &&
8691
9851
  this.topics.matchesEndpoint(session.sessionId, endpointBinding);
9852
+ const inactiveSuccessor =
9853
+ this.topics.get(session.sessionId)?.authorityState === "inactive" &&
9854
+ this.topics.get(session.sessionId)?.endpointDigest !== endpointBinding.endpointDigest;
8692
9855
  const canBootstrapTransport =
8693
- endpointAuthority.state === "none" &&
9856
+ (endpointAuthority.state === "none" || inactiveSuccessor) &&
8694
9857
  ownsLiveOpenEndpoint &&
8695
- !this.topics.get(session.sessionId) &&
9858
+ (!this.topics.get(session.sessionId) || inactiveSuccessor) &&
8696
9859
  !this.preservedInitiatorTopics.has(session.sessionId);
8697
9860
  const replayCandidateSessionId =
8698
9861
  replayIdentitySessionId ?? (canResumeTransport || canBootstrapTransport ? session.sessionId : undefined);
@@ -8704,6 +9867,7 @@ export class TelegramNotificationDaemon {
8704
9867
  true,
8705
9868
  replayIdentitySessionId ? undefined : canBootstrapTransport ? "bootstrap" : "resume",
8706
9869
  );
9870
+ if (this.sessions.get(session.sessionId) !== session) return;
8707
9871
  if (!recovered) {
8708
9872
  if (session.hostGeneration === msg.generation && session.recoveryLease?.state !== "pending")
8709
9873
  this.dropSession(session, "recovery_rejected");
@@ -9116,10 +10280,8 @@ export class TelegramNotificationDaemon {
9116
10280
  abandonStaleToolStart();
9117
10281
  return;
9118
10282
  }
9119
- if (
9120
- this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
9121
- this.topics.get(logicalSessionId)?.bindingMalformed
9122
- ) {
10283
+ const topicRecord = this.topics.get(logicalSessionId);
10284
+ if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) {
9123
10285
  this.failLegacyToolStart(toolActivity);
9124
10286
  return;
9125
10287
  }
@@ -9132,10 +10294,8 @@ export class TelegramNotificationDaemon {
9132
10294
  (await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session));
9133
10295
  const topicLease = await this.topicAuthorityLease(logicalSessionId);
9134
10296
  if (!topicId || !topicLease || topicLease.topicId !== topicId) {
9135
- if (
9136
- this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
9137
- this.topics.get(logicalSessionId)?.bindingMalformed
9138
- ) {
10297
+ const topicRecord = this.topics.get(logicalSessionId);
10298
+ if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) {
9139
10299
  this.failLegacyToolStart(toolActivity);
9140
10300
  return;
9141
10301
  }
@@ -9195,12 +10355,13 @@ export class TelegramNotificationDaemon {
9195
10355
  const logicalSessionId = this.#logicalSessionId(session);
9196
10356
  const socketLease = this.#socketLease(session, logicalSessionId);
9197
10357
  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;
10358
+ const pendingAction = msg.kind === "ask" ? { sessionId: logicalSessionId, actionId: msg.id } : undefined;
10359
+ if (pendingAction) {
10360
+ session.pending.set(msg.id, pendingAction);
10361
+ await this.reissuePendingAction(logicalSessionId, msg.id);
10362
+ }
10363
+ const topicRecord = this.topics.get(logicalSessionId);
10364
+ if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) return;
9204
10365
  const topicId = await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session);
9205
10366
  const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(logicalSessionId) : undefined;
9206
10367
  if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
@@ -9247,7 +10408,9 @@ export class TelegramNotificationDaemon {
9247
10408
  : [],
9248
10409
  );
9249
10410
  const displayOptions = options.map((option, index) =>
9250
- multiSelect ? `${selectedOptionIndices.has(index) ? "☑" : "☐"} ${option}` : option,
10411
+ multiSelect
10412
+ ? `${selectedOptionIndices.has(index) ? SELECTION_MARK_CHECKED : SELECTION_MARK_UNCHECKED} ${option}`
10413
+ : option,
9251
10414
  );
9252
10415
  const rendered = buildActionMessage({
9253
10416
  kind: msg.kind ?? "ask",
@@ -9258,81 +10421,245 @@ export class TelegramNotificationDaemon {
9258
10421
  controls,
9259
10422
  summary: msg.summary,
9260
10423
  });
10424
+ const kind = msg.kind === "idle" ? "idle" : "ask";
10425
+ const callbackDispatchAuthorityIsCurrent = (): boolean =>
10426
+ this.#leaseTokenAllows(socketLease) &&
10427
+ (!topicLease || this.topicLeaseIsCurrent(topicLease)) &&
10428
+ (kind !== "ask" || (pendingAction !== undefined && session.pending.get(msg.id) === pendingAction));
10429
+ const createdAliases: Array<[string, CallbackRoute]> = [];
10430
+ const createAlias = (answer: CallbackRoute["answer"]): string => {
10431
+ const alias = this.aliasTable.allocate(candidate => this.reissueBacklog.has(candidate));
10432
+ createdAliases.push([
10433
+ alias,
10434
+ {
10435
+ sessionId: logicalSessionId,
10436
+ actionId: msg.id,
10437
+ answer,
10438
+ },
10439
+ ]);
10440
+ return alias;
10441
+ };
9261
10442
  const inline_keyboard = [
9262
- ...buildCompactChoiceGrid(displayOptions, (i: number) =>
9263
- this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
9264
- ),
10443
+ ...buildCompactChoiceGrid(displayOptions, (i: number) => createAlias(i)),
9265
10444
  ...controls.map(control => [
9266
10445
  {
9267
10446
  text: control.label,
9268
- callback_data: this.aliasTable.put({
9269
- sessionId: logicalSessionId,
9270
- actionId: msg.id,
9271
- answer: { controlId: control.id },
9272
- }),
10447
+ callback_data: createAlias({ controlId: control.id }),
9273
10448
  },
9274
10449
  ]),
9275
10450
  ];
9276
- const kind = msg.kind === "idle" ? "idle" : "ask";
10451
+ if (createdAliases.length > 0) {
10452
+ for (const [alias, route] of createdAliases) {
10453
+ if (!this.aliasTable.activate(alias, route)) throw new Error("callback alias reservation conflicted");
10454
+ }
10455
+ // Reserve every exposed callback token durably before Telegram can
10456
+ // accept a keyboard. A restart loads these non-receipt routes only
10457
+ // into the non-routable reissue backlog.
10458
+ await this.persistAliases();
10459
+ }
9277
10460
  // HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
9278
- // returns the last chunk's message_id (the reply-routable message).
10461
+ // returns the last chunk's authoritative message_id (the reply-routable message).
10462
+ let callbackDelivery: "accepted" | "rejected" | "ambiguous" = "rejected";
9279
10463
  const sendHtmlChunks = async (): Promise<number | undefined> => {
9280
10464
  const chunks = splitTelegramHtml(rendered.text);
9281
- let result: { result?: { message_id?: number } } | undefined;
10465
+ let acceptedMessageId: number | undefined;
9282
10466
  for (let i = 0; i < chunks.length; i++) {
9283
- if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
10467
+ const exposesCallbacks = i === chunks.length - 1 && inline_keyboard.length > 0;
10468
+ if (!callbackDispatchAuthorityIsCurrent()) return undefined;
10469
+ let result: { ok?: unknown; result?: { message_id?: unknown } } | undefined;
10470
+ try {
10471
+ result = (await this.botApi.call(
10472
+ "sendMessage",
10473
+ {
10474
+ chat_id: this.opts.chatId,
10475
+ ...threadField,
10476
+ text: chunks[i]!,
10477
+ parse_mode: TELEGRAM_PARSE_MODE,
10478
+ ...(telegramDisableNotification(this.opts.sound, kind, i === chunks.length - 1) === true
10479
+ ? { disable_notification: true }
10480
+ : {}),
10481
+ ...(exposesCallbacks ? { reply_markup: { inline_keyboard } } : {}),
10482
+ },
10483
+ { noRetry: true },
10484
+ )) as { ok?: unknown; result?: { message_id?: unknown } } | undefined;
10485
+ } catch {
10486
+ if (exposesCallbacks) callbackDelivery = "ambiguous";
9284
10487
  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;
10488
+ }
10489
+ if (result?.ok !== true) {
10490
+ if (exposesCallbacks && result?.ok !== false) callbackDelivery = "ambiguous";
10491
+ return undefined;
10492
+ }
10493
+ const candidate = result.result?.message_id;
10494
+ if (typeof candidate !== "number" || !Number.isSafeInteger(candidate) || candidate <= 0) {
10495
+ if (exposesCallbacks) callbackDelivery = "ambiguous";
10496
+ return undefined;
10497
+ }
10498
+ if (exposesCallbacks) callbackDelivery = "accepted";
10499
+ acceptedMessageId = candidate;
9296
10500
  }
9297
- return result?.result?.message_id;
10501
+ return acceptedMessageId;
9298
10502
  };
10503
+ let messageId: number | undefined;
9299
10504
  if (this.opts.rich?.enabled !== false) {
9300
10505
  // Rich (default on): promote to sendRichMessage with a top-level
9301
10506
  // 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 });
10507
+ if (callbackDispatchAuthorityIsCurrent()) {
10508
+ const outcome = await deliverRichActionWithFallback(
10509
+ this.botApi,
10510
+ {
10511
+ chat_id: this.opts.chatId,
10512
+ ...threadField,
10513
+ ...(telegramDisableNotification(this.opts.sound, kind) === true
10514
+ ? { disable_notification: true }
10515
+ : {}),
10516
+ },
10517
+ {
10518
+ markdown: buildActionMarkdown({
10519
+ kind,
10520
+ question: msg.question,
10521
+ options: displayOptions,
10522
+ recommendedIndex: msg.recommendedIndex,
10523
+ summary: msg.summary,
10524
+ }),
10525
+ replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
10526
+ },
10527
+ AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
10528
+ sendHtmlChunks,
10529
+ logger,
10530
+ );
10531
+ messageId = outcome.messageId;
10532
+ if (createdAliases.length > 0 && messageId !== undefined) callbackDelivery = "accepted";
10533
+ else if (createdAliases.length > 0 && !outcome.usedFallback) callbackDelivery = "ambiguous";
10534
+ }
9330
10535
  } else {
9331
10536
  // 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 });
10537
+ messageId = await sendHtmlChunks();
10538
+ }
10539
+ const pendingActionIsCurrent = pendingAction !== undefined && session.pending.get(msg.id) === pendingAction;
10540
+ const mayActivate =
10541
+ kind === "ask" &&
10542
+ messageId !== undefined &&
10543
+ pendingActionIsCurrent &&
10544
+ this.#leaseTokenAllows(socketLease) &&
10545
+ (!topicLease || this.topicLeaseIsCurrent(topicLease));
10546
+ if (mayActivate) {
10547
+ for (const [alias, route] of createdAliases) {
10548
+ if (
10549
+ !this.aliasTable.update(alias, {
10550
+ ...route,
10551
+ chatId: String(this.opts.chatId),
10552
+ messageId,
10553
+ ownerId: this.opts.ownerId,
10554
+ generation: DAEMON_GENERATION,
10555
+ })
10556
+ )
10557
+ throw new Error("callback alias receipt update conflicted");
10558
+ }
10559
+ this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
10560
+ const activation = this.#stageCallbackActivation(
10561
+ createdAliases.map(([alias]) => alias),
10562
+ pendingAction,
10563
+ socketLease,
10564
+ topicLease,
10565
+ );
10566
+ try {
10567
+ // A receipt is durable before an alias gets a routable socket lease.
10568
+ await this.persistAliases();
10569
+ } catch {
10570
+ for (const [alias] of createdAliases) {
10571
+ this.#stagedCallbackActivations.delete(alias);
10572
+ this.#revokeCallbackAlias(alias);
10573
+ }
10574
+ this.deleteMessageRoutes(logicalSessionId, msg.id);
10575
+ try {
10576
+ await this.persistAliases();
10577
+ } catch {
10578
+ // The in-memory backlog retains the accepted receipt for shutdown terminalization.
10579
+ }
10580
+ activation.resolve(false);
10581
+ throw new Error("callback alias receipt persistence failed");
10582
+ }
10583
+ if (
10584
+ session.pending.get(msg.id) !== pendingAction ||
10585
+ !this.#leaseTokenAllows(socketLease) ||
10586
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
10587
+ ) {
10588
+ for (const [alias] of createdAliases) {
10589
+ this.#revokeCallbackAlias(alias);
10590
+ this.#stagedCallbackActivations.delete(alias);
10591
+ }
10592
+ this.deleteMessageRoutes(logicalSessionId, msg.id);
10593
+ let revocationPersistenceError: unknown;
10594
+ try {
10595
+ await this.persistAliases();
10596
+ } catch (error) {
10597
+ revocationPersistenceError = error;
10598
+ } finally {
10599
+ activation.resolve(false);
10600
+ }
10601
+ try {
10602
+ await this.botApi.call("editMessageReplyMarkup", {
10603
+ chat_id: this.opts.chatId,
10604
+ message_id: messageId,
10605
+ reply_markup: { inline_keyboard: [] },
10606
+ });
10607
+ } catch {
10608
+ // The accepted receipt remains in memory for shutdown terminalization if persistence failed.
10609
+ }
10610
+ if (revocationPersistenceError !== undefined)
10611
+ throw new Error("callback alias revocation persistence failed", {
10612
+ cause: revocationPersistenceError,
10613
+ });
10614
+ return;
10615
+ }
10616
+ for (const [alias] of createdAliases) {
10617
+ this.#callbackAliasPending.set(alias, pendingAction);
10618
+ this.#callbackAliasLeases.set(alias, socketLease);
10619
+ if (topicLease) this.#callbackAliasTopics.set(alias, topicLease);
10620
+ this.#stagedCallbackActivations.delete(alias);
10621
+ }
10622
+ activation.resolve(true);
10623
+ } else if (kind === "ask" && messageId !== undefined) {
10624
+ // The send may have succeeded after authority ended. Persist every
10625
+ // receipt as revoked before best-effort remote terminalization.
10626
+ for (const [alias, route] of createdAliases) {
10627
+ const receipt = {
10628
+ ...route,
10629
+ chatId: String(this.opts.chatId),
10630
+ messageId,
10631
+ ownerId: this.opts.ownerId,
10632
+ generation: DAEMON_GENERATION,
10633
+ };
10634
+ if (this.aliasTable.update(alias, receipt)) {
10635
+ this.#revokeCallbackAlias(alias);
10636
+ continue;
10637
+ }
10638
+ const revoked = this.reissueBacklog.get(alias);
10639
+ if (
10640
+ revoked &&
10641
+ (revoked.sessionId !== route.sessionId ||
10642
+ revoked.actionId !== route.actionId ||
10643
+ revoked.answer !== route.answer)
10644
+ )
10645
+ throw new Error("callback alias revocation update conflicted");
10646
+ this.reissueBacklog.set(alias, receipt);
10647
+ }
10648
+ await this.persistAliases();
10649
+ try {
10650
+ const terminalized = (await this.botApi.call("editMessageReplyMarkup", {
10651
+ chat_id: this.opts.chatId,
10652
+ message_id: messageId,
10653
+ reply_markup: { inline_keyboard: [] },
10654
+ })) as { ok?: unknown } | undefined;
10655
+ if (terminalized?.ok === true) for (const [alias] of createdAliases) this.reissueBacklog.delete(alias);
10656
+ } catch {
10657
+ // The accepted receipt remains durably terminalizable even if its keyboard cannot be removed.
10658
+ }
10659
+ } else if (kind === "ask" && callbackDelivery === "ambiguous") {
10660
+ for (const [alias] of createdAliases) this.#revokeCallbackAlias(alias);
10661
+ } else if (kind === "ask") {
10662
+ for (const [alias] of createdAliases) this.aliasTable.delete(alias);
9336
10663
  }
9337
10664
  await this.persistAliases();
9338
10665
  } else if (msg.type === "action_resolved" && msg.id) {
@@ -9340,8 +10667,9 @@ export class TelegramNotificationDaemon {
9340
10667
  this.deleteMessageRoutes(this.#logicalSessionId(session), msg.id);
9341
10668
  for (const [alias, route] of this.aliasTable.entries()) {
9342
10669
  if (route.sessionId === this.#logicalSessionId(session) && route.actionId === msg.id)
9343
- this.aliasTable.delete(alias);
10670
+ this.#revokeCallbackAlias(alias);
9344
10671
  }
10672
+ await this.reissuePendingAction(this.#logicalSessionId(session), msg.id);
9345
10673
  await this.persistAliases();
9346
10674
  }
9347
10675
  }
@@ -9556,14 +10884,18 @@ export class TelegramNotificationDaemon {
9556
10884
  }),
9557
10885
  }));
9558
10886
  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 }]),
10887
+ const response = await this.botApi.call(
10888
+ "sendMessage",
10889
+ {
10890
+ chat_id: this.opts.chatId,
10891
+ message_thread_id: threadId,
10892
+ text: "Choose where to start the GJC session:",
10893
+ reply_markup: {
10894
+ inline_keyboard: aliases.map(choice => [{ text: choice.label, callback_data: choice.alias }]),
10895
+ },
9565
10896
  },
9566
- });
10897
+ { noRetry: true },
10898
+ );
9567
10899
  if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
9568
10900
  for (const choice of aliases) this.#adoptionPickerAliases.delete(choice.alias);
9569
10901
  return false;
@@ -9626,12 +10958,16 @@ export class TelegramNotificationDaemon {
9626
10958
  index => aliases[index]!.alias,
9627
10959
  );
9628
10960
  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
- });
10961
+ const response = await this.botApi.call(
10962
+ "sendMessage",
10963
+ {
10964
+ chat_id: this.opts.chatId,
10965
+ message_thread_id: threadId,
10966
+ text: "Pick a recent work folder to start a session here:",
10967
+ reply_markup: { inline_keyboard },
10968
+ },
10969
+ { noRetry: true },
10970
+ );
9635
10971
  if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
9636
10972
  for (const a of aliases) this.#adoptionPickerAliases.delete(a.alias);
9637
10973
  return false;
@@ -10080,6 +11416,7 @@ export class TelegramNotificationDaemon {
10080
11416
  }
10081
11417
 
10082
11418
  private async processTelegramUpdate(update: unknown): Promise<TelegramUpdateOutcome> {
11419
+ if (this.validationMode()) return "consumed";
10083
11420
  const createdOutcome = await this.handleForumTopicCreatedUpdate(update);
10084
11421
  if (createdOutcome !== "not-topic") return createdOutcome;
10085
11422
  const topicOutcome = await this.handleForumTopicEdited(update);
@@ -10088,16 +11425,13 @@ export class TelegramNotificationDaemon {
10088
11425
  await this.handleTelegramUpdate(update);
10089
11426
  } catch (err) {
10090
11427
  logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
11428
+ return "retry";
10091
11429
  }
10092
11430
  return "consumed";
10093
11431
  }
10094
11432
 
10095
11433
  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.
11434
+ if (this.validationMode()) return;
10101
11435
  if ((await this.handleForumTopicCreatedUpdate(update)) !== "not-topic") return;
10102
11436
  if ((await this.handleForumTopicEdited(update)) !== "not-topic") return;
10103
11437
  // A raw path is accepted only after the explicit direct-entry choice. The exact
@@ -10259,7 +11593,17 @@ export class TelegramNotificationDaemon {
10259
11593
  // update_id dedupe are all enforced by decideThreadedInbound.
10260
11594
  const raw = update as {
10261
11595
  callback_query?: unknown;
10262
- message?: { text?: unknown; reply_to_message?: { message_id?: unknown } };
11596
+ message?: {
11597
+ text?: unknown;
11598
+ reply_to_message?: { message_id?: unknown };
11599
+ photo?: unknown;
11600
+ document?: unknown;
11601
+ video?: unknown;
11602
+ audio?: unknown;
11603
+ chat?: { id?: unknown };
11604
+ message_thread_id?: unknown;
11605
+ message_id?: unknown;
11606
+ };
10263
11607
  };
10264
11608
  // A reply to a known ask message routes to that ask (below). Any OTHER
10265
11609
  // message in a topic (plain text, or a reply to a non-ask message) is a
@@ -10269,6 +11613,23 @@ export class TelegramNotificationDaemon {
10269
11613
  typeof raw.message?.text === "string" ? parseBtwCommand(raw.message.text, this.botUsername) : undefined;
10270
11614
  const isAskReply =
10271
11615
  replyTo !== undefined && (this.messageRoutes.has(String(replyTo)) || this.messageRoutes.has(Number(replyTo)));
11616
+ if (
11617
+ reservedBtw?.kind === "question" &&
11618
+ (raw.message?.photo || raw.message?.document || raw.message?.video || raw.message?.audio) &&
11619
+ typeof raw.message?.message_thread_id === "number" &&
11620
+ typeof raw.message.message_id === "number" &&
11621
+ String(raw.message.chat?.id) === this.opts.chatId &&
11622
+ (await this.pairedChatIsPrivate())
11623
+ ) {
11624
+ if (!(await this.reserveSeenUpdateId((update as { update_id?: number }).update_id!))) return;
11625
+ await this.#sendBtwMessage({
11626
+ threadId: String(raw.message.message_thread_id),
11627
+ messageId: raw.message.message_id,
11628
+ text: BTW_USAGE_TEXT,
11629
+ isAuthoritative: () => true,
11630
+ });
11631
+ return;
11632
+ }
10272
11633
  const directControl =
10273
11634
  typeof raw.message?.text === "string"
10274
11635
  ? parseTelegramControlCommand(raw.message.text, this.botUsername)
@@ -10302,11 +11663,17 @@ export class TelegramNotificationDaemon {
10302
11663
  return undefined;
10303
11664
  return transportOwner.logicalSessionId;
10304
11665
  }
11666
+ if (
11667
+ reservedBtw?.kind === "question" &&
11668
+ (raw.message?.photo || raw.message?.document || raw.message?.video || raw.message?.audio)
11669
+ )
11670
+ return topicSessionId;
10305
11671
  return [...this.sessions.values()].some(
10306
11672
  session =>
10307
- session.sessionId === topicSessionId ||
10308
- this.#logicalSessionId(session) === topicSessionId ||
10309
- session.recoveryLease?.logicalSessionId === topicSessionId,
11673
+ (session.sessionId === topicSessionId ||
11674
+ this.#logicalSessionId(session) === topicSessionId ||
11675
+ session.recoveryLease?.logicalSessionId === topicSessionId) &&
11676
+ session.ws.readyState !== WebSocket.CLOSED,
10310
11677
  )
10311
11678
  ? undefined
10312
11679
  : topicSessionId;
@@ -10319,6 +11686,7 @@ export class TelegramNotificationDaemon {
10319
11686
  const preliminaryControl = inbound.attachment
10320
11687
  ? { kind: "none" as const }
10321
11688
  : parseTelegramControlCommand(inbound.text, this.botUsername);
11689
+ const reservedBtw = parseBtwCommand(inbound.text, this.botUsername);
10322
11690
  if (preliminaryControl.kind === "ignored") return;
10323
11691
  const session =
10324
11692
  this.logicalSessionOwners.get(inbound.sessionId) ??
@@ -10348,6 +11716,20 @@ export class TelegramNotificationDaemon {
10348
11716
  topicLeaseAllows();
10349
11717
  const routeLeaseAllows = (): boolean =>
10350
11718
  topicLeaseAllows() && (!session || (!!routeLease && this.#leaseTokenAllows(routeLease)));
11719
+ if (
11720
+ /^\/btw(?:\s|$)/i.test(inbound.text) &&
11721
+ inbound.attachment &&
11722
+ (!session || session.ws.readyState !== WebSocket.OPEN || !routeLease)
11723
+ ) {
11724
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
11725
+ await this.#sendBtwMessage({
11726
+ threadId: inbound.threadId,
11727
+ messageId: inbound.messageId,
11728
+ text: BTW_USAGE_TEXT,
11729
+ isAuthoritative: () => true,
11730
+ });
11731
+ return;
11732
+ }
10351
11733
  const reserveRouteUpdate = async (): Promise<boolean> => {
10352
11734
  if (!(await this.reserveSeenUpdateId(inbound.updateId))) return false;
10353
11735
  if (routeLeaseAllows()) return true;
@@ -10355,9 +11737,18 @@ export class TelegramNotificationDaemon {
10355
11737
  return false;
10356
11738
  };
10357
11739
 
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) {
11740
+ if (session && !routeLease && !(reservedBtw?.kind === "question" && inbound.attachment)) return;
11741
+ if (
11742
+ preliminaryControl.kind === "invalid" &&
11743
+ session?.ws.readyState !== WebSocket.OPEN &&
11744
+ !(reservedBtw?.kind === "question" && inbound.attachment)
11745
+ )
11746
+ return;
11747
+ if (
11748
+ preliminaryControl.kind === "command" &&
11749
+ session?.ws.readyState !== WebSocket.OPEN &&
11750
+ !(reservedBtw?.kind === "question" && inbound.attachment)
11751
+ ) {
10361
11752
  if (await reserveRouteUpdate()) {
10362
11753
  try {
10363
11754
  await this.botApi.call("sendMessage", {
@@ -10374,18 +11765,22 @@ export class TelegramNotificationDaemon {
10374
11765
  }
10375
11766
  return;
10376
11767
  }
10377
- const reservedBtw = parseBtwCommand(inbound.text, this.botUsername);
10378
11768
  if (reservedBtw?.kind === "ignored") {
10379
11769
  await this.rememberSeenUpdateId(inbound.updateId);
10380
11770
  return;
10381
11771
  }
10382
11772
  if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
10383
- if (!(await reserveRouteUpdate())) return;
11773
+ const reserved =
11774
+ session?.ws.readyState === WebSocket.OPEN
11775
+ ? await reserveRouteUpdate()
11776
+ : await this.reserveSeenUpdateId(inbound.updateId);
11777
+ if (!reserved) return;
11778
+ if (session?.ws.readyState !== WebSocket.OPEN) await this.flushPool();
10384
11779
  await this.#sendBtwMessage({
10385
11780
  threadId: inbound.threadId,
10386
11781
  messageId: inbound.messageId,
10387
11782
  text: BTW_USAGE_TEXT,
10388
- isAuthoritative: routeLeaseAllows,
11783
+ isAuthoritative: session?.ws.readyState === WebSocket.OPEN ? routeLeaseAllows : () => true,
10389
11784
  });
10390
11785
  return;
10391
11786
  }
@@ -10627,19 +12022,102 @@ export class TelegramNotificationDaemon {
10627
12022
  const callbackId = (update as { callback_query?: { id?: unknown } }).callback_query?.id;
10628
12023
  if (await this.#handleAdoptionPickerCallback(update, callbackId)) return;
10629
12024
  if (await this.#handleModelChoiceCallback(update, callbackId)) return;
12025
+ const callbackAlias = (update as { callback_query?: { data?: unknown } }).callback_query?.data;
12026
+ const stagedActivation =
12027
+ typeof callbackAlias === "string" ? this.#stagedCallbackActivations.get(callbackAlias) : undefined;
12028
+ if (stagedActivation && !(await stagedActivation.settled)) {
12029
+ await this.sendStaleGuidance(callbackId);
12030
+ return;
12031
+ }
10630
12032
  const decision = routeInboundUpdate(update, {
10631
12033
  aliasTable: this.aliasTable,
10632
12034
  messageRoutes: this.messageRoutes,
10633
12035
  pairedChatId: this.opts.chatId,
10634
12036
  });
10635
12037
  if (decision.kind === "reply") {
12038
+ const callbackLease =
12039
+ typeof callbackAlias === "string" ? this.#callbackAliasLeases.get(callbackAlias) : undefined;
12040
+ const callbackPending =
12041
+ typeof callbackAlias === "string" ? this.#callbackAliasPending.get(callbackAlias) : undefined;
12042
+ const callbackTopic =
12043
+ typeof callbackAlias === "string" ? this.#callbackAliasTopics.get(callbackAlias) : undefined;
10636
12044
  const session = this.logicalSessionOwners.get(decision.sessionId) ?? this.sessions.get(decision.sessionId);
12045
+ const pendingAction = session?.pending.get(decision.actionId);
10637
12046
  if (
12047
+ (typeof callbackAlias === "string" &&
12048
+ (!callbackLease ||
12049
+ !callbackPending ||
12050
+ callbackPending !== pendingAction ||
12051
+ !this.#leaseTokenAllows(callbackLease) ||
12052
+ (callbackTopic
12053
+ ? !this.topicLeaseIsCurrent(callbackTopic)
12054
+ : this.topics.get(decision.sessionId) !== undefined))) ||
10638
12055
  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))
12056
+ !pendingAction ||
12057
+ !this.#leaseAllows(session, decision.sessionId)
12058
+ ) {
12059
+ if (
12060
+ typeof callbackAlias === "string" &&
12061
+ callbackPending !== undefined &&
12062
+ callbackPending !== pendingAction &&
12063
+ this.#revokeCallbackAlias(callbackAlias)
12064
+ )
12065
+ await this.persistAliases();
12066
+ await this.sendStaleGuidance(callbackId);
12067
+ return;
12068
+ }
12069
+ const consumedAliases: Array<
12070
+ [
12071
+ string,
12072
+ CallbackRoute,
12073
+ { session: SessionSocket; token: number; logicalSessionId: string } | undefined,
12074
+ { sessionId: string; actionId: string } | undefined,
12075
+ TopicAuthorityLease | undefined,
12076
+ ]
12077
+ > = [];
12078
+ // Consume only controls from this exact rendered ask. Predecessor receipts
12079
+ // remain in the terminalization backlog until their authoritative outcome.
12080
+ for (const [alias, route] of this.aliasTable.entries()) {
12081
+ if (
12082
+ route.sessionId === decision.sessionId &&
12083
+ route.actionId === decision.actionId &&
12084
+ this.#callbackAliasPending.get(alias) === pendingAction
12085
+ ) {
12086
+ consumedAliases.push([
12087
+ alias,
12088
+ route,
12089
+ this.#callbackAliasLeases.get(alias),
12090
+ this.#callbackAliasPending.get(alias),
12091
+ this.#callbackAliasTopics.get(alias),
12092
+ ]);
12093
+ this.aliasTable.delete(alias);
12094
+ this.#callbackAliasLeases.delete(alias);
12095
+ this.#callbackAliasPending.delete(alias);
12096
+ this.#callbackAliasTopics.delete(alias);
12097
+ }
12098
+ }
12099
+ try {
12100
+ await this.persistAliases();
12101
+ } catch {
12102
+ for (const [alias, route, lease, pending, topic] of consumedAliases) {
12103
+ this.aliasTable.activate(alias, route);
12104
+ if (lease) this.#callbackAliasLeases.set(alias, lease);
12105
+ if (pending) this.#callbackAliasPending.set(alias, pending);
12106
+ if (topic) this.#callbackAliasTopics.set(alias, topic);
12107
+ }
12108
+ throw new Error("callback receipt consumption was not durably persisted");
12109
+ }
12110
+ if (
12111
+ session.ws.readyState !== WebSocket.OPEN ||
12112
+ session.pending.get(decision.actionId) !== pendingAction ||
12113
+ (typeof callbackAlias === "string" &&
12114
+ (!callbackPending ||
12115
+ callbackPending !== pendingAction ||
12116
+ !callbackLease ||
12117
+ !this.#leaseTokenAllows(callbackLease) ||
12118
+ (callbackTopic
12119
+ ? !this.topicLeaseIsCurrent(callbackTopic)
12120
+ : this.topics.get(decision.sessionId) !== undefined)))
10643
12121
  ) {
10644
12122
  await this.sendStaleGuidance(callbackId);
10645
12123
  return;
@@ -10694,6 +12172,12 @@ export class TelegramNotificationDaemon {
10694
12172
  // Runtime callers can bypass TypeScript's option type. Without a valid bot
10695
12173
  // token, there is no authenticated daemon identity or lifecycle authority.
10696
12174
  if (!validBotToken(this.opts.botToken)) return;
12175
+ if (this.validationMode()) {
12176
+ if (!/^-100\d+$/.test(this.opts.validationTestSupergroupChatId!))
12177
+ throw new Error("validation forum destination must be a negative -100... chat ID");
12178
+ if ((await this.resolvePairedChatPrivacy()) !== "validation-forum")
12179
+ throw new Error("validation forum destination must be the exact supergroup forum returned by getChat");
12180
+ }
10697
12181
  let ownershipProved = false;
10698
12182
  try {
10699
12183
  const renewed = await renewDaemonHeartbeat({
@@ -10713,19 +12197,22 @@ export class TelegramNotificationDaemon {
10713
12197
  if (!this.running) return;
10714
12198
  // Self-heal durable notification state before any scan/poll work so
10715
12199
  // `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))}`);
12200
+ if (!this.validationMode()) {
12201
+ try {
12202
+ await healTelegramDaemonNotificationState({
12203
+ settings: this.opts.settings,
12204
+ fs: this.fsImpl,
12205
+ now: this.opts.now,
12206
+ });
12207
+ } catch (error) {
12208
+ logger.warn(`notifications: startup self-heal failed: ${sanitizeDiagnostic(String(error))}`);
12209
+ }
10724
12210
  }
12211
+ await this.loadAliases();
10725
12212
  // Owner-only: start lifecycle control immediately after ownership proof,
10726
12213
  // before timers or pre-poll startup work can invalidate this run.
10727
12214
  // Best-effort; notification delivery remains available on failure.
10728
- await this.startLifecycleControl();
12215
+ if (!this.validationMode()) await this.startLifecycleControl();
10729
12216
  // A stop may arrive while lifecycle startup awaits its control endpoint.
10730
12217
  // Do not re-enable runtime work after that stop; close the partial server.
10731
12218
  if (!this.running) return;
@@ -10734,14 +12221,19 @@ export class TelegramNotificationDaemon {
10734
12221
  this.startFlushTimer();
10735
12222
  this.startScanTimer();
10736
12223
  this.startTypingTimer();
10737
- await this.refreshBotIdentity();
10738
- await this.registerBotCommands();
10739
- await this.loadAliases();
12224
+ if (!this.validationMode()) {
12225
+ await this.refreshBotIdentity();
12226
+ await this.registerBotCommands();
12227
+ }
10740
12228
  await this.loadTopics();
10741
- await this.loadAdoptionIntents();
10742
- this.startAdoptionSweepTimer();
10743
- await this.loadSeenUpdateIds();
10744
- await this.replyStore.load();
12229
+ if (!this.validationMode()) {
12230
+ await this.loadAdoptionIntents();
12231
+ this.startAdoptionSweepTimer();
12232
+ }
12233
+ if (!this.validationMode()) {
12234
+ await this.loadSeenUpdateIds();
12235
+ await this.replyStore.load();
12236
+ }
10745
12237
  await this.runScan();
10746
12238
  let idleSince = this.runtime.now();
10747
12239
  while (this.running) {
@@ -10807,37 +12299,43 @@ export class TelegramNotificationDaemon {
10807
12299
  }
10808
12300
  this.effects.beginShutdown();
10809
12301
  this.#deliveryAbort.abort();
12302
+ this.stopFlushTimer();
12303
+ this.stopScanTimer();
12304
+ this.stopTypingTimer();
12305
+ this.stopAdoptionSweepTimer();
12306
+ this.stopLifecycleControl();
10810
12307
  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));
12308
+ const sessionEffectsQuiesced = await this.effects.join(BTW_SHUTDOWN_JOIN_MS);
12309
+ let completed = false;
12310
+ if (sessionEffectsQuiesced) {
12311
+ const shutdown = this.effects.allowTerminal(async () => {
12312
+ if (toolShutdownError) throw toolShutdownError;
12313
+ await this.#drainBtwTurns();
12314
+ await this.toolTerminalizationChain;
12315
+ await this.cleanupAllAttachmentDirs();
12316
+ if (!this.validationMode()) {
12317
+ await this.persistAliases();
12318
+ await this.persistSeenUpdateIds();
12319
+ await this.opts.control?.clear?.(this.opts.ownerId);
12320
+ }
12321
+ await this.persistTopics();
12322
+ persisted = true;
12323
+ });
12324
+ const deadline = Promise.withResolvers<boolean>();
12325
+ const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
12326
+ completed = await Promise.race([
12327
+ shutdown.then(
12328
+ () => true,
12329
+ error => {
12330
+ logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
12331
+ return false;
12332
+ },
12333
+ ),
12334
+ deadline.promise,
12335
+ ]);
12336
+ clearTimeout(deadlineTimer);
12337
+ }
12338
+ const quiesced = completed && heartbeatJoined && sessionEffectsQuiesced;
10841
12339
  if (!quiesced || !persisted) {
10842
12340
  logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
10843
12341
  } else {