@gajae-code/coding-agent 0.11.5 → 0.11.7

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 (138) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/commands/daemon.d.ts +2 -2
  5. package/dist/types/commands/sdk.d.ts +20 -2
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +67 -7
  8. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  9. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
  10. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
  12. package/dist/types/main.d.ts +24 -1
  13. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  14. package/dist/types/modes/components/model-selector.d.ts +2 -0
  15. package/dist/types/modes/components/tool-status-header.d.ts +1 -0
  16. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  17. package/dist/types/modes/interactive-mode.d.ts +2 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -0
  21. package/dist/types/sdk/bus/index.d.ts +1 -1
  22. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  23. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  27. package/dist/types/sdk/client/discovery.d.ts +2 -0
  28. package/dist/types/sdk/client/liveness.d.ts +7 -0
  29. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  30. package/dist/types/sdk/transport/index.d.ts +12 -0
  31. package/dist/types/sdk/transport/relay.d.ts +43 -0
  32. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  33. package/dist/types/sdk/transport/socket.d.ts +7 -0
  34. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  35. package/dist/types/session/agent-session.d.ts +28 -5
  36. package/dist/types/session/btw-contract.d.ts +16 -0
  37. package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
  38. package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
  39. package/dist/types/session/session-manager.d.ts +35 -4
  40. package/dist/types/session/yield-queue.d.ts +8 -0
  41. package/dist/types/setup/credential-import.d.ts +5 -1
  42. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  43. package/dist/types/ssh/utils.d.ts +1 -0
  44. package/dist/types/task/discovery.d.ts +3 -1
  45. package/dist/types/tools/fetch.d.ts +5 -8
  46. package/dist/types/tools/image-gen.d.ts +16 -0
  47. package/dist/types/tools/skill-discovery.d.ts +7 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  49. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  50. package/dist/types/web/insane/bridge.d.ts +1 -0
  51. package/dist/types/web/insane/url-guard.d.ts +17 -0
  52. package/package.json +8 -8
  53. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  54. package/src/capability/index.ts +50 -42
  55. package/src/capability/types.ts +3 -0
  56. package/src/cli/setup-cli.ts +10 -4
  57. package/src/cli.ts +34 -9
  58. package/src/commands/daemon.ts +22 -5
  59. package/src/commands/sdk.ts +42 -9
  60. package/src/config/keybindings.ts +1 -1
  61. package/src/config/model-profiles.ts +18 -0
  62. package/src/config/settings-schema.ts +71 -7
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  64. package/src/discovery/ssh.ts +12 -1
  65. package/src/eval/py/kernel.ts +1 -21
  66. package/src/eval/py/runner-artifact.ts +84 -0
  67. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  68. package/src/gjc-runtime/ultragoal-guard.ts +88 -10
  69. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  70. package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
  71. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  72. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/internal-urls/local-protocol.ts +32 -6
  75. package/src/main.ts +87 -28
  76. package/src/modes/action-registry.ts +1 -1
  77. package/src/modes/components/btw-panel.ts +82 -16
  78. package/src/modes/components/model-selector.ts +31 -3
  79. package/src/modes/components/tool-status-header.ts +6 -1
  80. package/src/modes/controllers/btw-controller.ts +119 -36
  81. package/src/modes/controllers/command-controller.ts +15 -0
  82. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  83. package/src/modes/controllers/input-controller.ts +31 -0
  84. package/src/modes/controllers/selector-controller.ts +106 -12
  85. package/src/modes/interactive-mode.ts +16 -7
  86. package/src/modes/runtime-init.ts +16 -2
  87. package/src/modes/types.ts +1 -0
  88. package/src/prompts/system/btw-user.md +3 -8
  89. package/src/prompts/tools/skill-discovery.md +1 -0
  90. package/src/runtime-mcp/client.ts +41 -55
  91. package/src/runtime-mcp/content-limits.ts +71 -0
  92. package/src/runtime-mcp/json-rpc.ts +23 -9
  93. package/src/runtime-mcp/transports/http.ts +50 -16
  94. package/src/sdk/bus/chat-daemon-control.ts +20 -7
  95. package/src/sdk/bus/index.ts +8 -7
  96. package/src/sdk/bus/lifecycle-commands.ts +16 -7
  97. package/src/sdk/bus/notification-service.ts +41 -18
  98. package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
  99. package/src/sdk/bus/telegram-daemon-control.ts +26 -8
  100. package/src/sdk/bus/telegram-daemon.ts +1736 -338
  101. package/src/sdk/bus/topic-registry.ts +451 -12
  102. package/src/sdk/client/discovery.ts +19 -3
  103. package/src/sdk/client/liveness.ts +49 -0
  104. package/src/sdk/host/control/dispatch.ts +14 -5
  105. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  106. package/src/sdk/session.ts +29 -5
  107. package/src/sdk/transport/auth-preface.ts +82 -0
  108. package/src/sdk/transport/index.ts +18 -0
  109. package/src/sdk/transport/relay.ts +252 -0
  110. package/src/sdk/transport/serve-cli.ts +105 -0
  111. package/src/sdk/transport/socket.ts +197 -0
  112. package/src/sdk/transport/stdio.ts +17 -0
  113. package/src/session/agent-session.ts +1212 -768
  114. package/src/session/blob-store.ts +19 -6
  115. package/src/session/btw-contract.ts +59 -0
  116. package/src/session/internal/managed-session-scope.ts +313 -57
  117. package/src/session/internal/managed-session-storage.ts +146 -35
  118. package/src/session/session-manager.ts +385 -85
  119. package/src/session/yield-queue.ts +20 -0
  120. package/src/setup/credential-import.ts +28 -3
  121. package/src/setup/provider-onboarding.ts +30 -1
  122. package/src/setup/provider-presets.json +2 -1
  123. package/src/slash-commands/builtin-registry.ts +37 -1
  124. package/src/ssh/utils.ts +14 -0
  125. package/src/task/discovery.ts +8 -2
  126. package/src/tools/ask-answer-registry.ts +15 -1
  127. package/src/tools/ask.ts +1 -1
  128. package/src/tools/fetch.ts +13 -131
  129. package/src/tools/image-gen.ts +301 -20
  130. package/src/tools/read.ts +2 -0
  131. package/src/tools/skill-discovery.ts +39 -3
  132. package/src/tools/sqlite-reader.ts +63 -5
  133. package/src/utils/pasted-image-path.ts +15 -6
  134. package/src/utils/shell-snapshot.ts +173 -63
  135. package/src/web/insane/bridge.ts +5 -1
  136. package/src/web/insane/url-guard.ts +138 -27
  137. package/src/web/scrapers/types.ts +19 -49
  138. package/src/web/scrapers/utils.ts +12 -26
@@ -20,6 +20,7 @@ import {
20
20
  import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
21
21
  import {
22
22
  acquireDaemonTransitionLock,
23
+ classifyNotificationEndpoint,
23
24
  type DaemonTransitionLock,
24
25
  daemonTransitionLockIsHeld,
25
26
  exactUnlinkNotificationFile,
@@ -60,6 +61,7 @@ import {
60
61
  formatLifecycleOutcome,
61
62
  isLifecycleCommandLikeText,
62
63
  isLifecycleCommandText,
64
+ type LifecycleCommandVerb,
63
65
  lifecycleUsage,
64
66
  parseLifecycleCommand,
65
67
  validateLifecycleTarget,
@@ -95,7 +97,7 @@ import {
95
97
  } from "./telegram-reference";
96
98
  import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
97
99
  import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
98
- import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
100
+ import { type TopicEndpointBinding, TopicRegistry, type TopicRegistryState } from "./topic-registry";
99
101
 
100
102
  export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
101
103
  /** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */
@@ -221,6 +223,9 @@ const RATE_LIMIT_FLUSH_INTERVAL_MS = 1_000;
221
223
  // buffered ask is delivered up to 25s late — or never, if the user answers the
222
224
  // local ask first (which clears the buffered ask).
223
225
  const SESSION_SCAN_INTERVAL_MS = 1_000;
226
+ // Retry a session endpoint whose socket never completes its WebSocket handshake.
227
+ const CONNECTING_RECONNECT_MS = 1_000;
228
+
224
229
  // Transient Telegram API delivery is retried this many times before giving up.
225
230
  const BOT_API_RETRY_ATTEMPTS = 3;
226
231
  // Backoff after a failed getUpdates long-poll so a persistent outage does not
@@ -245,8 +250,13 @@ const BTW_USAGE_TEXT = "Usage: /btw <question>";
245
250
  const BTW_CAPACITY_TEXT = "Too many /btw questions are pending. Wait for one to finish and try again.";
246
251
  export const BTW_QUESTION_MAX_UNICODE_SCALARS = 4_096;
247
252
  export const BTW_QUESTION_MAX_UTF8_BYTES = 16_384;
253
+ const TELEGRAM_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
254
+ const TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT = 20;
255
+ const TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024;
256
+ const TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 60_000;
248
257
  const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar values and 16384 UTF-8 bytes.";
249
258
  type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
259
+ type TelegramFileDownload = { bytes: Buffer } | { failure: "download_failed" | "too_large" };
250
260
  class ThreadedModeCapabilityRefusal extends Error {}
251
261
 
252
262
  /** Only an explicit Bot API capability refusal may enable flat private-chat delivery. */
@@ -591,8 +601,15 @@ function ownershipLockMatchesState(lock: OwnershipLockRead, state: DaemonState |
591
601
  );
592
602
  }
593
603
 
594
- function ownershipLockMatchesStoppedState(lock: OwnershipLockRead, state: DaemonState | undefined): boolean {
595
- return Boolean(state && isExplicitlyStoppedDaemonState(state) && ownershipLockMatchesState(lock, state));
604
+ function ownershipLockMatchesStoppedState(lock: OwnershipLockRead, state: unknown): boolean {
605
+ if (isExplicitlyStoppedDaemonState(state)) return ownershipLockMatchesState(lock, state);
606
+ if (!isLegacyStoppedDaemonState(state) || lock.kind !== "legacy") return false;
607
+ const legacyState = state as Pick<DaemonState, "pid" | "startedAt"> & { stoppedAt: number };
608
+ return (
609
+ lock.metadata.pid === legacyState.pid &&
610
+ lock.metadata.startedAt <= legacyState.startedAt &&
611
+ legacyState.startedAt <= legacyState.stoppedAt
612
+ );
596
613
  }
597
614
 
598
615
  async function transitionLockIsHeldByCaller(input: {
@@ -854,7 +871,7 @@ function notificationRootForCwd(cwd: string): string {
854
871
  }
855
872
 
856
873
  function validBotToken(token: unknown): token is string {
857
- return typeof token === "string" && token.length > 0;
874
+ return typeof token === "string" && token.trim().length > 0;
858
875
  }
859
876
 
860
877
  function isExplicitlyStoppedDaemonState(state: unknown): state is DaemonState {
@@ -865,6 +882,39 @@ function isExplicitlyStoppedDaemonState(state: unknown): state is DaemonState {
865
882
  state.acquisitionId.length > 0,
866
883
  );
867
884
  }
885
+ /**
886
+ * v0.11.4 and earlier generations wrote stopped tombstones before acquisition
887
+ * and incarnation fields existed. A live, reused PID must not turn that durable
888
+ * stop marker into a foreign live owner during an upgrade.
889
+ */
890
+ function isLegacyStoppedDaemonState(state: unknown): boolean {
891
+ const candidate = state as Partial<DaemonState> | undefined;
892
+ return Boolean(
893
+ candidate &&
894
+ Number.isSafeInteger(candidate.pid) &&
895
+ (candidate.pid ?? 0) > 0 &&
896
+ typeof candidate.ownerId === "string" &&
897
+ candidate.ownerId.length > 0 &&
898
+ typeof candidate.tokenFingerprint === "string" &&
899
+ typeof candidate.chatId === "string" &&
900
+ Number.isSafeInteger(candidate.startedAt) &&
901
+ Number.isSafeInteger(candidate.heartbeatAt) &&
902
+ Number.isSafeInteger(candidate.stoppedAt) &&
903
+ (candidate.launcherPid === undefined ||
904
+ (Number.isSafeInteger(candidate.launcherPid) && (candidate.launcherPid ?? 0) > 0)) &&
905
+ Array.isArray(candidate.roots) &&
906
+ candidate.roots.every(root => typeof root === "string") &&
907
+ candidate.version === DAEMON_VERSION &&
908
+ isRecognizedLegacyGeneration(candidate.generation) &&
909
+ candidate.incarnation === undefined &&
910
+ candidate.acquisitionId === undefined &&
911
+ candidate.ownershipPhase === undefined,
912
+ );
913
+ }
914
+
915
+ function isStoppedDaemonState(state: unknown): boolean {
916
+ return isExplicitlyStoppedDaemonState(state) || isLegacyStoppedDaemonState(state);
917
+ }
868
918
 
869
919
  function ownerIdentityMatches(
870
920
  state: Pick<DaemonState, "tokenFingerprint" | "chatId">,
@@ -1016,8 +1066,8 @@ function classifyForeignLiveOwner(input: {
1016
1066
  // Validate before probing so malformed PIDs never reach the liveness source.
1017
1067
  if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
1018
1068
  // A stopped tombstone with a canonical acquisition is not a live owner.
1019
- if (isExplicitlyStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
1020
- const currentIncarnation = input.pidIncarnation?.(state.pid) ?? defaultPidIncarnation(state.pid);
1069
+ if (isStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
1070
+ const currentIncarnation = input.pidIncarnation ? input.pidIncarnation(state.pid) : defaultPidIncarnation(state.pid);
1021
1071
  if (
1022
1072
  !hasSafeDaemonStateShape(state) ||
1023
1073
  !isProcessIncarnation(state.incarnation) ||
@@ -1156,7 +1206,7 @@ export async function acquireDaemonOwnership(input: {
1156
1206
  if (state && !hasSafeDaemonStateShape(state)) {
1157
1207
  const malformed = state as Partial<DaemonState>;
1158
1208
  if (
1159
- !isExplicitlyStoppedDaemonState(malformed) &&
1209
+ !isStoppedDaemonState(malformed) &&
1160
1210
  validDaemonPid(malformed.pid) &&
1161
1211
  typeof malformed.incarnation === "string" &&
1162
1212
  pidAlive(malformed.pid)
@@ -1206,6 +1256,16 @@ export async function acquireDaemonOwnership(input: {
1206
1256
  return { acquired: false, attached: false, provisional: true };
1207
1257
  };
1208
1258
  const existing = await readJson<DaemonState>(fsImpl, paths.state);
1259
+ if (
1260
+ existing &&
1261
+ validDaemonPid(existing.pid) &&
1262
+ !ownerIdentityMatches(existing, input.tokenFingerprint, input.chatId) &&
1263
+ (!hasSafeDaemonStateShape(existing) ||
1264
+ !isProcessIncarnation(existing.incarnation) ||
1265
+ !isProcessIncarnation(pidIncarnation(existing.pid))) &&
1266
+ pidAlive(existing.pid)
1267
+ )
1268
+ return { acquired: false, attached: false, blocked: true };
1209
1269
  const foreignOwner = classifyForeignLiveOwner({
1210
1270
  state: existing,
1211
1271
  tokenFingerprint: input.tokenFingerprint,
@@ -1825,8 +1885,8 @@ function defaultPidAlive(pid: number): boolean {
1825
1885
  try {
1826
1886
  process.kill(pid, 0);
1827
1887
  return true;
1828
- } catch {
1829
- return false;
1888
+ } catch (error) {
1889
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
1830
1890
  }
1831
1891
  }
1832
1892
 
@@ -2010,6 +2070,135 @@ export async function spawnTelegramDaemonOwner(
2010
2070
  return { result: "owner_spawned", acquisition, runtime, warnings: [] };
2011
2071
  }
2012
2072
 
2073
+ /**
2074
+ * Owner-bound reclamation of a confirmed-dead daemon owner, mirroring the
2075
+ * daemon step of `gjc notify recovery`. It returns a structured, actionable
2076
+ * result and removes only identity-verified dead-owner artifacts while holding
2077
+ * the transition fence; live, successor, unknown, or unreadable evidence is
2078
+ * retained.
2079
+ */
2080
+ export type DeadOwnerRecoveryResult =
2081
+ | { recovered: true; reason: "cleared" }
2082
+ | {
2083
+ recovered: false;
2084
+ reason:
2085
+ | "not-confirmed-dead"
2086
+ | "unsafe-lock"
2087
+ | "transition-contended"
2088
+ | "owner-superseded"
2089
+ | "unsafe-endpoint"
2090
+ | "endpoint-changed"
2091
+ | "endpoint-directory-unreadable"
2092
+ | "lock-changed";
2093
+ };
2094
+
2095
+ /** Preflight cleanup for a confirmed-dead owner, with identity-bound removal. */
2096
+ export async function reclaimDeadDaemonOwner(input: {
2097
+ settings: Settings;
2098
+ endpointDir?: string;
2099
+ fs?: TelegramDaemonFs;
2100
+ now?: () => number;
2101
+ pidAlive?: (pid: number) => boolean;
2102
+ pidIncarnation?: (pid: number) => string | undefined;
2103
+ }): Promise<DeadOwnerRecoveryResult> {
2104
+ const fsImpl = input.fs ?? nodeFs;
2105
+ const now = input.now ?? Date.now;
2106
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
2107
+ const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
2108
+ const paths = daemonPaths(input.settings.getAgentDir());
2109
+ const state = await readDaemonState(input.settings, fsImpl);
2110
+ if (!state || !hasSafeDaemonStateShape(state) || pidAlive(state.pid))
2111
+ return { recovered: false, reason: "not-confirmed-dead" };
2112
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
2113
+ if (
2114
+ !(await ownershipLockIsReclaimable({ fs: fsImpl, path: paths.lock, lock, now: now(), pidAlive, pidIncarnation }))
2115
+ )
2116
+ return { recovered: false, reason: "unsafe-lock" };
2117
+ const transition = await acquireDaemonTransitionLock({
2118
+ fs: fsImpl,
2119
+ path: paths.steal,
2120
+ pid: process.pid,
2121
+ pidAlive: defaultPidAlive,
2122
+ pidIncarnation: defaultPidIncarnation,
2123
+ });
2124
+ const readEndpointFile = fsImpl.readEndpointFile;
2125
+ if (!transition || !readEndpointFile || !fsImpl.exactUnlink)
2126
+ return { recovered: false, reason: "transition-contended" };
2127
+ try {
2128
+ const current = await readDaemonState(input.settings, fsImpl);
2129
+ if (
2130
+ !current ||
2131
+ !hasSafeDaemonStateShape(current) ||
2132
+ current.ownerId !== state.ownerId ||
2133
+ current.acquisitionId !== state.acquisitionId ||
2134
+ current.pid !== state.pid ||
2135
+ current.incarnation !== state.incarnation ||
2136
+ current.generation !== state.generation ||
2137
+ current.ownershipPhase !== state.ownershipPhase ||
2138
+ current.tokenFingerprint !== state.tokenFingerprint ||
2139
+ current.chatId !== state.chatId ||
2140
+ pidAlive(current.pid)
2141
+ )
2142
+ return { recovered: false, reason: "owner-superseded" };
2143
+ const currentLock = await readOwnershipLock(fsImpl, paths.lock);
2144
+ if (
2145
+ !ownershipLockMatches(lock, currentLock) ||
2146
+ !(await ownershipLockIsReclaimable({
2147
+ fs: fsImpl,
2148
+ path: paths.lock,
2149
+ lock: currentLock,
2150
+ now: now(),
2151
+ pidAlive,
2152
+ pidIncarnation,
2153
+ })) ||
2154
+ !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
2155
+ )
2156
+ return { recovered: false, reason: "unsafe-lock" };
2157
+ const endpoints: Array<{ file: string; identity: NotificationEndpointFileIdentity }> = [];
2158
+ if (input.endpointDir) {
2159
+ let names: string[];
2160
+ try {
2161
+ names = await fsImpl.readdir(input.endpointDir);
2162
+ } catch (error) {
2163
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") names = [];
2164
+ else return { recovered: false, reason: "endpoint-directory-unreadable" };
2165
+ }
2166
+ for (const name of names) {
2167
+ if (!name.endsWith(".json")) continue;
2168
+ const file = path.join(input.endpointDir, name);
2169
+ const endpoint = await classifyNotificationEndpoint(
2170
+ { readEndpointFile: fsImpl.readEndpointFile! },
2171
+ file,
2172
+ pidAlive,
2173
+ );
2174
+ if (endpoint.kind === "non-endpoint") continue;
2175
+ if (endpoint.kind !== "endpoint" || endpoint.liveness !== "dead")
2176
+ return { recovered: false, reason: "unsafe-endpoint" };
2177
+ endpoints.push({ file, identity: endpoint.identity });
2178
+ }
2179
+ }
2180
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
2181
+ return { recovered: false, reason: "transition-contended" };
2182
+ for (const endpoint of endpoints)
2183
+ if (!(await fsImpl.exactUnlink(endpoint.file, endpoint.identity)).ok)
2184
+ return { recovered: false, reason: "endpoint-changed" };
2185
+ if (currentLock.kind === "missing") return { recovered: true, reason: "cleared" };
2186
+ const exactLock = await fsImpl.readEndpointFile!(paths.lock).catch(() => undefined);
2187
+ const exactCurrentLock = await readOwnershipLock(fsImpl, paths.lock);
2188
+ if (
2189
+ !exactLock ||
2190
+ !ownershipLockMatches(currentLock, exactCurrentLock) ||
2191
+ !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
2192
+ )
2193
+ return { recovered: false, reason: "lock-changed" };
2194
+ return (await fsImpl.exactUnlink(paths.lock, exactLock.identity)).ok
2195
+ ? { recovered: true, reason: "cleared" }
2196
+ : { recovered: false, reason: "lock-changed" };
2197
+ } finally {
2198
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
2199
+ }
2200
+ }
2201
+
2013
2202
  /**
2014
2203
  * Ensure a configured daemon owns this session root, preserving ownership safety
2015
2204
  * while exposing whether a #2028 generation handoff was required.
@@ -2022,6 +2211,24 @@ export async function ensureTelegramDaemonRunningDetailed(
2022
2211
  if (!isTelegramConfigured(cfg)) return "disabled";
2023
2212
  const root = notificationRootForCwd(input.cwd);
2024
2213
  const fp = tokenFingerprint(cfg.botToken);
2214
+ // Windows can retain dead launcher metadata without an ownership lock; reclaim
2215
+ // its dead discovery records before the replacement can publish a new owner.
2216
+ if ((deps.platform ?? process.platform) === "win32" && !deps.fs) {
2217
+ const preflight = await reclaimDeadDaemonOwner({
2218
+ settings: input.settings,
2219
+ endpointDir: path.join(root, "sdk"),
2220
+ fs: deps.fs,
2221
+ now: deps.now,
2222
+ pidAlive: deps.pidAlive,
2223
+ pidIncarnation: deps.pidIncarnation,
2224
+ });
2225
+ if (!preflight.recovered && preflight.reason !== "not-confirmed-dead") {
2226
+ logger.warn(
2227
+ `notifications: startup recovery unsafe (${preflight.reason}); run \`gjc notify recovery\` for diagnostics`,
2228
+ );
2229
+ return "blocked_identity";
2230
+ }
2231
+ }
2025
2232
  let spawned = await withTelegramSetupLease(
2026
2233
  cfg.botToken,
2027
2234
  async () =>
@@ -2063,8 +2270,31 @@ export async function ensureTelegramDaemonRunningDetailed(
2063
2270
  );
2064
2271
  }
2065
2272
  }
2273
+ let recoveryReason: Extract<DeadOwnerRecoveryResult, { recovered: false }>["reason"] | undefined;
2274
+ if (spawned.result === "blocked" && !spawned.warnings[0]?.includes("provisional")) {
2275
+ const recovery = await reclaimDeadDaemonOwner({
2276
+ settings: input.settings,
2277
+ endpointDir: path.join(root, "sdk"),
2278
+ fs: deps.fs,
2279
+ now: deps.now,
2280
+ pidAlive: deps.pidAlive,
2281
+ pidIncarnation: deps.pidIncarnation,
2282
+ });
2283
+ if (recovery.recovered) {
2284
+ spawned = await withTelegramSetupLease(
2285
+ cfg.botToken,
2286
+ async () =>
2287
+ await spawnTelegramDaemonOwner(
2288
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2289
+ deps,
2290
+ ),
2291
+ );
2292
+ } else recoveryReason = recovery.reason;
2293
+ }
2066
2294
  if (spawned.result === "blocked") {
2067
- logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
2295
+ logger.warn(
2296
+ `notifications: failed to ensure Telegram daemon: ${recoveryReason ? `stale recovery ${recoveryReason}; run \`gjc notify recovery\`` : spawned.warnings.join("; ")}`,
2297
+ );
2068
2298
  return "blocked_identity";
2069
2299
  }
2070
2300
  if (spawned.result === "attached" && spawned.reloadRequired) {
@@ -2411,7 +2641,10 @@ export class TelegramUpdatePoller {
2411
2641
  /** Mutable dispatch state shared by session frames and inbound Telegram updates. */
2412
2642
  export class TelegramEventDispatchState {
2413
2643
  readonly busy = new Set<string>();
2414
- readonly inboundReactions = new Map<number, { messageId: number }>();
2644
+ readonly inboundReactions = new Map<
2645
+ number,
2646
+ { messageId: number; socketLease?: { session: SessionSocket; token: number; logicalSessionId: string } }
2647
+ >();
2415
2648
  readonly seenUpdateIds = new Set<number>();
2416
2649
  }
2417
2650
 
@@ -2500,11 +2733,16 @@ interface SessionSocket {
2500
2733
  sessionId: string;
2501
2734
  /** Current logical session carried by valid threaded frames on this transport. */
2502
2735
  logicalSessionId: string;
2736
+ /** Endpoint metadata proved this logical id belongs to this discovery record. */
2737
+ logicalSessionIdTrusted: boolean;
2503
2738
  token: string;
2504
2739
  endpointKey: string;
2505
2740
  endpointDigest: string;
2506
2741
  hostGeneration: number;
2507
2742
  ws: WebSocket;
2743
+ /** Timestamp (via opts.now) at which this socket began connecting. */
2744
+ connectingSince: number;
2745
+
2508
2746
  pending: Map<string, { sessionId: string; actionId: string }>;
2509
2747
  /** True once the server advertised the `client_ping_pong` capability. */
2510
2748
  capable: boolean;
@@ -2520,11 +2758,20 @@ interface SessionSocket {
2520
2758
  /** Queues live frames until startup replay is applied. */
2521
2759
  replayPending: boolean;
2522
2760
  replayQueue: Record<string, unknown>[];
2761
+ /** Trusted recovery may not route until its durable endpoint lease is committed. */
2762
+ recoveryLease?: {
2763
+ state: "pending" | "authorized" | "rejected";
2764
+ logicalSessionId: string;
2765
+ binding: TopicEndpointBinding;
2766
+ token: number;
2767
+ };
2523
2768
  }
2524
2769
 
2525
2770
  interface ModelChoiceRoute {
2526
2771
  /** Exact transport socket that rendered this choice. */
2527
2772
  session: SessionSocket;
2773
+ /** Immutable owner lease captured when this choice was rendered. */
2774
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
2528
2775
  /** Logical session current when this choice was rendered. */
2529
2776
  sessionId: string;
2530
2777
  selector: string;
@@ -2555,7 +2802,8 @@ interface ToolActivityOwner {
2555
2802
  interface PendingThreadedFrame {
2556
2803
  send: ThreadedSend;
2557
2804
  msg: Record<string, unknown>;
2558
- endpointDigest: string;
2805
+ logicalSessionId: string;
2806
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
2559
2807
  toolActivity?: ToolActivityOwner;
2560
2808
  }
2561
2809
 
@@ -2580,6 +2828,8 @@ interface SelectedAckQueueItem {
2580
2828
  requestId: string;
2581
2829
  commitKey: string;
2582
2830
  session: SessionSocket;
2831
+ /** Immutable owner lease captured when this acknowledgement was admitted. */
2832
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
2583
2833
  state: "queued" | "dispatching" | "sending";
2584
2834
  controller?: AbortController;
2585
2835
  followers: Array<{ pendingKey: string; requestId: string; commitKey: string }>;
@@ -2588,6 +2838,8 @@ interface SelectedAckQueueItem {
2588
2838
  interface TelegramQueuePayload {
2589
2839
  send: ThreadedSend;
2590
2840
  topicLease?: TopicAuthorityLease;
2841
+ /** Immutable owner lease captured when the work was admitted. */
2842
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string };
2591
2843
  selectedAck?: SelectedAckQueueItem;
2592
2844
  btwDelivery?: BtwQueuedDelivery;
2593
2845
  toolActivity?: ToolActivityOwner;
@@ -2598,6 +2850,10 @@ interface PendingBtwTurn {
2598
2850
  transportSessionId: string;
2599
2851
  /** Logical session id supplied to the session endpoint. */
2600
2852
  logicalSessionId: string;
2853
+ /** Immutable committed owner lease captured when the inbound turn was admitted. */
2854
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
2855
+ /** Lease token to which this request was most recently dispatched. */
2856
+ dispatchedSocketLeaseToken?: number;
2601
2857
  endpointDigest: string;
2602
2858
  generation: number;
2603
2859
  question: string;
@@ -2700,12 +2956,22 @@ export class TelegramNotificationDaemon {
2700
2956
  private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
2701
2957
  private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
2702
2958
  private running = false;
2959
+ /** Once set, a concurrent startup await can never restore a running daemon. */
2960
+ private stopRequested = false;
2961
+
2703
2962
  private readonly fsImpl: TelegramDaemonFs;
2704
2963
  private readonly botApi: BotApi;
2705
2964
  private readonly effects = new TelegramEffectSupervisor();
2706
2965
  private readonly topics = new TopicRegistry();
2707
2966
  /** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
2967
+ /** Legacy sockets have no durable token, so explicit teardown is their revocation fence. */
2968
+ private readonly droppedSessions = new WeakSet<SessionSocket>();
2708
2969
  private topicsPersistQueue: Promise<void> = Promise.resolve();
2970
+ /** Serializes recovery compare/write/publish claims so competing endpoint migrations cannot durably diverge. */
2971
+ private recoveryBindingClaimQueue: Promise<void> = Promise.resolve();
2972
+ /** Durable compensation fences retry under supervision until persistence succeeds. */
2973
+ private readonly compensationFenceRetries = new Map<string, Promise<void>>();
2974
+
2709
2975
  /** Daemon edit attempts that can race an accepted user service message. */
2710
2976
  private readonly daemonRenameAttempts = new Map<string, number>();
2711
2977
  private readonly selectedAckPending = new Map<string, SelectedAckQueueItem>();
@@ -2727,10 +2993,17 @@ export class TelegramNotificationDaemon {
2727
2993
  private readonly draftStream = new DraftStreamState();
2728
2994
  /** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
2729
2995
  private readonly topicOwnerByIdentity = new Map<string, string>();
2996
+ /** Ephemeral legacy topic owners retained across a same-socket config rekey. */
2997
+ private readonly legacyTopicOwners = new Map<string, SessionSocket>();
2998
+ /** Preserved initiator topics must not route through a rekeyed transport. */
2999
+ private readonly preservedInitiatorTopics = new Set<string>();
2730
3000
  /** Non-identity frames held until identity creates the correct thread. */
2731
3001
  private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
2732
- /** Endpoint generation tombstones for sessions that already sent session_closed. */
2733
- private readonly closedEndpointKeys = new Map<string, string>();
3002
+ /** Durable endpoint leases for sessions that already sent an authorized session_closed. */
3003
+ private readonly closedEndpointKeys = new Map<string, TopicEndpointBinding>();
3004
+ /** Exactly one authorized transport may route each recovered logical session. */
3005
+ private readonly logicalSessionOwners = new Map<string, SessionSocket>();
3006
+ private nextSocketLeaseToken = 1;
2734
3007
  /** True once the daemon has nudged the user to enable Threaded Mode. */
2735
3008
  private threadedFallbackNoticeSent = false;
2736
3009
  /** Sessions whose identity header was already sent flat (Threaded Mode off). */
@@ -2744,7 +3017,10 @@ export class TelegramNotificationDaemon {
2744
3017
  return this.dispatchState.busy;
2745
3018
  }
2746
3019
  /** Inbound update id → originating Telegram message, for delivery reactions. */
2747
- private get inboundReactions(): Map<number, { messageId: number }> {
3020
+ private get inboundReactions(): Map<
3021
+ number,
3022
+ { messageId: number; socketLease?: { session: SessionSocket; token: number; logicalSessionId: string } }
3023
+ > {
2748
3024
  return this.dispatchState.inboundReactions;
2749
3025
  }
2750
3026
  /**
@@ -2780,7 +3056,7 @@ export class TelegramNotificationDaemon {
2780
3056
  this.selectedAckPending.delete(item.pendingKey);
2781
3057
  for (const follower of item.followers) this.selectedAckPending.delete(follower.pendingKey);
2782
3058
  this.cacheSelectedAck(item.cacheKey, outcome);
2783
- if (item.session.ws.readyState === WebSocket.OPEN) {
3059
+ if (this.#leaseTokenAllows(item.socketLease) && item.session.ws.readyState === WebSocket.OPEN) {
2784
3060
  for (const result of [{ requestId: item.requestId, commitKey: item.commitKey }, ...item.followers]) {
2785
3061
  item.session.ws.send(
2786
3062
  JSON.stringify({
@@ -2800,6 +3076,8 @@ export class TelegramNotificationDaemon {
2800
3076
  * ~25s getUpdates timeout. Safe to call from a signal handler.
2801
3077
  */
2802
3078
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
3079
+ this.stopRequested = true;
3080
+
2803
3081
  const toolShutdown = this.beginToolActivityShutdown();
2804
3082
  void toolShutdown
2805
3083
  .finally(() => {
@@ -3121,7 +3399,7 @@ export class TelegramNotificationDaemon {
3121
3399
 
3122
3400
  const frame = this.buildLifecycleFrame(parsed, updateId ?? Date.now());
3123
3401
  const response = await this.submitLifecycleFrame(frame);
3124
- await reply(this.formatLifecycleResponse(response));
3402
+ await reply(this.formatLifecycleResponse(response, verb));
3125
3403
  return true;
3126
3404
  }
3127
3405
 
@@ -3137,8 +3415,8 @@ export class TelegramNotificationDaemon {
3137
3415
  }
3138
3416
 
3139
3417
  /** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
3140
- private formatLifecycleResponse(r: SessionLifecycleResponse): string {
3141
- return formatLifecycleOutcome(r);
3418
+ private formatLifecycleResponse(r: SessionLifecycleResponse, verb: LifecycleCommandVerb): string {
3419
+ return formatLifecycleOutcome(r, verb);
3142
3420
  }
3143
3421
 
3144
3422
  constructor(private readonly opts: TelegramDaemonOptions) {
@@ -3220,10 +3498,14 @@ export class TelegramNotificationDaemon {
3220
3498
  finishImmediately({ status: "failed", reason: "route_missing" });
3221
3499
  return;
3222
3500
  }
3223
- const topicLease = this.topicAuthorityLeaseFromRegistry(session.sessionId);
3501
+ const logicalSessionId = this.#logicalSessionId(session);
3502
+ const socketLease = this.#socketLease(session, logicalSessionId);
3503
+ if (!socketLease) return;
3504
+ const topicLease = this.topicAuthorityLeaseFromRegistry(logicalSessionId);
3224
3505
  if (
3225
- this.topics.get(session.sessionId)?.authorityState === "delete_pending" ||
3226
- (mode === "recovery" && (!topicLease || msg.sessionId !== session.sessionId))
3506
+ this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
3507
+ this.topics.get(logicalSessionId)?.bindingMalformed ||
3508
+ (mode === "recovery" && (!topicLease || msg.sessionId !== logicalSessionId))
3227
3509
  ) {
3228
3510
  finishImmediately({ status: "failed", reason: "route_missing" });
3229
3511
  return;
@@ -3249,13 +3531,14 @@ export class TelegramNotificationDaemon {
3249
3531
  requestId,
3250
3532
  commitKey,
3251
3533
  session,
3534
+ socketLease,
3252
3535
  state: "queued",
3253
3536
  followers: [],
3254
3537
  };
3255
3538
  this.selectedAckPending.set(pendingKey, item);
3256
3539
  this.submitPool({
3257
- sessionId: session.sessionId,
3258
3540
  lane: "ask",
3541
+ sessionId: logicalSessionId,
3259
3542
  itemId: item.itemId,
3260
3543
  deadlineAt,
3261
3544
  payload: {
@@ -3332,34 +3615,117 @@ export class TelegramNotificationDaemon {
3332
3615
  name: "activity",
3333
3616
  matches: msg => msg.type === "activity",
3334
3617
  handle: async (session, msg) => {
3618
+ const logicalSessionId = this.#logicalSessionId(session);
3335
3619
  if (msg.state === "busy") {
3336
- this.busy.add(session.sessionId);
3337
- await this.sendTyping(session.sessionId);
3620
+ this.busy.add(logicalSessionId);
3621
+ await this.sendTyping(logicalSessionId, this.#socketLease(session, logicalSessionId));
3338
3622
  } else {
3339
- this.busy.delete(session.sessionId);
3623
+ this.busy.delete(logicalSessionId);
3340
3624
  }
3341
3625
  },
3342
3626
  })
3343
3627
  .add({
3344
3628
  name: "inbound_ack",
3345
3629
  matches: msg => msg.type === "inbound_ack" && typeof msg.updateId === "number",
3346
- handle: async (_session, msg) => {
3630
+ handle: async (session, msg) => {
3347
3631
  const target = this.inboundReactions.get(msg.updateId as number);
3348
3632
  if (target && msg.state === "consumed") {
3349
3633
  this.inboundReactions.delete(msg.updateId as number);
3350
- await this.setReaction(target.messageId, CONSUMED_REACTION);
3634
+ await this.setReaction(
3635
+ target.messageId,
3636
+ CONSUMED_REACTION,
3637
+ target.socketLease ?? this.#socketLease(session),
3638
+ );
3351
3639
  }
3352
3640
  },
3353
3641
  })
3354
3642
  .add({
3355
3643
  name: "session_closed",
3356
3644
  matches: msg => msg.type === "session_closed",
3357
- handle: async session => {
3358
- this.busy.delete(session.sessionId);
3359
- this.closedEndpointKeys.set(session.sessionId, session.endpointKey);
3645
+ handle: async (session, msg) => {
3646
+ const logicalSessionId = this.#logicalSessionId(session);
3647
+ if (
3648
+ (this.sessions.has(session.sessionId) && this.sessions.get(session.sessionId) !== session) ||
3649
+ typeof msg.sessionId !== "string" ||
3650
+ msg.sessionId !== logicalSessionId ||
3651
+ (session.logicalSessionIdTrusted && (!this.#leaseAllows(session) || session.hostGeneration < 1))
3652
+ )
3653
+ return;
3654
+ const socketLease = session.logicalSessionIdTrusted
3655
+ ? this.#socketLease(session, logicalSessionId)
3656
+ : undefined;
3657
+ if (session.logicalSessionIdTrusted && !socketLease) return;
3658
+ this.busy.delete(logicalSessionId);
3360
3659
  await this.#terminalizeBtwTurnsForSession(session, true);
3361
- await this.deleteTopic(session.sessionId);
3362
- this.dropSession(session, "session_closed");
3660
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
3661
+ await this.#withRecoveryBindingClaim(async () => {
3662
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
3663
+
3664
+ const closedBinding = this.#endpointBinding(session);
3665
+ const closeTopicAuthority = this.topics.captureDeleteAuthority(logicalSessionId);
3666
+ const previousClosedBinding = this.closedEndpointKeys.get(session.sessionId);
3667
+ await this.#persistTopicMutation(
3668
+ () => {
3669
+ this.closedEndpointKeys.set(session.sessionId, closedBinding);
3670
+ this.topics.beginDelete(logicalSessionId);
3671
+ },
3672
+ () => {
3673
+ this.topics.restoreDeleteAuthority(closeTopicAuthority);
3674
+ if (previousClosedBinding === undefined) this.closedEndpointKeys.delete(session.sessionId);
3675
+ else if (this.closedEndpointKeys.get(session.sessionId) === closedBinding)
3676
+ this.closedEndpointKeys.set(session.sessionId, previousClosedBinding);
3677
+ },
3678
+ );
3679
+ if (
3680
+ socketLease &&
3681
+ (this.sessions.get(session.sessionId) !== session ||
3682
+ session.recoveryLease?.token !== socketLease.token ||
3683
+ session.recoveryLease.state !== "authorized" ||
3684
+ this.logicalSessionOwners.get(logicalSessionId) !== session)
3685
+ ) {
3686
+ // A replacement won after the close fence committed. Restore the exact
3687
+ // pre-close authority and remove the predecessor tombstone together.
3688
+ const restoreCloseAuthority = (): Promise<boolean> =>
3689
+ this.#persistTopicMutation(
3690
+ () => {
3691
+ const restored = this.topics.restoreDeleteAuthority(closeTopicAuthority);
3692
+ if (!restored) throw new Error("close authority changed before compensation");
3693
+ this.closedEndpointKeys.delete(session.sessionId);
3694
+ return true;
3695
+ },
3696
+ () => {
3697
+ this.topics.restoreDeleteFence(closeTopicAuthority);
3698
+ this.closedEndpointKeys.set(session.sessionId, closedBinding);
3699
+ },
3700
+ );
3701
+ try {
3702
+ await restoreCloseAuthority();
3703
+ } catch {
3704
+ await restoreCloseAuthority();
3705
+ }
3706
+ return;
3707
+ }
3708
+ const deleteOutcome = await this.deleteTopic(logicalSessionId, socketLease, true);
3709
+ if (
3710
+ deleteOutcome === "pre_dispatch_cancelled" &&
3711
+ socketLease &&
3712
+ !this.#deleteLeaseAllows(socketLease)
3713
+ ) {
3714
+ await this.#persistTopicMutation(
3715
+ () => {
3716
+ if (!this.topics.restoreDeleteAuthority(closeTopicAuthority))
3717
+ throw new Error("close authority changed before compensation");
3718
+ this.closedEndpointKeys.delete(session.sessionId);
3719
+ },
3720
+ () => {
3721
+ this.topics.restoreDeleteFence(closeTopicAuthority);
3722
+ this.closedEndpointKeys.set(session.sessionId, closedBinding);
3723
+ },
3724
+ );
3725
+ return;
3726
+ }
3727
+ this.dropSession(session, "session_closed");
3728
+ });
3363
3729
  },
3364
3730
  });
3365
3731
  }
@@ -3440,7 +3806,27 @@ export class TelegramNotificationDaemon {
3440
3806
  return true;
3441
3807
  }
3442
3808
 
3809
+ private async releaseSeenUpdateId(updateId: number): Promise<void> {
3810
+ if (!this.dispatchState.seenUpdateIds.has(updateId)) return;
3811
+ const candidate = new Set(this.dispatchState.seenUpdateIds);
3812
+ candidate.delete(updateId);
3813
+ try {
3814
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
3815
+ await ensureDir(this.fsImpl, paths.dir);
3816
+ await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
3817
+ version: 1,
3818
+ updateIds: [...candidate],
3819
+ });
3820
+ } catch {
3821
+ logger.warn("notifications: Telegram update state release failed");
3822
+ return;
3823
+ }
3824
+ this.dispatchState.seenUpdateIds.clear();
3825
+ for (const seenId of candidate) this.dispatchState.seenUpdateIds.add(seenId);
3826
+ }
3827
+
3443
3828
  async scanRoots(): Promise<void> {
3829
+ await this.reconcilePendingTopicDeletes();
3444
3830
  const paths = daemonPaths(this.opts.settings.getAgentDir());
3445
3831
  const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
3446
3832
  const endpointSessionIds = new Set<string>();
@@ -3463,6 +3849,11 @@ export class TelegramNotificationDaemon {
3463
3849
  // A hard-killed owner can leave both its endpoint file and socket map
3464
3850
  // entry behind; skipping the read in that case permanently preserves
3465
3851
  // the stale Telegram topic.
3852
+ const owner = this.logicalSessionOwners.get(sessionId);
3853
+ if (owner && this.#leaseAllows(owner, sessionId)) {
3854
+ if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
3855
+ continue;
3856
+ }
3466
3857
  const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
3467
3858
  if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) {
3468
3859
  const connected = this.sessions.get(sessionId);
@@ -3471,22 +3862,36 @@ export class TelegramNotificationDaemon {
3471
3862
  await this.observeOrphanedTopic(sessionId);
3472
3863
  continue;
3473
3864
  }
3865
+
3474
3866
  if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
3475
3867
  const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
3476
3868
  const connected = this.sessions.get(sessionId);
3477
3869
  if (connected) {
3478
- if (connected.endpointKey !== endpointKey)
3870
+ if (
3871
+ connected.endpointKey !== endpointKey ||
3872
+ (connected.ws.readyState === WebSocket.CONNECTING &&
3873
+ this.runtime.now() - connected.connectingSince >= CONNECTING_RECONNECT_MS)
3874
+ )
3479
3875
  this.connectSession(sessionId, endpoint.url, endpoint.token);
3480
3876
  continue;
3481
3877
  }
3482
- if (this.closedEndpointKeys.get(sessionId) === endpointKey) continue;
3483
- this.closedEndpointKeys.delete(sessionId);
3878
+ const closed = this.closedEndpointKeys.get(sessionId);
3879
+ if (closed?.endpointKey === endpointKey) continue;
3880
+ if (closed) {
3881
+ this.closedEndpointKeys.delete(sessionId);
3882
+ await this.persistTopics();
3883
+ }
3484
3884
  this.connectSession(sessionId, endpoint.url, endpoint.token);
3485
3885
  } catch {}
3486
3886
  }
3487
3887
  }
3488
3888
  if (allRootsReadable) {
3489
3889
  for (const sessionId of this.topics.sessionIds()) {
3890
+ const owner = this.logicalSessionOwners.get(sessionId);
3891
+ if (owner && this.#leaseAllows(owner)) {
3892
+ if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
3893
+ continue;
3894
+ }
3490
3895
  if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId)) {
3491
3896
  this.#terminalizeBtwTurnsForTransportSession(sessionId);
3492
3897
  await this.observeOrphanedTopic(sessionId);
@@ -3500,7 +3905,6 @@ export class TelegramNotificationDaemon {
3500
3905
  const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
3501
3906
  const endpointKey = endpointGenerationKey(url, token);
3502
3907
  const endpointDigest = endpointAuthorityDigest(url, token);
3503
- this.closedEndpointKeys.delete(sessionId);
3504
3908
  const existing = this.sessions.get(sessionId);
3505
3909
  if (existing) {
3506
3910
  this.dropSession(
@@ -3515,12 +3919,13 @@ export class TelegramNotificationDaemon {
3515
3919
  const session: SessionSocket = {
3516
3920
  sessionId,
3517
3921
  logicalSessionId: sessionId,
3518
-
3922
+ logicalSessionIdTrusted: false,
3519
3923
  token,
3520
3924
  endpointKey,
3521
3925
  endpointDigest,
3522
3926
  hostGeneration: 0,
3523
3927
  ws,
3928
+ connectingSince: this.runtime.now(),
3524
3929
  pending: new Map(),
3525
3930
  capable: false,
3526
3931
  ephemeralCapable: false,
@@ -3532,13 +3937,20 @@ export class TelegramNotificationDaemon {
3532
3937
  replayQueue: [],
3533
3938
  };
3534
3939
  this.sessions.set(sessionId, session);
3940
+ if (this.topics.get(sessionId)) this.preservedInitiatorTopics.add(sessionId);
3941
+
3535
3942
  // Bidirectional capability advertisement: announce client_ping_pong once the
3536
3943
  // socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
3537
3944
  // while CONNECTING — and liveness starts only after a capable ServerHello.
3538
3945
  ws.addEventListener("open", () => {
3946
+ if (this.sessions.get(sessionId) !== session) return;
3539
3947
  session.replayPending = true;
3540
3948
  session.replayQueue = [];
3541
- const replayCursor = this.topics.replayCursor(sessionId);
3949
+ // Cursors are endpoint-authority scoped. Reusing a prior host's cursor can
3950
+ // skip its identity event when a fresh host restarts at generation 1.
3951
+ const persistedTopic = this.topics.get(sessionId);
3952
+ const replayCursor =
3953
+ persistedTopic?.endpointDigest === session.endpointDigest ? this.topics.replayCursor(sessionId) : undefined;
3542
3954
  if (session.ws.readyState === WebSocket.OPEN) {
3543
3955
  try {
3544
3956
  session.ws.send(
@@ -3566,12 +3978,20 @@ export class TelegramNotificationDaemon {
3566
3978
  );
3567
3979
  } catch {}
3568
3980
  }
3569
- // Eagerly create the session's Telegram topic as soon as it connects, so
3570
- // a thread exists the moment a notifications-enabled session is live —
3571
- // not lazily on the first delivered frame (which only arrives once the
3572
- // user sends a prompt). A provisional "GJC <id>" name is used; the
3573
- // identity_header frame renames it to "{repo}/{branch} - {title}" later.
3574
- void this.ensureTopic(sessionId, this.topicNameFor(sessionId, {})).catch(() => undefined);
3981
+ // Preserve the transport-session contract: ordinary endpoints own a topic
3982
+ // as soon as they connect. A later authenticated logical rekey is the only
3983
+ // path that may recover a different logical session's durable topic.
3984
+ void (async () => {
3985
+ if (this.#logicalSessionId(session) !== sessionId) return;
3986
+ const topicId = await this.ensureTopic(sessionId, this.topicNameFor(sessionId, {}), session);
3987
+ const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
3988
+ if (topicId && topicLease?.topicId === topicId)
3989
+ await this.flushPendingThreadedFrames(sessionId, topicLease);
3990
+ })().catch(err =>
3991
+ logger.warn(
3992
+ `notifications: Telegram initial topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
3993
+ ),
3994
+ );
3575
3995
  });
3576
3996
  ws.addEventListener("message", ev => {
3577
3997
  // Identity guard: a delayed frame from a superseded socket must not act
@@ -3746,7 +4166,12 @@ export class TelegramNotificationDaemon {
3746
4166
  }
3747
4167
 
3748
4168
  private dropSession(session: SessionSocket, reason: string): void {
4169
+ // A dropped socket must lose authority before any teardown-triggered asynchronous
4170
+ // work can observe it. Its immutable lease token remains captured by queued work,
4171
+ // but is no longer usable.
4172
+ if (session.recoveryLease) session.recoveryLease = { ...session.recoveryLease, state: "rejected" };
3749
4173
  const isCurrentSession = this.sessions.get(session.sessionId) === session;
4174
+ if (isCurrentSession) this.droppedSessions.add(session);
3750
4175
  if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
3751
4176
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
3752
4177
  if (session.pingTimer) {
@@ -3764,6 +4189,12 @@ export class TelegramNotificationDaemon {
3764
4189
  if (isCurrentSession) {
3765
4190
  this.#clearModelChoiceAliasesForSocket(session);
3766
4191
  this.sessions.delete(session.sessionId);
4192
+ for (const [topicSessionId, owner] of this.legacyTopicOwners) {
4193
+ if (owner === session) this.legacyTopicOwners.delete(topicSessionId);
4194
+ }
4195
+ for (const [logicalSessionId, owner] of this.logicalSessionOwners) {
4196
+ if (owner === session) this.logicalSessionOwners.delete(logicalSessionId);
4197
+ }
3767
4198
  }
3768
4199
 
3769
4200
  for (const item of new Set(this.selectedAckPending.values())) {
@@ -3841,6 +4272,7 @@ export class TelegramNotificationDaemon {
3841
4272
  void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
3842
4273
  }
3843
4274
  }
4275
+
3844
4276
  #resumeBtwTurnsForSession(session: SessionSocket): void {
3845
4277
  if (!session.ephemeralCapable || session.hostGeneration < 1 || session.ws.readyState !== WebSocket.OPEN) return;
3846
4278
  const logicalSessionId = this.#logicalSessionId(session);
@@ -3851,6 +4283,7 @@ export class TelegramNotificationDaemon {
3851
4283
  continue;
3852
4284
  }
3853
4285
  if (
4286
+ !this.#leaseTokenAllows(pending.socketLease) ||
3854
4287
  pending.transportSessionId !== session.sessionId ||
3855
4288
  pending.logicalSessionId !== logicalSessionId ||
3856
4289
  pending.endpointDigest !== session.endpointDigest ||
@@ -3861,6 +4294,9 @@ export class TelegramNotificationDaemon {
3861
4294
  }
3862
4295
  }
3863
4296
  #sendPendingBtwTurn(session: SessionSocket, requestId: string, pending: PendingBtwTurn): boolean {
4297
+ if (!this.#leaseTokenAllows(pending.socketLease) || this.sessions.get(session.sessionId) !== session)
4298
+ return false;
4299
+ if (pending.dispatchedSocketLeaseToken === pending.socketLease.token) return true;
3864
4300
  try {
3865
4301
  session.ws.send(
3866
4302
  JSON.stringify({
@@ -3874,6 +4310,7 @@ export class TelegramNotificationDaemon {
3874
4310
  messageId: pending.messageId,
3875
4311
  }),
3876
4312
  );
4313
+ pending.dispatchedSocketLeaseToken = pending.socketLease.token;
3877
4314
  return true;
3878
4315
  } catch {
3879
4316
  return false;
@@ -3931,8 +4368,14 @@ export class TelegramNotificationDaemon {
3931
4368
  parseMode?: typeof TELEGRAM_PARSE_MODE;
3932
4369
  allowWhileStopping?: boolean;
3933
4370
  signal?: AbortSignal;
4371
+ isAuthoritative?: () => boolean;
3934
4372
  }): Promise<unknown> {
3935
- if ((!input.allowWhileStopping && this.#stoppingBtw) || this.#btwDeliveryAbort.signal.aborted) return undefined;
4373
+ if (
4374
+ (!input.allowWhileStopping && this.#stoppingBtw) ||
4375
+ this.#btwDeliveryAbort.signal.aborted ||
4376
+ (input.isAuthoritative && !input.isAuthoritative())
4377
+ )
4378
+ return undefined;
3936
4379
  const signals = [this.#btwDeliveryAbort.signal, AbortSignal.timeout(30_000)];
3937
4380
  if (input.signal) signals.unshift(input.signal);
3938
4381
  return this.botApi.call(
@@ -4065,12 +4508,149 @@ export class TelegramNotificationDaemon {
4065
4508
  return session.logicalSessionId ?? session.sessionId;
4066
4509
  }
4067
4510
 
4511
+ #leaseAllows(session: SessionSocket, logicalSessionId = this.#logicalSessionId(session)): boolean {
4512
+ if (this.droppedSessions.has(session)) return false;
4513
+ const closedBinding = this.closedEndpointKeys.get(session.sessionId);
4514
+ if (
4515
+ closedBinding &&
4516
+ typeof session.endpointKey === "string" &&
4517
+ typeof session.endpointDigest === "string" &&
4518
+ closedBinding.endpointKey === session.endpointKey &&
4519
+ closedBinding.endpointDigest === session.endpointDigest &&
4520
+ closedBinding.endpointGeneration === session.hostGeneration
4521
+ )
4522
+ return false;
4523
+ if (!session.logicalSessionIdTrusted) return true;
4524
+ // Trusted leases require both the current transport socket and exact logical owner.
4525
+ if (this.sessions.get(session.sessionId) !== session) return false;
4526
+ const lease = session.recoveryLease;
4527
+ if (lease?.state !== "authorized" || lease.logicalSessionId !== logicalSessionId) return false;
4528
+ if (this.logicalSessionOwners.get(logicalSessionId) !== session) return false;
4529
+ const record = this.topics.get(logicalSessionId);
4530
+ return (
4531
+ lease.binding.endpointKey === session.endpointKey &&
4532
+ lease.binding.endpointDigest === session.endpointDigest &&
4533
+ lease.binding.endpointGeneration === session.hostGeneration &&
4534
+ (!record ||
4535
+ (record.authorityState !== "delete_pending" &&
4536
+ !record.bindingMalformed &&
4537
+ record.chatId === lease.binding.chatId &&
4538
+ record.endpointKey === lease.binding.endpointKey &&
4539
+ record.endpointDigest === lease.binding.endpointDigest &&
4540
+ record.endpointGeneration === lease.binding.endpointGeneration))
4541
+ );
4542
+ }
4543
+
4544
+ #leaseTokenAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
4545
+ return socketLease.token === 0
4546
+ ? !socketLease.session.logicalSessionIdTrusted && !this.droppedSessions.has(socketLease.session)
4547
+ : this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
4548
+ socketLease.session.recoveryLease?.token === socketLease.token &&
4549
+ this.#leaseAllows(socketLease.session, socketLease.logicalSessionId);
4550
+ }
4551
+
4552
+ #deleteLeaseAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
4553
+ if (socketLease.token === 0)
4554
+ return (
4555
+ this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
4556
+ !this.droppedSessions.has(socketLease.session)
4557
+ );
4558
+ return (
4559
+ this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
4560
+ socketLease.session.recoveryLease?.token === socketLease.token &&
4561
+ socketLease.session.recoveryLease.state === "authorized" &&
4562
+ socketLease.session.recoveryLease.logicalSessionId === socketLease.logicalSessionId &&
4563
+ this.logicalSessionOwners.get(socketLease.logicalSessionId) === socketLease.session
4564
+ );
4565
+ }
4566
+
4567
+ /**
4568
+ * An eager transport-topic create may complete after authenticated replay
4569
+ * authorizes that exact transport as its own logical session. Preserve that
4570
+ * handoff, but never revive a replaced socket or a rekeyed transport.
4571
+ */
4572
+ #isEagerCreationHandoff(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
4573
+ const { session, logicalSessionId } = socketLease;
4574
+ const lease = session.recoveryLease;
4575
+ return (
4576
+ socketLease.token === 0 &&
4577
+ logicalSessionId === session.sessionId &&
4578
+ session.logicalSessionIdTrusted &&
4579
+ this.sessions.get(session.sessionId) === session &&
4580
+ lease?.state === "authorized" &&
4581
+ lease.logicalSessionId === logicalSessionId &&
4582
+ this.logicalSessionOwners.get(logicalSessionId) === session &&
4583
+ lease.binding.chatId === String(this.opts.chatId) &&
4584
+ lease.binding.endpointKey === session.endpointKey &&
4585
+ lease.binding.endpointDigest === session.endpointDigest &&
4586
+ lease.binding.endpointGeneration === session.hostGeneration
4587
+ );
4588
+ }
4589
+
4590
+ #creationLeaseAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
4591
+ return this.#leaseTokenAllows(socketLease) || this.#isEagerCreationHandoff(socketLease);
4592
+ }
4593
+
4594
+ async #awaitCreationLeaseAuthority(socketLease: {
4595
+ session: SessionSocket;
4596
+ token: number;
4597
+ logicalSessionId: string;
4598
+ }): Promise<boolean> {
4599
+ if (this.#creationLeaseAllows(socketLease)) return true;
4600
+ if (
4601
+ socketLease.token === 0 &&
4602
+ socketLease.session.logicalSessionIdTrusted &&
4603
+ socketLease.session.recoveryLease?.state === "pending" &&
4604
+ this.sessions.get(socketLease.session.sessionId) === socketLease.session
4605
+ )
4606
+ await this.recoveryBindingClaimQueue;
4607
+ return this.#creationLeaseAllows(socketLease);
4608
+ }
4609
+ #socketLease(
4610
+ session: SessionSocket,
4611
+ logicalSessionId = this.#logicalSessionId(session),
4612
+ ): { session: SessionSocket; token: number; logicalSessionId: string } | undefined {
4613
+ const lease = session.recoveryLease;
4614
+ return lease && this.#leaseAllows(session, logicalSessionId)
4615
+ ? { session, token: lease.token, logicalSessionId }
4616
+ : session.logicalSessionIdTrusted
4617
+ ? undefined
4618
+ : { session, token: 0, logicalSessionId };
4619
+ }
4620
+
4621
+ #authorizeLease(session: SessionSocket, logicalSessionId: string, binding: TopicEndpointBinding): void {
4622
+ const previousSessionId = this.#logicalSessionId(session);
4623
+ if (previousSessionId !== logicalSessionId && this.logicalSessionOwners.get(previousSessionId) === session)
4624
+ this.logicalSessionOwners.delete(previousSessionId);
4625
+ const previousOwner = this.logicalSessionOwners.get(logicalSessionId);
4626
+ if (previousOwner && previousOwner !== session && previousOwner.recoveryLease)
4627
+ previousOwner.recoveryLease = { ...previousOwner.recoveryLease, state: "rejected" };
4628
+ this.logicalSessionOwners.set(logicalSessionId, session);
4629
+ this.preservedInitiatorTopics.delete(logicalSessionId);
4630
+ session.logicalSessionId = logicalSessionId;
4631
+ session.recoveryLease = { state: "authorized", logicalSessionId, binding, token: this.nextSocketLeaseToken++ };
4632
+ }
4633
+
4068
4634
  #clearModelChoiceAliases(sessionId: string): void {
4069
4635
  for (const [alias, route] of this.#modelChoiceAliases) {
4070
4636
  if (route.sessionId === sessionId) this.#modelChoiceAliases.delete(alias);
4071
4637
  }
4072
4638
  }
4073
4639
 
4640
+ async #revokeAskAuthority(sessionId: string): Promise<void> {
4641
+ for (const [alias, route] of this.aliasTable.entries()) {
4642
+ if (route.sessionId === sessionId) this.aliasTable.delete(alias);
4643
+ }
4644
+ for (const session of this.sessions.values()) {
4645
+ if (session.sessionId === sessionId || this.#logicalSessionId(session) === sessionId) {
4646
+ for (const [actionId, pending] of session.pending) {
4647
+ if (pending.sessionId === sessionId) session.pending.delete(actionId);
4648
+ }
4649
+ }
4650
+ }
4651
+ await this.persistAliases();
4652
+ }
4653
+
4074
4654
  #clearModelChoiceAliasesForSocket(session: SessionSocket): void {
4075
4655
  for (const [alias, route] of this.#modelChoiceAliases) {
4076
4656
  if (route.session === session) this.#modelChoiceAliases.delete(alias);
@@ -4114,8 +4694,8 @@ export class TelegramNotificationDaemon {
4114
4694
  "reasoning_summary",
4115
4695
  ]);
4116
4696
 
4117
- /** Rekey model controls before a valid server-threaded frame can render or route output. */
4118
- #updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): void {
4697
+ /** Rekey only after authenticated replay, except legacy config updates which are transport-local. */
4698
+ async #updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): Promise<void> {
4119
4699
  if (
4120
4700
  typeof msg.type !== "string" ||
4121
4701
  !TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type) ||
@@ -4125,10 +4705,187 @@ export class TelegramNotificationDaemon {
4125
4705
  (msg.type !== "config_update" && !renderThreadedFrame(msg))
4126
4706
  )
4127
4707
  return;
4128
- void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
4129
- this.#clearModelChoiceAliasesForSocket(session);
4130
- this.#clearModelChoiceAliases(msg.sessionId);
4131
- session.logicalSessionId = msg.sessionId;
4708
+ if (!session.logicalSessionIdTrusted) return;
4709
+ await this.#recoverTopicBinding(session, msg.sessionId, msg.type === "config_update");
4710
+ }
4711
+
4712
+ #endpointBinding(session: SessionSocket): TopicEndpointBinding {
4713
+ return {
4714
+ chatId: String(this.opts.chatId),
4715
+ endpointKey: session.endpointKey,
4716
+ endpointDigest: session.endpointDigest,
4717
+ endpointGeneration: session.hostGeneration,
4718
+ };
4719
+ }
4720
+
4721
+ #endpointAuthority(binding: TopicEndpointBinding, excludedSession?: SessionSocket) {
4722
+ const tombstoned = [...this.closedEndpointKeys.values()].some(
4723
+ closed =>
4724
+ closed.chatId === binding.chatId &&
4725
+ closed.endpointKey === binding.endpointKey &&
4726
+ closed.endpointDigest === binding.endpointDigest,
4727
+ );
4728
+ if (tombstoned) return { state: "ambiguous" as const };
4729
+ const authority = this.topics.endpointAuthority(binding, excludedSession);
4730
+ const competingLiveClaim = [...this.sessions.values()].some(
4731
+ session =>
4732
+ session !== excludedSession &&
4733
+ session.endpointKey === binding.endpointKey &&
4734
+ session.endpointDigest === binding.endpointDigest,
4735
+ );
4736
+ const competingRecoveryClaim = [...this.sessions.values()].some(
4737
+ session =>
4738
+ session !== excludedSession &&
4739
+ session.recoveryLease?.state === "pending" &&
4740
+ session.recoveryLease.binding.chatId === binding.chatId &&
4741
+ session.recoveryLease.binding.endpointKey === binding.endpointKey &&
4742
+ session.recoveryLease.binding.endpointDigest === binding.endpointDigest,
4743
+ );
4744
+ return competingLiveClaim || competingRecoveryClaim ? { state: "ambiguous" as const } : authority;
4745
+ }
4746
+
4747
+ #ownsLiveOpenEndpoint(session: SessionSocket, binding: TopicEndpointBinding): boolean {
4748
+ return (
4749
+ this.sessions.get(session.sessionId) === session &&
4750
+ session.ws.readyState === WebSocket.OPEN &&
4751
+ binding.endpointKey === session.endpointKey &&
4752
+ binding.endpointDigest === session.endpointDigest &&
4753
+ binding.endpointGeneration === session.hostGeneration
4754
+ );
4755
+ }
4756
+
4757
+ #activeEndpointKeysFor(logicalSessionId: string, claimant: SessionSocket): Set<string> {
4758
+ const keys = new Set<string>();
4759
+ for (const session of this.sessions.values()) {
4760
+ if (
4761
+ session !== claimant &&
4762
+ this.#logicalSessionId(session) === logicalSessionId &&
4763
+ this.#leaseAllows(session, logicalSessionId)
4764
+ )
4765
+ keys.add(session.endpointKey);
4766
+ }
4767
+ return keys;
4768
+ }
4769
+
4770
+ #withRecoveryBindingClaim<T>(claim: () => Promise<T>): Promise<T> {
4771
+ const pending = this.recoveryBindingClaimQueue.then(claim, claim);
4772
+ this.recoveryBindingClaimQueue = pending.then(
4773
+ () => undefined,
4774
+ () => undefined,
4775
+ );
4776
+ return pending;
4777
+ }
4778
+
4779
+ async #recoverTopicBinding(
4780
+ session: SessionSocket,
4781
+ candidateSessionId = this.#logicalSessionId(session),
4782
+ preserveTransportTopic = false,
4783
+ allowEndpointRotation = false,
4784
+ identitylessAdmission: "bootstrap" | "resume" | undefined = undefined,
4785
+ ): Promise<boolean> {
4786
+ if (!session.logicalSessionIdTrusted) return false;
4787
+ const binding = this.#endpointBinding(session);
4788
+ const pendingToken = this.nextSocketLeaseToken++;
4789
+ session.recoveryLease = { state: "pending", logicalSessionId: candidateSessionId, binding, token: pendingToken };
4790
+ const claim = await this.#withRecoveryBindingClaim(async () => {
4791
+ const existing = this.topics.get(candidateSessionId);
4792
+ const hadDurableTopic = existing !== undefined;
4793
+ const previousBinding = existing
4794
+ ? {
4795
+ chatId: existing.chatId,
4796
+ endpointKey: existing.endpointKey,
4797
+ endpointDigest: existing.endpointDigest,
4798
+ endpointGeneration: existing.endpointGeneration,
4799
+ endpointIncarnation: existing.endpointIncarnation,
4800
+ }
4801
+ : undefined;
4802
+ const outcome = await this.#persistTopicMutation(
4803
+ () =>
4804
+ this.topics.get(candidateSessionId)
4805
+ ? this.topics.bindEndpoint(
4806
+ candidateSessionId,
4807
+ binding,
4808
+ this.#activeEndpointKeysFor(candidateSessionId, session),
4809
+ allowEndpointRotation,
4810
+ )
4811
+ : "unchanged",
4812
+ () => {
4813
+ if (previousBinding) this.topics.restoreEndpointBinding(candidateSessionId, binding, previousBinding);
4814
+ },
4815
+ ).catch(() => "rejected" as const);
4816
+ if (outcome === "rejected") {
4817
+ if (session.recoveryLease?.token === pendingToken)
4818
+ session.recoveryLease = {
4819
+ state: "rejected",
4820
+ logicalSessionId: candidateSessionId,
4821
+ binding,
4822
+ token: pendingToken,
4823
+ };
4824
+ return undefined;
4825
+ }
4826
+ const endpointAuthority = this.#endpointAuthority(binding, session);
4827
+ const identitylessAdmissionAllows =
4828
+ identitylessAdmission === undefined ||
4829
+ (identitylessAdmission === "bootstrap"
4830
+ ? (endpointAuthority.state === "none" && !this.topics.get(candidateSessionId)) ||
4831
+ (endpointAuthority.state === "unique" &&
4832
+ endpointAuthority.sessionId === candidateSessionId &&
4833
+ this.topics.matchesEndpoint(candidateSessionId, binding))
4834
+ : endpointAuthority.state === "unique" &&
4835
+ endpointAuthority.sessionId === candidateSessionId &&
4836
+ this.topics.matchesEndpoint(candidateSessionId, binding));
4837
+ if (
4838
+ session.recoveryLease?.token !== pendingToken ||
4839
+ session.recoveryLease.state !== "pending" ||
4840
+ !this.#ownsLiveOpenEndpoint(session, binding) ||
4841
+ !identitylessAdmissionAllows
4842
+ ) {
4843
+ if (session.recoveryLease?.token === pendingToken)
4844
+ session.recoveryLease = {
4845
+ state: "rejected",
4846
+ logicalSessionId: candidateSessionId,
4847
+ binding,
4848
+ token: pendingToken,
4849
+ };
4850
+ return undefined;
4851
+ }
4852
+ const previousSessionId = this.#logicalSessionId(session);
4853
+ if (previousSessionId !== candidateSessionId) await this.#terminalizeBtwTurnsForSession(session, true);
4854
+ if (!this.#ownsLiveOpenEndpoint(session, binding)) {
4855
+ if (session.recoveryLease?.token === pendingToken)
4856
+ session.recoveryLease = {
4857
+ state: "rejected",
4858
+ logicalSessionId: candidateSessionId,
4859
+ binding,
4860
+ token: pendingToken,
4861
+ };
4862
+ return undefined;
4863
+ }
4864
+ this.#authorizeLease(session, candidateSessionId, binding);
4865
+ return { previousSessionId, hadDurableTopic };
4866
+ });
4867
+ if (!claim) return false;
4868
+ const { previousSessionId } = claim;
4869
+ if (preserveTransportTopic && previousSessionId !== candidateSessionId) {
4870
+ this.legacyTopicOwners.set(previousSessionId, session);
4871
+ this.preservedInitiatorTopics.add(previousSessionId);
4872
+ }
4873
+ if (candidateSessionId === session.sessionId && !this.topics.get(candidateSessionId))
4874
+ void (async () => {
4875
+ const topicId = await this.ensureTopic(
4876
+ candidateSessionId,
4877
+ this.topicNameFor(candidateSessionId, {}),
4878
+ session,
4879
+ );
4880
+ const topicLease = this.topicAuthorityLeaseFromRegistry(candidateSessionId);
4881
+ if (topicId && topicLease?.topicId === topicId)
4882
+ await this.flushPendingThreadedFrames(candidateSessionId, topicLease);
4883
+ })().catch(err =>
4884
+ logger.warn(
4885
+ `notifications: Telegram recovered topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
4886
+ ),
4887
+ );
4888
+ return true;
4132
4889
  }
4133
4890
 
4134
4891
  private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
@@ -4210,13 +4967,6 @@ export class TelegramNotificationDaemon {
4210
4967
  return undefined;
4211
4968
  }
4212
4969
 
4213
- private sessionCanClaimIdentity(session: SessionSocket, msg: { repo?: unknown; branch?: unknown }): boolean {
4214
- const current = this.sessions.get(session.sessionId);
4215
- if (current) return current === session;
4216
- const ownerId = this.topicOwnerForIdentity(msg);
4217
- return !ownerId || ownerId === session.sessionId;
4218
- }
4219
-
4220
4970
  private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
4221
4971
  if (msg.type !== "tool_activity") return undefined;
4222
4972
  const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
@@ -4224,7 +4974,7 @@ export class TelegramNotificationDaemon {
4224
4974
  const phase = typeof msg.phase === "string" ? msg.phase : undefined;
4225
4975
  if (!toolCallId || !toolName || !phase) return undefined;
4226
4976
  return {
4227
- sessionId: session.sessionId,
4977
+ sessionId: this.#logicalSessionId(session),
4228
4978
  toolCallId,
4229
4979
  toolName,
4230
4980
  endpointDigest: session.endpointDigest,
@@ -4235,7 +4985,8 @@ export class TelegramNotificationDaemon {
4235
4985
 
4236
4986
  private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
4237
4987
  if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
4238
- const session = this.sessions.get(toolActivity.sessionId);
4988
+ const session =
4989
+ this.logicalSessionOwners.get(toolActivity.sessionId) ?? this.sessions.get(toolActivity.sessionId);
4239
4990
  if (toolActivity.endpointDigest === undefined) return session === undefined;
4240
4991
  return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
4241
4992
  }
@@ -4244,12 +4995,18 @@ export class TelegramNotificationDaemon {
4244
4995
  send: ThreadedSend,
4245
4996
  topicLease: TopicAuthorityLease,
4246
4997
  toolActivity?: ToolActivityOwner,
4998
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
4247
4999
  ): Promise<void> {
4248
5000
  this.submitPool({
4249
5001
  sessionId,
4250
5002
  lane: send.lane,
4251
5003
  coalesceKey: send.coalesceKey,
4252
- payload: { send, topicLease, ...(toolActivity ? { toolActivity } : {}) },
5004
+ payload: {
5005
+ send,
5006
+ topicLease,
5007
+ ...(socketLease ? { socketLease } : {}),
5008
+ ...(toolActivity ? { toolActivity } : {}),
5009
+ },
4253
5010
  });
4254
5011
  await this.flushPool();
4255
5012
  }
@@ -4265,14 +5022,15 @@ export class TelegramNotificationDaemon {
4265
5022
 
4266
5023
  private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
4267
5024
  const topic = this.topics.get(sessionId);
4268
- if (!topic || topic.authorityState === "delete_pending") return undefined;
5025
+ if (!topic || !this.topics.isActiveUnambiguous(sessionId) || topic.bindingMalformed) return undefined;
4269
5026
  return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
4270
5027
  }
4271
5028
 
4272
5029
  private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
4273
5030
  const topic = this.topics.get(lease.sessionId);
4274
5031
  return (
4275
- topic?.authorityState !== "delete_pending" &&
5032
+ this.topics.isActiveUnambiguous(lease.sessionId) &&
5033
+ !topic?.bindingMalformed &&
4276
5034
  topic?.topicId === lease.topicId &&
4277
5035
  (topic.authorityEpoch ?? 0) === lease.authorityEpoch
4278
5036
  );
@@ -4317,26 +5075,29 @@ export class TelegramNotificationDaemon {
4317
5075
  msg: Record<string, unknown>,
4318
5076
  toolActivity?: ToolActivityOwner,
4319
5077
  ): void {
4320
- const frames = this.pendingThreadedFrames.get(session.sessionId) ?? [];
5078
+ const logicalSessionId = this.#logicalSessionId(session);
5079
+ const socketLease = this.#socketLease(session, logicalSessionId);
5080
+ if (!socketLease) return;
5081
+ const frames = this.pendingThreadedFrames.get(logicalSessionId) ?? [];
4321
5082
  frames.push({
4322
5083
  send,
4323
5084
  msg,
4324
- endpointDigest: session.endpointDigest,
5085
+ logicalSessionId,
5086
+ socketLease,
4325
5087
  ...(toolActivity ? { toolActivity } : {}),
4326
5088
  });
4327
5089
  if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
4328
- this.pendingThreadedFrames.set(session.sessionId, frames);
5090
+ this.pendingThreadedFrames.set(logicalSessionId, frames);
4329
5091
  }
4330
5092
 
4331
5093
  private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
4332
5094
  const frames = this.pendingThreadedFrames.get(sessionId);
4333
5095
  if (!frames || frames.length === 0) return;
4334
5096
  this.pendingThreadedFrames.delete(sessionId);
4335
- const session = this.sessions.get(sessionId);
4336
5097
  for (const frame of frames) {
4337
- if (session && session.endpointDigest !== frame.endpointDigest) continue;
5098
+ if (frame.logicalSessionId !== sessionId || !this.#leaseTokenAllows(frame.socketLease)) continue;
4338
5099
  if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
4339
- await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity);
5100
+ await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, frame.socketLease);
4340
5101
  }
4341
5102
  }
4342
5103
 
@@ -4346,36 +5107,163 @@ export class TelegramNotificationDaemon {
4346
5107
  * `undefined`; callers then flat-deliver to a private paired chat (with a
4347
5108
  * one-time nudge) or drop fail-closed for a non-private chat.
4348
5109
  */
4349
- private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
5110
+ private async ensureTopic(
5111
+ sessionId: string,
5112
+ name: string,
5113
+ session?: SessionSocket,
5114
+ creationLease?: { session: SessionSocket; token: number; logicalSessionId: string },
5115
+ ): Promise<string | undefined> {
4350
5116
  if (!(await this.pairedChatIsPrivate())) return undefined;
5117
+ if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
5118
+ const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
5119
+ if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
4351
5120
  const existing = this.topics.get(sessionId);
4352
- if (existing?.authorityState === "delete_pending") return undefined;
5121
+ if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
4353
5122
  if (existing) return existing.topicId;
5123
+ if (
5124
+ session &&
5125
+ sessionId !== session.sessionId &&
5126
+ (!session.logicalSessionIdTrusted || sessionId !== this.#logicalSessionId(session))
5127
+ )
5128
+ return undefined;
5129
+ if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease)) return undefined;
5130
+ const creationBinding = session ? this.#endpointBinding(session) : undefined;
5131
+ const creationLeaseEpoch = this.topics.authorityEpoch(sessionId);
5132
+ let acceptedTopicId: string | undefined;
5133
+ let acceptedTopicCompensated = false;
5134
+ let acceptedTopicDeleteAttempted = false;
4354
5135
  try {
4355
5136
  const rec = await this.topics.getOrCreateTopic(
4356
5137
  sessionId,
4357
5138
  async () => {
4358
- const res = await this.botApi.call("createForumTopic", {
4359
- chat_id: this.opts.chatId,
4360
- name,
4361
- });
5139
+ if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease))
5140
+ throw new Error("topic authority was revoked during creation");
5141
+ const res = await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name });
4362
5142
  if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
4363
5143
  const tid = (res as { result?: { message_thread_id?: unknown } }).result?.message_thread_id;
4364
5144
  if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0)
4365
5145
  throw new Error("createForumTopic: invalid message_thread_id");
4366
- return String(tid);
5146
+ acceptedTopicId = String(tid);
5147
+ if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
5148
+ if (
5149
+ !this.topics.fenceAcceptedCreateForLease(
5150
+ sessionId,
5151
+ acceptedTopicId,
5152
+ creationLeaseEpoch,
5153
+ this.opts.now,
5154
+ name,
5155
+ creationBinding,
5156
+ )
5157
+ )
5158
+ throw new Error("topic authority was revoked during creation");
5159
+ try {
5160
+ await this.persistTopics();
5161
+ } finally {
5162
+ acceptedTopicDeleteAttempted = true;
5163
+ const deletion = await this.botApi.call("deleteForumTopic", {
5164
+ chat_id: this.opts.chatId,
5165
+ message_thread_id: tid,
5166
+ });
5167
+ acceptedTopicCompensated = topicDeleteSettled(deletion);
5168
+
5169
+ if (topicDeleteSettled(deletion)) {
5170
+ this.topics.settleDelete(sessionId, acceptedTopicId);
5171
+ await this.persistTopics();
5172
+ } else {
5173
+ this.#superviseCompensationFence(sessionId);
5174
+ await this.#persistTopicsWithRetry().catch(() => undefined);
5175
+ }
5176
+ }
5177
+ throw new Error("topic authority was revoked during creation");
5178
+ }
5179
+ return acceptedTopicId;
4367
5180
  },
4368
5181
  this.opts.now,
4369
- // The create winner records the name it actually used; callers that
4370
- // merely JOIN an in-flight create must not overwrite it locally, or a
4371
- // later identity rename would be wrongly skipped (topic stuck at the
4372
- // provisional name on Telegram).
4373
5182
  name,
5183
+ creationBinding,
5184
+ () => this.persistTopics(),
5185
+ session,
4374
5186
  );
4375
- await this.persistTopics();
5187
+ // getOrCreateTopic deduplicates callers, so an accepted create can be
5188
+ // observed by a successor after its initiating socket was revoked. Check
5189
+ // the immutable lease again before exposing that record to frame delivery.
5190
+ if (capturedCreationLease && this.#isEagerCreationHandoff(capturedCreationLease)) {
5191
+ const binding = this.#endpointBinding(capturedCreationLease.session);
5192
+ if (
5193
+ this.topics.bindEndpoint(
5194
+ sessionId,
5195
+ binding,
5196
+ this.#activeEndpointKeysFor(sessionId, capturedCreationLease.session),
5197
+ true,
5198
+ ) !== "unchanged"
5199
+ )
5200
+ await this.persistTopics();
5201
+ }
5202
+ if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
5203
+ if (
5204
+ this.topics.fenceAcceptedCreateForLease(
5205
+ sessionId,
5206
+ rec.topicId,
5207
+ creationLeaseEpoch,
5208
+ this.opts.now,
5209
+ name,
5210
+ creationBinding,
5211
+ )
5212
+ )
5213
+ await this.deleteTopic(sessionId, undefined, true);
5214
+ return undefined;
5215
+ }
4376
5216
  return rec.topicId;
4377
5217
  } catch (err) {
4378
5218
  if (err instanceof ThreadedModeCapabilityRefusal) return undefined;
5219
+ if (
5220
+ acceptedTopicId &&
5221
+ !acceptedTopicCompensated &&
5222
+ !acceptedTopicDeleteAttempted &&
5223
+ this.topics.get(sessionId)?.authorityState !== "delete_pending"
5224
+ ) {
5225
+ // A failed initial commit must never make compensation conditional on
5226
+ // successfully publishing its fence.
5227
+ if (
5228
+ this.topics.fenceAcceptedCreateForLease(
5229
+ sessionId,
5230
+ acceptedTopicId,
5231
+ creationLeaseEpoch,
5232
+ this.opts.now,
5233
+ name,
5234
+ creationBinding,
5235
+ )
5236
+ ) {
5237
+ try {
5238
+ await this.#persistTopicsWithRetry();
5239
+ } catch {
5240
+ this.#superviseCompensationFence(sessionId);
5241
+ }
5242
+
5243
+ try {
5244
+ const deletion = await this.botApi.call("deleteForumTopic", {
5245
+ chat_id: this.opts.chatId,
5246
+ message_thread_id: Number(acceptedTopicId),
5247
+ });
5248
+ if (topicDeleteSettled(deletion)) {
5249
+ this.topics.settleDelete(sessionId, acceptedTopicId);
5250
+ await this.persistTopics();
5251
+ } else {
5252
+ this.#superviseCompensationFence(sessionId);
5253
+ await this.#persistTopicsWithRetry().catch(() => undefined);
5254
+ }
5255
+ } catch {
5256
+ this.#superviseCompensationFence(sessionId);
5257
+ await this.#persistTopicsWithRetry().catch(() => undefined);
5258
+ }
5259
+ }
5260
+ }
5261
+ if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicDeleteAttempted) {
5262
+ this.#superviseCompensationFence(sessionId);
5263
+ await this.#persistTopicsWithRetry().catch(() => undefined);
5264
+ }
5265
+ if (session && err instanceof Error && err.message === "topic authority was revoked during creation")
5266
+ return undefined;
4379
5267
  logger.warn(
4380
5268
  `notifications: Telegram topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
4381
5269
  );
@@ -4389,24 +5277,39 @@ export class TelegramNotificationDaemon {
4389
5277
  }
4390
5278
 
4391
5279
  private async observeOrphanedTopic(sessionId: string): Promise<void> {
4392
- if (this.topics.markOrphaned(sessionId, this.runtime.now())) await this.persistTopics();
4393
- if (!this.topicPastOrphanGrace(sessionId)) return;
4394
- await this.deleteTopic(sessionId);
5280
+ await this.#withRecoveryBindingClaim(async () => {
5281
+ const owner = this.logicalSessionOwners.get(sessionId);
5282
+ if (owner && this.#leaseAllows(owner, sessionId)) {
5283
+ if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
5284
+ return;
5285
+ }
5286
+ if (this.topics.markOrphaned(sessionId, this.runtime.now())) await this.persistTopics();
5287
+ if (!this.topicPastOrphanGrace(sessionId)) return;
5288
+ const currentOwner = this.logicalSessionOwners.get(sessionId);
5289
+ if (currentOwner && this.#leaseAllows(currentOwner, sessionId)) return;
5290
+ await this.deleteTopic(sessionId);
5291
+ });
4395
5292
  }
4396
5293
 
4397
5294
  /** Best-effort delete of a session topic once its local notification endpoint shuts down. */
4398
- private async deleteTopic(sessionId: string): Promise<void> {
4399
- let record = this.topics.beginDelete(sessionId);
4400
- // Persist even an absent-record fence: it revokes a create that was admitted
4401
- // before close but has not yet committed its topic record.
5295
+ private async deleteTopic(
5296
+ sessionId: string,
5297
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
5298
+ deleteFenceAlreadyPublished = false,
5299
+ ): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
5300
+ const deleteSnapshot = this.topics.captureDeleteAuthority(sessionId);
5301
+ let record = deleteFenceAlreadyPublished ? this.topics.get(sessionId) : this.topics.beginDelete(sessionId);
5302
+ if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
4402
5303
  await this.persistTopics();
5304
+ if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
5305
+ await this.#revokeAskAuthority(sessionId);
5306
+ this.deleteMessageRoutes(sessionId);
5307
+ this.#clearModelChoiceAliases(sessionId);
4403
5308
  if (!record) {
4404
5309
  await this.topics.awaitInflight(sessionId);
4405
5310
  record = this.topics.get(sessionId);
4406
- // A remotely accepted revoked create installs a delete_pending record while
4407
- // its caller rejects; durably retain it before compensating remotely.
4408
5311
  await this.persistTopics();
4409
- if (!record) return;
5312
+ if (!record) return "settled";
4410
5313
  }
4411
5314
  const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
4412
5315
  for (const item of removed) {
@@ -4416,11 +5319,12 @@ export class TelegramNotificationDaemon {
4416
5319
  }
4417
5320
  try {
4418
5321
  await this.flushPool();
5322
+ if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
4419
5323
  const res = (await this.botApi.call("deleteForumTopic", {
4420
5324
  chat_id: this.opts.chatId,
4421
5325
  message_thread_id: Number(record.topicId),
4422
5326
  })) as { ok?: boolean };
4423
- if (!topicDeleteSettled(res)) return;
5327
+ if (!topicDeleteSettled(res)) return "post_dispatch_pending";
4424
5328
  this.topics.settleDelete(sessionId, record.topicId);
4425
5329
  for (const k of [...this.liveMessages.keys()])
4426
5330
  if (k.startsWith(`${sessionId}:`)) {
@@ -4431,32 +5335,119 @@ export class TelegramNotificationDaemon {
4431
5335
  if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
4432
5336
  });
4433
5337
  this.pendingThreadedFrames.delete(sessionId);
5338
+ try {
5339
+ await this.persistTopics();
5340
+ return "settled";
5341
+ } catch {
5342
+ this.topics.restoreDeleteFence(deleteSnapshot);
5343
+ await this.#persistTopicsWithRetry().catch(() => undefined);
5344
+ return "post_dispatch_pending";
5345
+ }
5346
+ } catch {
5347
+ // Once Telegram dispatch starts, retain the persisted deletion fence: the
5348
+ // remote result is ambiguous and may not restore stale routing authority.
5349
+ return "post_dispatch_pending";
5350
+ }
5351
+ }
5352
+
5353
+ /** Serialize a mutation and its durable snapshot so rollback precedes later writers. */
5354
+ #persistTopicMutation<T>(mutation: () => T, rollback: () => void): Promise<T> {
5355
+ const pending = this.topicsPersistQueue.then(async () => {
5356
+ const result = mutation();
5357
+ try {
5358
+ const snapshot = this.#topicStateForPersistence();
5359
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
5360
+ await ensureDir(this.fsImpl, paths.dir);
5361
+ await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot);
5362
+ return result;
5363
+ } catch (error) {
5364
+ rollback();
5365
+ throw error;
5366
+ }
5367
+ });
5368
+ this.topicsPersistQueue = pending.then(
5369
+ () => undefined,
5370
+ () => undefined,
5371
+ );
5372
+ return pending;
5373
+ }
5374
+
5375
+ async #persistTopicsWithRetry(): Promise<void> {
5376
+ try {
4434
5377
  await this.persistTopics();
4435
5378
  } catch {
4436
- // Retain the persisted delete fence on transport ambiguity.
5379
+ await this.persistTopics();
4437
5380
  }
4438
5381
  }
4439
5382
 
5383
+ #superviseCompensationFence(sessionId: string): void {
5384
+ if (this.compensationFenceRetries.has(sessionId)) return;
5385
+ const retry = this.effects.track(
5386
+ (async () => {
5387
+ for (;;) {
5388
+ try {
5389
+ await this.persistTopics();
5390
+ return;
5391
+ } catch {
5392
+ await this.runtime.sleep(250);
5393
+ }
5394
+ }
5395
+ })(),
5396
+ );
5397
+ this.compensationFenceRetries.set(sessionId, retry);
5398
+ void retry.finally(() => this.compensationFenceRetries.delete(sessionId));
5399
+ }
5400
+
4440
5401
  private persistTopics(): Promise<void> {
4441
5402
  const pending = this.topicsPersistQueue.then(async () => {
5403
+ // Resolve implicit snapshots inside the serialization queue. Callers that
5404
+ // mutate the registry before waiting cannot overwrite a newer authority
5405
+ // binding with an invocation-time snapshot.
5406
+ const snapshot = this.#topicStateForPersistence();
4442
5407
  const paths = daemonPaths(this.opts.settings.getAgentDir());
4443
5408
  await ensureDir(this.fsImpl, paths.dir);
4444
- await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), this.topics.serialize());
5409
+ await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot);
4445
5410
  });
4446
5411
  this.topicsPersistQueue = pending.catch(() => undefined);
4447
5412
  return pending;
4448
5413
  }
4449
5414
 
5415
+ #topicStateForPersistence(): TopicRegistryState {
5416
+ return { ...this.topics.serialize(), closedEndpoints: Object.fromEntries(this.closedEndpointKeys) };
5417
+ }
5418
+
4450
5419
  async loadTopics(): Promise<void> {
4451
5420
  const paths = daemonPaths(this.opts.settings.getAgentDir());
4452
5421
  const raw = await readJson<TopicRegistryState>(this.fsImpl, path.join(paths.dir, "telegram-topics.json"));
4453
5422
  // Restore the full serialized registry (topicId + identitySent + name) so a
4454
5423
  // fresh daemon after reload does not resend identity headers or lose renames.
4455
- if (raw && typeof raw === "object") this.topics.load(raw);
5424
+ if (raw && typeof raw === "object") {
5425
+ this.topics.load(raw);
5426
+ for (const [sessionId, binding] of Object.entries(raw.closedEndpoints ?? {})) {
5427
+ if (
5428
+ binding &&
5429
+ typeof binding.chatId === "string" &&
5430
+ typeof binding.endpointKey === "string" &&
5431
+ typeof binding.endpointDigest === "string" &&
5432
+ (binding.endpointGeneration === undefined ||
5433
+ (Number.isSafeInteger(binding.endpointGeneration) && binding.endpointGeneration >= 0))
5434
+ )
5435
+ this.closedEndpointKeys.set(sessionId, binding);
5436
+ }
5437
+ }
4456
5438
  }
4457
5439
 
4458
- /** Download a Telegram file by its file_path (from getFile) into memory. */
4459
- private async downloadTelegramFile(filePath: string): Promise<Buffer | undefined> {
5440
+ /** Retry crash-interrupted topic deletes; only a definite Telegram result clears the durable fence. */
5441
+ private async reconcilePendingTopicDeletes(): Promise<void> {
5442
+ for (const sessionId of this.topics.deletePendingSessionIds()) await this.deleteTopic(sessionId);
5443
+ }
5444
+
5445
+ /** Download one Telegram file with the Bot API's 20 MiB ceiling and one end-to-end deadline. */
5446
+ private async downloadTelegramFile(
5447
+ filePath: string,
5448
+ maxBytes = TELEGRAM_ATTACHMENT_MAX_BYTES,
5449
+ deadlineController?: AbortController,
5450
+ ): Promise<TelegramFileDownload> {
4460
5451
  const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
4461
5452
  const fetchImpl = this.opts.fetchImpl ?? fetch;
4462
5453
  // `filePath` is remote metadata from getFile; reject suspicious segments
@@ -4464,17 +5455,68 @@ export class TelegramNotificationDaemon {
4464
5455
  // composing the download URL.
4465
5456
  if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
4466
5457
  logger.warn("notifications: rejecting suspicious Telegram file_path");
4467
- return undefined;
5458
+ return { failure: "download_failed" };
4468
5459
  }
4469
5460
  const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
4470
5461
  const url = `${apiBase}/file/bot${this.opts.botToken}/${encodedPath}`;
5462
+ const controller = deadlineController ?? new AbortController();
5463
+ let cancelActiveReader: (() => Promise<void>) | undefined;
5464
+ const setTimeoutImpl = this.opts.setTimeoutImpl ?? setTimeout;
5465
+ const clearTimeoutImpl = this.opts.clearTimeoutImpl ?? clearTimeout;
5466
+ const timeout = deadlineController
5467
+ ? undefined
5468
+ : setTimeoutImpl(
5469
+ () => controller.abort(new Error("Telegram attachment download timed out")),
5470
+ TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS,
5471
+ );
5472
+ const cancelReader = () => void cancelActiveReader?.();
5473
+ controller.signal.addEventListener("abort", cancelReader, { once: true });
4471
5474
  try {
4472
- const res = await fetchImpl(url);
4473
- if (!res.ok) return undefined;
4474
- return Buffer.from(await res.arrayBuffer());
5475
+ const res = await fetchImpl(url, { signal: controller.signal });
5476
+ if (!res.ok) {
5477
+ await res.body?.cancel().catch(() => undefined);
5478
+ controller.abort();
5479
+ return { failure: "download_failed" };
5480
+ }
5481
+ const declaredLength = res.headers.get("content-length");
5482
+ if (declaredLength !== null) {
5483
+ const declaredBytes = Number(declaredLength);
5484
+ if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0 || declaredBytes > maxBytes) {
5485
+ await res.body?.cancel().catch(() => undefined);
5486
+ controller.abort();
5487
+ return { failure: "too_large" };
5488
+ }
5489
+ }
5490
+ if (!res.body) {
5491
+ controller.abort();
5492
+ return { failure: "download_failed" };
5493
+ }
5494
+ const reader = res.body.getReader();
5495
+ cancelActiveReader = () => reader.cancel().catch(() => undefined);
5496
+ controller.signal.throwIfAborted();
5497
+ const chunks: Buffer[] = [];
5498
+ let total = 0;
5499
+ while (true) {
5500
+ const { done, value } = await reader.read();
5501
+ if (done) break;
5502
+ total += value.byteLength;
5503
+ if (total > maxBytes) {
5504
+ await reader.cancel().catch(() => undefined);
5505
+ controller.abort();
5506
+ return { failure: "too_large" };
5507
+ }
5508
+ chunks.push(Buffer.from(value));
5509
+ }
5510
+ controller.signal.throwIfAborted();
5511
+ return { bytes: Buffer.concat(chunks, total) };
4475
5512
  } catch (e) {
5513
+ await cancelActiveReader?.();
5514
+ controller.abort();
4476
5515
  logger.warn(`notifications: file download failed: ${sanitizeDiagnostic(String(e))}`);
4477
- return undefined;
5516
+ return { failure: "download_failed" };
5517
+ } finally {
5518
+ controller.signal.removeEventListener("abort", cancelReader);
5519
+ if (timeout !== undefined) clearTimeoutImpl(timeout);
4478
5520
  }
4479
5521
  }
4480
5522
 
@@ -4484,6 +5526,8 @@ export class TelegramNotificationDaemon {
4484
5526
  * removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
4485
5527
  */
4486
5528
  private readonly attachmentDirs = new Map<string, string>();
5529
+ readonly #attachmentUsage = new Map<string, { count: number; bytes: number }>();
5530
+ readonly #attachmentChains = new Map<string, Promise<void>>();
4487
5531
 
4488
5532
  /** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
4489
5533
  private async ensureAttachmentDir(sessionId: string): Promise<string> {
@@ -4501,6 +5545,8 @@ export class TelegramNotificationDaemon {
4501
5545
  private async cleanupAllAttachmentDirs(): Promise<void> {
4502
5546
  const dirs = [...this.attachmentDirs.values()];
4503
5547
  this.attachmentDirs.clear();
5548
+ this.#attachmentUsage.clear();
5549
+ this.#attachmentChains.clear();
4504
5550
  await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
4505
5551
  }
4506
5552
 
@@ -4516,22 +5562,83 @@ export class TelegramNotificationDaemon {
4516
5562
  private async resolveInboundAttachment(
4517
5563
  att: InboundAttachment,
4518
5564
  sessionId: string,
5565
+ ): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
5566
+ const previous = this.#attachmentChains.get(sessionId) ?? Promise.resolve();
5567
+ const task = previous.then(() => this.resolveInboundAttachmentSerial(att, sessionId));
5568
+ const chain = task.then(
5569
+ () => undefined,
5570
+ () => undefined,
5571
+ );
5572
+ this.#attachmentChains.set(sessionId, chain);
5573
+ return task.finally(() => {
5574
+ if (this.#attachmentChains.get(sessionId) === chain) this.#attachmentChains.delete(sessionId);
5575
+ });
5576
+ }
5577
+
5578
+ private async resolveInboundAttachmentSerial(
5579
+ att: InboundAttachment,
5580
+ sessionId: string,
4519
5581
  ): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
4520
5582
  const images: { data: string; mime?: string }[] = [];
4521
5583
  const fileNotes: string[] = [];
4522
5584
  const label = att.fileName ?? att.kind;
5585
+ let timeout: NodeJS.Timeout | undefined;
4523
5586
  try {
4524
- const got = (await this.botApi.call("getFile", { file_id: att.fileId })) as {
4525
- result?: { file_path?: unknown };
5587
+ const usage = this.#attachmentUsage.get(sessionId) ?? { count: 0, bytes: 0 };
5588
+ if (
5589
+ usage.count >= TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT ||
5590
+ usage.bytes >= TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES
5591
+ ) {
5592
+ fileNotes.push(`[attachment rejected: ${label}; session attachment limit reached]`);
5593
+ return { images, fileNotes };
5594
+ }
5595
+ const controller = new AbortController();
5596
+ timeout = (this.opts.setTimeoutImpl ?? setTimeout)(
5597
+ () => controller.abort(new Error("Telegram attachment download timed out")),
5598
+ TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS,
5599
+ );
5600
+ const got = (await this.botApi.call("getFile", { file_id: att.fileId }, { signal: controller.signal })) as {
5601
+ result?: { file_path?: unknown; file_size?: unknown };
4526
5602
  };
4527
5603
  const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
4528
5604
  if (!filePath) {
4529
5605
  fileNotes.push(`[attachment unavailable: ${label}]`);
4530
5606
  return { images, fileNotes };
4531
5607
  }
4532
- const bytes = await this.downloadTelegramFile(filePath);
4533
- if (!bytes) {
4534
- fileNotes.push(`[attachment download failed: ${label}]`);
5608
+ const declaredBytes = got.result?.file_size;
5609
+ const remainingBytes = TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES - usage.bytes;
5610
+ if (
5611
+ (typeof declaredBytes === "number" &&
5612
+ (!Number.isSafeInteger(declaredBytes) ||
5613
+ declaredBytes < 0 ||
5614
+ declaredBytes > TELEGRAM_ATTACHMENT_MAX_BYTES ||
5615
+ declaredBytes > remainingBytes)) ||
5616
+ remainingBytes <= 0
5617
+ ) {
5618
+ fileNotes.push(`[attachment rejected: ${label}; size limit exceeded]`);
5619
+ return { images, fileNotes };
5620
+ }
5621
+ const downloaded = await this.downloadTelegramFile(
5622
+ filePath,
5623
+ Math.min(TELEGRAM_ATTACHMENT_MAX_BYTES, remainingBytes),
5624
+ controller,
5625
+ );
5626
+ if ("failure" in downloaded) {
5627
+ fileNotes.push(
5628
+ downloaded.failure === "too_large"
5629
+ ? `[attachment rejected: ${label}; size limit exceeded]`
5630
+ : `[attachment download failed: ${label}]`,
5631
+ );
5632
+ return { images, fileNotes };
5633
+ }
5634
+ const bytes = downloaded.bytes;
5635
+ const accountedBytes = Math.max(bytes.byteLength, typeof declaredBytes === "number" ? declaredBytes : 0);
5636
+ const current = this.#attachmentUsage.get(sessionId) ?? { count: 0, bytes: 0 };
5637
+ if (
5638
+ current.count >= TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT ||
5639
+ current.bytes + accountedBytes > TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES
5640
+ ) {
5641
+ fileNotes.push(`[attachment rejected: ${label}; session attachment limit reached]`);
4535
5642
  return { images, fileNotes };
4536
5643
  }
4537
5644
  const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
@@ -4548,12 +5655,20 @@ export class TelegramNotificationDaemon {
4548
5655
  // Unguessable, non-colliding name inside the private 0700 dir; the
4549
5656
  // exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
4550
5657
  const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
4551
- await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
5658
+ try {
5659
+ await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
5660
+ } catch (e) {
5661
+ await fs.promises.unlink(dest).catch(() => undefined);
5662
+ throw e;
5663
+ }
4552
5664
  fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
4553
5665
  }
5666
+ this.#attachmentUsage.set(sessionId, { count: current.count + 1, bytes: current.bytes + accountedBytes });
4554
5667
  } catch (e) {
4555
5668
  logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
4556
5669
  fileNotes.push(`[attachment error: ${label}]`);
5670
+ } finally {
5671
+ if (timeout !== undefined) (this.opts.clearTimeoutImpl ?? clearTimeout)(timeout);
4557
5672
  }
4558
5673
  return { images, fileNotes };
4559
5674
  }
@@ -4659,7 +5774,9 @@ export class TelegramNotificationDaemon {
4659
5774
  const controller = new AbortController();
4660
5775
  selectedAck.controller = controller;
4661
5776
  const routeAvailable =
4662
- (await this.pairedChatIsPrivate()) && (!topicLease || this.topicLeaseIsCurrent(topicLease));
5777
+ this.#leaseTokenAllows(selectedAck.socketLease) &&
5778
+ (await this.pairedChatIsPrivate()) &&
5779
+ (!topicLease || this.topicLeaseIsCurrent(topicLease));
4663
5780
  if (this.selectedAckPending.get(selectedAck.pendingKey) !== selectedAck) {
4664
5781
  this.pool.settle(item.itemId!, "removed");
4665
5782
  continue;
@@ -4681,6 +5798,14 @@ export class TelegramNotificationDaemon {
4681
5798
  Math.min(8_000, remaining),
4682
5799
  );
4683
5800
  try {
5801
+ if (
5802
+ !this.#leaseTokenAllows(selectedAck.socketLease) ||
5803
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
5804
+ ) {
5805
+ this.finishSelectedAck(selectedAck, { status: "failed", reason: "route_missing" });
5806
+ this.pool.settle(item.itemId!, "rejected");
5807
+ continue;
5808
+ }
4684
5809
  const response = (await this.botApi.call(
4685
5810
  "sendMessage",
4686
5811
  {
@@ -4705,8 +5830,11 @@ export class TelegramNotificationDaemon {
4705
5830
  }
4706
5831
  continue;
4707
5832
  }
4708
- const { send, topicLease } = item.payload;
4709
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
5833
+ const { send, topicLease, socketLease } = item.payload;
5834
+ if (
5835
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
5836
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
5837
+ ) {
4710
5838
  this.pool.settle(item.itemId!, "rejected");
4711
5839
  continue;
4712
5840
  }
@@ -4715,7 +5843,10 @@ export class TelegramNotificationDaemon {
4715
5843
  this.pool.settle(item.itemId!, "rejected");
4716
5844
  continue;
4717
5845
  }
4718
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
5846
+ if (
5847
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
5848
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
5849
+ ) {
4719
5850
  this.pool.settle(item.itemId!, "rejected");
4720
5851
  continue;
4721
5852
  }
@@ -4760,7 +5891,10 @@ export class TelegramNotificationDaemon {
4760
5891
  }
4761
5892
  }
4762
5893
  }
4763
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
5894
+ if (
5895
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
5896
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
5897
+ ) {
4764
5898
  disposition = "rejected";
4765
5899
  continue;
4766
5900
  }
@@ -4800,7 +5934,11 @@ export class TelegramNotificationDaemon {
4800
5934
  // non-editable, HTML-only pool items (rich markers stripped) — same
4801
5935
  // per-token discipline as the non-rich split path.
4802
5936
  const chunks = splitTelegramHtml(send.text!);
4803
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
5937
+ if (
5938
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
5939
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
5940
+ )
5941
+ return;
4804
5942
  await this.botApi.call("sendMessage", {
4805
5943
  chat_id: this.opts.chatId,
4806
5944
  ...threadField,
@@ -4825,6 +5963,7 @@ export class TelegramNotificationDaemon {
4825
5963
  richClass: undefined,
4826
5964
  },
4827
5965
  topicLease,
5966
+ socketLease,
4828
5967
  },
4829
5968
  });
4830
5969
  }
@@ -4859,6 +5998,11 @@ export class TelegramNotificationDaemon {
4859
5998
  // transport error) resends so the first chunk is never lost.
4860
5999
  let edited = false;
4861
6000
  try {
6001
+ if (
6002
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6003
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
6004
+ )
6005
+ return;
4862
6006
  const res = (await this.botApi.call("editMessageText", {
4863
6007
  chat_id: this.opts.chatId,
4864
6008
  message_id: existingId,
@@ -4871,7 +6015,10 @@ export class TelegramNotificationDaemon {
4871
6015
  }
4872
6016
  if (edited) {
4873
6017
  firstMessageId = existingId;
4874
- } else if (!topicLease || this.topicLeaseIsCurrent(topicLease)) {
6018
+ } else if (
6019
+ (!socketLease || this.#leaseTokenAllows(socketLease)) &&
6020
+ (!topicLease || this.topicLeaseIsCurrent(topicLease))
6021
+ ) {
4875
6022
  const res = (await this.botApi.call("sendMessage", {
4876
6023
  chat_id: this.opts.chatId,
4877
6024
  ...threadField,
@@ -4883,6 +6030,11 @@ export class TelegramNotificationDaemon {
4883
6030
  } else {
4884
6031
  // No streamed message to edit: a single granted slot maps to a
4885
6032
  // single Telegram send.
6033
+ if (
6034
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6035
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
6036
+ )
6037
+ return;
4886
6038
  const res = (await this.botApi.call("sendMessage", {
4887
6039
  chat_id: this.opts.chatId,
4888
6040
  ...threadField,
@@ -4916,6 +6068,7 @@ export class TelegramNotificationDaemon {
4916
6068
  richClass: undefined,
4917
6069
  },
4918
6070
  topicLease,
6071
+ socketLease,
4919
6072
  },
4920
6073
  });
4921
6074
  }
@@ -4993,17 +6146,21 @@ export class TelegramNotificationDaemon {
4993
6146
  sessionId: string,
4994
6147
  send: ThreadedSend,
4995
6148
  toolActivity?: ToolActivityOwner,
6149
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
4996
6150
  ): Promise<void> {
4997
- if (!(await this.pairedChatIsPrivate())) return;
4998
- await this.notifyThreadedFallback();
6151
+ if ((socketLease && !this.#leaseTokenAllows(socketLease)) || !(await this.pairedChatIsPrivate())) return;
6152
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
6153
+ await this.notifyThreadedFallback(socketLease);
6154
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
4999
6155
  if (send.identity && this.flatIdentitySent.has(sessionId)) return;
5000
6156
  this.submitPool({
5001
6157
  sessionId,
5002
6158
  lane: send.lane,
5003
6159
  coalesceKey: send.coalesceKey,
5004
- payload: { send, ...(toolActivity ? { toolActivity } : {}) },
6160
+ payload: { send, ...(socketLease ? { socketLease } : {}), ...(toolActivity ? { toolActivity } : {}) },
5005
6161
  });
5006
6162
  await this.flushPool();
6163
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5007
6164
  if (send.identity) this.flatIdentitySent.add(sessionId);
5008
6165
  }
5009
6166
 
@@ -5046,10 +6203,21 @@ export class TelegramNotificationDaemon {
5046
6203
  }
5047
6204
 
5048
6205
  /** Tell the user once (per daemon run) how to enable Threaded Mode. */
5049
- private async notifyThreadedFallback(): Promise<void> {
5050
- if (this.threadedFallbackNoticeSent || !(await this.pairedChatIsPrivate())) return;
6206
+ private async notifyThreadedFallback(socketLease?: {
6207
+ session: SessionSocket;
6208
+ token: number;
6209
+ logicalSessionId: string;
6210
+ }): Promise<void> {
6211
+ if (
6212
+ this.threadedFallbackNoticeSent ||
6213
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6214
+ !(await this.pairedChatIsPrivate())
6215
+ )
6216
+ return;
6217
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5051
6218
  this.threadedFallbackNoticeSent = true;
5052
6219
  try {
6220
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5053
6221
  await this.botApi.call("sendMessage", {
5054
6222
  chat_id: this.opts.chatId,
5055
6223
  text: "Flat Telegram private chat supports outbound notifications and inline ask buttons only. Enable Threaded Mode in @BotFather > Bot Settings > Threads Settings for free-text replies and session commands.",
@@ -5124,14 +6292,20 @@ export class TelegramNotificationDaemon {
5124
6292
  }
5125
6293
 
5126
6294
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
5127
- private async sendTyping(sessionId: string): Promise<void> {
6295
+ private async sendTyping(
6296
+ sessionId: string,
6297
+ capturedLease?: { session: SessionSocket; token: number; logicalSessionId: string },
6298
+ ): Promise<void> {
6299
+ const session = this.logicalSessionOwners.get(sessionId) ?? this.sessions.get(sessionId);
6300
+ const socketLease = capturedLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
6301
+ if (!socketLease) return;
5128
6302
  const topicLease = await this.topicAuthorityLease(sessionId);
5129
- if (!topicLease || !this.topicLeaseIsCurrent(topicLease)) return;
5130
- const topicId = topicLease.topicId;
6303
+ if (!topicLease || !this.topicLeaseIsCurrent(topicLease) || !this.#leaseTokenAllows(socketLease)) return;
5131
6304
  try {
6305
+ if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return;
5132
6306
  await this.botApi.call("sendChatAction", {
5133
6307
  chat_id: this.opts.chatId,
5134
- message_thread_id: Number(topicId),
6308
+ message_thread_id: Number(topicLease.topicId),
5135
6309
  action: "typing",
5136
6310
  });
5137
6311
  } catch {
@@ -5140,9 +6314,14 @@ export class TelegramNotificationDaemon {
5140
6314
  }
5141
6315
 
5142
6316
  /** Set a native reaction on an inbound thread message (best-effort). */
5143
- private async setReaction(messageId: number, emoji: string): Promise<void> {
5144
- if (!(await this.pairedChatIsPrivate())) return;
6317
+ private async setReaction(
6318
+ messageId: number,
6319
+ emoji: string,
6320
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
6321
+ ): Promise<void> {
6322
+ if ((socketLease && !this.#leaseTokenAllows(socketLease)) || !(await this.pairedChatIsPrivate())) return;
5145
6323
  try {
6324
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5146
6325
  await this.botApi.call("setMessageReaction", {
5147
6326
  chat_id: this.opts.chatId,
5148
6327
  message_id: messageId,
@@ -5166,7 +6345,10 @@ export class TelegramNotificationDaemon {
5166
6345
 
5167
6346
  /** Render successful `/model` lists as session-bound, one-shot inline choices. */
5168
6347
  async #renderModelChoices(session: SessionSocket, msg: Record<string, unknown>): Promise<boolean> {
5169
- const logicalSessionId = this.#logicalSessionId(session);
6348
+ const logicalSessionId =
6349
+ !session.logicalSessionIdTrusted && typeof msg.sessionId === "string" && msg.sessionId.trim()
6350
+ ? msg.sessionId
6351
+ : this.#logicalSessionId(session);
5170
6352
  if (
5171
6353
  msg.type !== "control_command_result" ||
5172
6354
  msg.status !== "ok" ||
@@ -5175,6 +6357,8 @@ export class TelegramNotificationDaemon {
5175
6357
  this.sessions.get(session.sessionId) !== session
5176
6358
  )
5177
6359
  return false;
6360
+ const socketLease = this.#socketLease(session, logicalSessionId);
6361
+ if (!socketLease) return false;
5178
6362
 
5179
6363
  const choices: RenderedModelChoice[] = [];
5180
6364
  for (const choice of msg.modelChoices) {
@@ -5189,21 +6373,27 @@ export class TelegramNotificationDaemon {
5189
6373
  const rendered = renderThreadedFrame({ ...msg, type: "control_command_result" });
5190
6374
  if (!rendered?.text) return false;
5191
6375
  const topicId =
5192
- (await this.existingTopicForPrivateChat(session.sessionId)) ??
5193
- (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
5194
- const topicLease = await this.topicAuthorityLease(session.sessionId);
6376
+ (await this.existingTopicForPrivateChat(logicalSessionId)) ??
6377
+ (await this.ensureTopic(
6378
+ logicalSessionId,
6379
+ this.topicNameFor(logicalSessionId, msg),
6380
+ session.logicalSessionIdTrusted ? session : undefined,
6381
+ socketLease,
6382
+ ));
6383
+ const topicLease = await this.topicAuthorityLease(logicalSessionId);
5195
6384
  if (!topicId || !topicLease || topicLease.topicId !== topicId) return false;
6385
+ if (!session.logicalSessionIdTrusted) this.legacyTopicOwners.set(logicalSessionId, session);
5196
6386
 
5197
6387
  // Each logical session owns only its most recently rendered menu.
5198
6388
  this.#clearModelChoiceAliases(logicalSessionId);
5199
6389
  const aliases = choices.map(choice =>
5200
- this.#putModelChoiceAlias({ session, sessionId: logicalSessionId, selector: choice.selector }),
6390
+ this.#putModelChoiceAlias({ session, socketLease, sessionId: logicalSessionId, selector: choice.selector }),
5201
6391
  );
5202
6392
  const inline_keyboard = buildButtonGrid(
5203
6393
  choices.map(choice => choice.label),
5204
6394
  index => aliases[index]!,
5205
6395
  );
5206
- if (!this.topicLeaseIsCurrent(topicLease)) return false;
6396
+ if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return false;
5207
6397
  try {
5208
6398
  const response = await this.botApi.call("sendMessage", {
5209
6399
  chat_id: this.opts.chatId,
@@ -5226,6 +6416,18 @@ export class TelegramNotificationDaemon {
5226
6416
  }
5227
6417
 
5228
6418
  async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
6419
+ if (msg?.type === "hello") {
6420
+ const capabilities = Array.isArray(msg.capabilities) ? msg.capabilities : [];
6421
+ if (capabilities.includes("ephemeral_turn_v1")) {
6422
+ session.ephemeralCapable = true;
6423
+ this.#resumeBtwTurnsForSession(session);
6424
+ }
6425
+ if (capabilities.includes(CLIENT_PING_PONG_CAPABILITY)) {
6426
+ session.capable = true;
6427
+ this.startLiveness(session);
6428
+ }
6429
+ return;
6430
+ }
5229
6431
  if (session.replayPending) {
5230
6432
  if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
5231
6433
  return;
@@ -5236,34 +6438,100 @@ export class TelegramNotificationDaemon {
5236
6438
  session.replayQueue.push(frame);
5237
6439
  return;
5238
6440
  }
5239
- session.replayPending = false;
5240
6441
  const replayValid =
6442
+ msg.ok === true &&
5241
6443
  Number.isSafeInteger(msg.generation) &&
5242
6444
  msg.generation >= 1 &&
5243
6445
  Number.isSafeInteger(msg.lastSeq) &&
5244
6446
  msg.lastSeq >= 0 &&
5245
- Array.isArray(msg.events);
5246
- if (replayValid) {
5247
- session.hostGeneration = msg.generation;
5248
- }
6447
+ msg.gap === undefined &&
6448
+ Array.isArray(msg.events) &&
6449
+ msg.events.every((event: unknown) => event !== null && typeof event === "object" && !Array.isArray(event));
5249
6450
  const replayed: Record<string, unknown>[] = replayValid
5250
- ? (msg.events as unknown[]).flatMap((event: unknown): Record<string, unknown>[] => {
5251
- if (!event || typeof event !== "object" || Array.isArray(event)) return [];
5252
- const envelope = event as Record<string, unknown>;
5253
- const payload = envelope.payload;
5254
- return [
5255
- payload && typeof payload === "object" && !Array.isArray(payload)
5256
- ? (payload as Record<string, unknown>)
5257
- : envelope,
5258
- ];
6451
+ ? (msg.events as Record<string, unknown>[]).map(event => {
6452
+ const payload = event.payload;
6453
+ return payload && typeof payload === "object" && !Array.isArray(payload)
6454
+ ? (payload as Record<string, unknown>)
6455
+ : event;
5259
6456
  })
5260
6457
  : [];
6458
+ const identityFrames = replayed.filter(frame => frame.type === "identity_header");
6459
+ const identities = identityFrames.flatMap(frame =>
6460
+ typeof frame.sessionId === "string" && frame.sessionId.trim() ? [frame.sessionId] : [],
6461
+ );
6462
+ const malformedIdentity = identityFrames.some(
6463
+ frame => typeof frame.sessionId !== "string" || !frame.sessionId.trim(),
6464
+ );
6465
+ const identityConflict = new Set(identities).size > 1;
6466
+ if (!replayValid || malformedIdentity || identityConflict) {
6467
+ // A replay result is the admission proof. Never clear its barrier or drain
6468
+ // queued frames after malformed/conflicting proof; the socket cannot fall
6469
+ // back to transport-local config rekeying.
6470
+ this.dropSession(session, "invalid_replay");
6471
+ return;
6472
+ }
6473
+ session.hostGeneration = msg.generation;
6474
+ session.logicalSessionIdTrusted = true;
6475
+ const identityIndex = replayed.findLastIndex(frame => frame.type === "identity_header");
6476
+ const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
6477
+ const replayIdentitySessionId = latestIdentity?.sessionId as string | undefined;
6478
+ const endpointBinding = this.#endpointBinding(session);
6479
+ // An open transport owns an eager topic-create claim. An identity-less replay
6480
+ // that races that claim must await its own public creation before classifying
6481
+ // endpoint authority; otherwise the registry correctly sees an in-flight
6482
+ // claim as ambiguous and rejects a valid bootstrap.
6483
+ if (!this.topics.get(session.sessionId) && this.#ownsLiveOpenEndpoint(session, endpointBinding)) {
6484
+ try {
6485
+ await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, {}), session);
6486
+ } catch (error) {
6487
+ logger.warn(
6488
+ `notifications: Telegram replay topic creation failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
6489
+ );
6490
+ this.dropSession(session, "replay_topic_creation_failed");
6491
+ return;
6492
+ }
6493
+ }
6494
+ // Identity-less replay may resume only the exact transport owner. A
6495
+ // rekeyed A→B transport remains denied unless replay proves B.
6496
+ const endpointAuthority = this.#endpointAuthority(endpointBinding, session);
6497
+ const ownsLiveOpenEndpoint = this.#ownsLiveOpenEndpoint(session, endpointBinding);
6498
+ const canResumeTransport =
6499
+ endpointAuthority.state === "unique" &&
6500
+ endpointAuthority.sessionId === session.sessionId &&
6501
+ ownsLiveOpenEndpoint &&
6502
+ this.topics.matchesEndpoint(session.sessionId, endpointBinding);
6503
+ const canBootstrapTransport =
6504
+ endpointAuthority.state === "none" &&
6505
+ ownsLiveOpenEndpoint &&
6506
+ !this.topics.get(session.sessionId) &&
6507
+ !this.preservedInitiatorTopics.has(session.sessionId);
6508
+ const replayCandidateSessionId =
6509
+ replayIdentitySessionId ?? (canResumeTransport || canBootstrapTransport ? session.sessionId : undefined);
6510
+ if (!replayCandidateSessionId) return;
6511
+ const recovered = await this.#recoverTopicBinding(
6512
+ session,
6513
+ replayCandidateSessionId ?? session.sessionId,
6514
+ true,
6515
+ true,
6516
+ replayIdentitySessionId ? undefined : canBootstrapTransport ? "bootstrap" : "resume",
6517
+ );
6518
+ if (!recovered) {
6519
+ if (session.hostGeneration === msg.generation && session.recoveryLease?.state !== "pending")
6520
+ this.dropSession(session, "recovery_rejected");
6521
+ return;
6522
+ }
6523
+ if (
6524
+ !this.#ownsLiveOpenEndpoint(session, endpointBinding) ||
6525
+ !this.#leaseAllows(session, this.#logicalSessionId(session))
6526
+ ) {
6527
+ this.dropSession(session, "recovery_rejected");
6528
+ return;
6529
+ }
6530
+ session.replayPending = false;
5261
6531
  // Replay restores durable attachment state only. Live notification effects
5262
6532
  // (turn streams, context updates, lifecycle messages) may already have been
5263
6533
  // delivered before a reconnect and must never be rendered a second time.
5264
- const identityIndex = replayed.findLastIndex(frame => frame.type === "identity_header");
5265
6534
  const currentGeneration = identityIndex < 0 ? replayed : replayed.slice(identityIndex);
5266
- const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
5267
6535
  const latestActions = new Map<string, Record<string, unknown>>();
5268
6536
  for (const frame of currentGeneration) {
5269
6537
  if ((frame.type === "action_needed" || frame.type === "action_resolved") && typeof frame.id === "string")
@@ -5274,7 +6542,15 @@ export class TelegramNotificationDaemon {
5274
6542
  for (const frame of replayState) {
5275
6543
  const fingerprint = JSON.stringify(frame);
5276
6544
  replayCounts.set(fingerprint, (replayCounts.get(fingerprint) ?? 0) + 1);
5277
- await this.handleSessionMessage(session, frame);
6545
+ try {
6546
+ await this.handleSessionMessage(session, frame);
6547
+ } catch (error) {
6548
+ logger.warn(
6549
+ `notifications: Telegram replay admission failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
6550
+ );
6551
+ this.dropSession(session, "replay_admission_failed");
6552
+ return;
6553
+ }
5278
6554
  }
5279
6555
  const queued = session.replayQueue.splice(0);
5280
6556
  for (const frame of queued) {
@@ -5292,16 +6568,19 @@ export class TelegramNotificationDaemon {
5292
6568
  }
5293
6569
  await this.handleSessionMessage(session, frame);
5294
6570
  }
5295
- if (replayValid) {
5296
- this.#terminalizeBtwTurnsForGenerationChange(session);
5297
- this.#resumeBtwTurnsForSession(session);
5298
- if (this.topics.markReplayCursor(session.sessionId, msg.generation, msg.lastSeq))
5299
- await this.persistTopics();
5300
- }
6571
+ this.#terminalizeBtwTurnsForGenerationChange(session);
6572
+ this.#resumeBtwTurnsForSession(session);
6573
+ const recoveredSessionId = this.#logicalSessionId(session);
6574
+ if (
6575
+ this.#leaseAllows(session, recoveredSessionId) &&
6576
+ this.topics.markReplayCursor(recoveredSessionId, msg.generation, msg.lastSeq)
6577
+ )
6578
+ await this.persistTopics();
5301
6579
  return;
5302
6580
  }
5303
6581
  if (msg?.type === "event_replay_result") return;
5304
- if (msg && typeof msg === "object") this.#updateLogicalSessionForThreadedFrame(session, msg);
6582
+ if (msg && typeof msg === "object") await this.#updateLogicalSessionForThreadedFrame(session, msg);
6583
+ if (session.logicalSessionIdTrusted && !this.#leaseAllows(session)) return;
5305
6584
  if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
5306
6585
  if (await this.#renderModelChoices(session, msg as Record<string, unknown>)) return;
5307
6586
 
@@ -5335,7 +6614,7 @@ export class TelegramNotificationDaemon {
5335
6614
  msg.threadId !== pending.threadId ||
5336
6615
  msg.updateId !== pending.updateId ||
5337
6616
  msg.messageId !== pending.messageId ||
5338
- this.topics.sessionForTopic(pending.threadId) !== pending.transportSessionId
6617
+ this.topics.sessionForTopic(pending.threadId) !== pending.logicalSessionId
5339
6618
  )
5340
6619
  return;
5341
6620
  if (
@@ -5349,6 +6628,21 @@ export class TelegramNotificationDaemon {
5349
6628
  return;
5350
6629
  if (msg.status === "ok" && typeof msg.text !== "string") return;
5351
6630
  if (this.#stoppingBtw || this.#btwTerminalDeliveries.has(requestId)) return;
6631
+ const isAuthoritative = (): boolean =>
6632
+ !this.#stoppingBtw &&
6633
+ this.#leaseTokenAllows(pending.socketLease) &&
6634
+ this.sessions.get(session.sessionId) === session &&
6635
+ session.ws.readyState === WebSocket.OPEN &&
6636
+ pending.endpointDigest === session.endpointDigest &&
6637
+ pending.generation === session.hostGeneration &&
6638
+ pending.logicalSessionId === this.#logicalSessionId(session) &&
6639
+ pending.transportSessionId === session.sessionId &&
6640
+ requestId === msg.requestId &&
6641
+ pending.logicalSessionId === msg.sessionId &&
6642
+ pending.messageId === msg.messageId &&
6643
+ pending.threadId === msg.threadId &&
6644
+ pending.updateId === msg.updateId &&
6645
+ this.topics.sessionForTopic(pending.threadId) === pending.logicalSessionId;
5352
6646
  const finished = Promise.withResolvers<void>();
5353
6647
  const terminalDelivery: PendingBtwDelivery = {
5354
6648
  pending,
@@ -5377,6 +6671,7 @@ export class TelegramNotificationDaemon {
5377
6671
  messageId: pending.messageId,
5378
6672
  text,
5379
6673
  signal: terminalDelivery.controller.signal,
6674
+ isAuthoritative,
5380
6675
  });
5381
6676
  deliveryOutcome =
5382
6677
  response && typeof response === "object" && (response as { ok?: unknown }).ok === true
@@ -5391,20 +6686,6 @@ export class TelegramNotificationDaemon {
5391
6686
  return;
5392
6687
  }
5393
6688
  const markdown = msg.text;
5394
- const isAuthoritative = (): boolean =>
5395
- !this.#stoppingBtw &&
5396
- this.sessions.get(session.sessionId) === session &&
5397
- session.ws.readyState === WebSocket.OPEN &&
5398
- pending.endpointDigest === session.endpointDigest &&
5399
- pending.generation === session.hostGeneration &&
5400
- pending.logicalSessionId === this.#logicalSessionId(session) &&
5401
- pending.transportSessionId === session.sessionId &&
5402
- requestId === msg.requestId &&
5403
- pending.logicalSessionId === msg.sessionId &&
5404
- pending.messageId === msg.messageId &&
5405
- pending.threadId === msg.threadId &&
5406
- pending.updateId === msg.updateId &&
5407
- this.topics.sessionForTopic(pending.threadId) === pending.transportSessionId;
5408
6689
  const signal = AbortSignal.any([
5409
6690
  this.#btwDeliveryAbort.signal,
5410
6691
  terminalDelivery.controller.signal,
@@ -5531,13 +6812,13 @@ export class TelegramNotificationDaemon {
5531
6812
  toolAdmissionEpoch === this.toolActivityPolicyEpoch);
5532
6813
  const abandonStaleToolStart = (): void => {
5533
6814
  if (toolActivity?.phase !== "started") return;
5534
- const key = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
6815
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
5535
6816
  const owner = this.toolActivityOwners.get(key);
5536
6817
  if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
5537
6818
  this.toolActivityOwners.delete(key);
5538
6819
  };
5539
6820
  if (toolActivity) {
5540
- const liveKey = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
6821
+ const liveKey = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
5541
6822
  const currentOwner = this.toolActivityOwners.get(liveKey);
5542
6823
  if (toolActivity.phase === "started") {
5543
6824
  if (!toolStartIsCurrent()) return;
@@ -5562,102 +6843,106 @@ export class TelegramNotificationDaemon {
5562
6843
  }
5563
6844
  const send = renderThreadedFrame(msg);
5564
6845
  if (!send) return;
5565
- const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
6846
+ const transportLogicalSessionId = this.#logicalSessionId(session);
6847
+ // Preserve legacy identity routing for direct/non-replay session callers.
6848
+ // Authenticated transports never infer topic ownership from display identity:
6849
+ // a resume must bind only its proven logical session.
6850
+ const logicalSessionId =
6851
+ send.identity && !session.logicalSessionIdTrusted && this.sessions.get(session.sessionId) !== session
6852
+ ? (this.topicOwnerForIdentity(msg) ?? transportLogicalSessionId)
6853
+ : transportLogicalSessionId;
6854
+ if (!this.#leaseAllows(session, logicalSessionId)) return;
6855
+ const socketLease = this.#socketLease(session, logicalSessionId);
6856
+ if (!socketLease) return;
6857
+ const existingTopic = await this.existingTopicForPrivateChat(logicalSessionId);
5566
6858
  if (!toolStartIsCurrent()) {
5567
6859
  abandonStaleToolStart();
5568
6860
  return;
5569
6861
  }
5570
- if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
5571
- if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
5572
- if (!toolStartIsCurrent()) {
5573
- abandonStaleToolStart();
5574
- return;
5575
- }
6862
+ if (
6863
+ this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
6864
+ this.topics.get(logicalSessionId)?.bindingMalformed
6865
+ )
6866
+ return;
6867
+ if (!send.identity && !existingTopic && !this.flatIdentitySent.has(logicalSessionId)) {
5576
6868
  this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
5577
6869
  return;
5578
6870
  }
5579
- if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
5580
- const ownerId = this.topicOwnerForIdentity(msg);
5581
- const ownerLease = ownerId ? await this.topicAuthorityLease(ownerId) : undefined;
5582
- if (ownerId && ownerId !== session.sessionId && ownerLease) {
5583
- await this.flushPendingThreadedFrames(session.sessionId, ownerLease);
5584
- return;
5585
- }
5586
- }
5587
6871
  const topicId =
5588
- existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
5589
- const topicLease = await this.topicAuthorityLease(session.sessionId);
5590
- if (!toolStartIsCurrent()) {
5591
- abandonStaleToolStart();
5592
- return;
5593
- }
6872
+ existingTopic ??
6873
+ (await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session));
6874
+ const topicLease = await this.topicAuthorityLease(logicalSessionId);
5594
6875
  if (!topicId || !topicLease || topicLease.topicId !== topicId) {
5595
- if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
5596
- await this.deliverFlatFallback(session.sessionId, send, toolActivity);
6876
+ if (
6877
+ this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
6878
+ this.topics.get(logicalSessionId)?.bindingMalformed
6879
+ )
6880
+ return;
6881
+ await this.deliverFlatFallback(logicalSessionId, send, toolActivity, socketLease);
5597
6882
  return;
5598
6883
  }
5599
6884
  if (send.identity) {
5600
6885
  const identityKey = this.topicIdentityKey(msg);
5601
6886
  if (identityKey) {
5602
- this.topicOwnerByIdentity.set(identityKey, session.sessionId);
5603
- if (this.topics.markIdentityKey(session.sessionId, identityKey)) await this.persistTopics();
6887
+ this.topicOwnerByIdentity.set(identityKey, logicalSessionId);
6888
+ if (this.topics.markIdentityKey(logicalSessionId, identityKey)) await this.persistTopics();
5604
6889
  }
5605
- // Explicit Telegram-side user renames own the topic title. Pending user
5606
- // reconciliation runs before daemon identity naming, so retries and daemon
5607
- // restarts cannot silently replace the preserved name.
5608
6890
  await this.reconcileUserTopicName(topicLease);
5609
- const name = this.topicNameFor(session.sessionId, msg);
5610
- if (this.topics.needsRename(session.sessionId, name)) {
5611
- this.daemonRenameAttempts.set(
5612
- session.sessionId,
5613
- (this.daemonRenameAttempts.get(session.sessionId) ?? 0) + 1,
5614
- );
6891
+ const name = this.topicNameFor(logicalSessionId, msg);
6892
+ if (this.topics.needsRename(logicalSessionId, name)) {
5615
6893
  try {
5616
- if (!this.topicLeaseIsCurrent(topicLease)) return;
6894
+ if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return;
6895
+ this.daemonRenameAttempts.set(
6896
+ logicalSessionId,
6897
+ (this.daemonRenameAttempts.get(logicalSessionId) ?? 0) + 1,
6898
+ );
5617
6899
  const response = await this.botApi.call("editForumTopic", {
5618
6900
  chat_id: this.opts.chatId,
5619
6901
  message_thread_id: Number(topicId),
5620
6902
  name,
5621
6903
  });
5622
- if (topicRenameApplied(response)) this.topics.markNameApplied(session.sessionId, name);
6904
+ if (topicRenameApplied(response)) this.topics.markNameApplied(logicalSessionId, name);
5623
6905
  } catch {
5624
- // Best-effort rename; leave daemon-owned names unchanged so a
5625
- // later identity frame retries.
6906
+ // A later identity frame retries a transient daemon rename failure.
5626
6907
  } finally {
5627
- const remaining = (this.daemonRenameAttempts.get(session.sessionId) ?? 1) - 1;
5628
- if (remaining > 0) this.daemonRenameAttempts.set(session.sessionId, remaining);
5629
- else {
5630
- this.daemonRenameAttempts.delete(session.sessionId);
5631
- await this.reconcileUserTopicName(topicLease);
5632
- }
6908
+ const attempts = (this.daemonRenameAttempts.get(logicalSessionId) ?? 1) - 1;
6909
+ if (attempts > 0) this.daemonRenameAttempts.set(logicalSessionId, attempts);
6910
+ else this.daemonRenameAttempts.delete(logicalSessionId);
5633
6911
  }
5634
6912
  }
5635
- // Send the full bulleted identity header EXACTLY ONCE per topic.
5636
- if (this.topics.needsIdentity(session.sessionId)) {
5637
- await this.submitThreadedFrame(session.sessionId, send, topicLease);
5638
- this.topics.markIdentitySent(session.sessionId);
6913
+ if (this.topics.needsIdentity(logicalSessionId)) {
6914
+ await this.submitThreadedFrame(logicalSessionId, send, topicLease, undefined, socketLease);
6915
+ this.topics.markIdentitySent(logicalSessionId);
5639
6916
  }
5640
- await this.flushPendingThreadedFrames(session.sessionId, topicLease);
5641
6917
  await this.persistTopics();
6918
+ await this.flushPendingThreadedFrames(logicalSessionId, topicLease);
6919
+ await this.reconcileUserTopicName(topicLease);
5642
6920
  return;
5643
6921
  }
5644
6922
  if (!toolStartIsCurrent()) {
5645
6923
  abandonStaleToolStart();
5646
6924
  return;
5647
6925
  }
5648
- await this.submitThreadedFrame(session.sessionId, send, topicLease, toolActivity);
6926
+ await this.submitThreadedFrame(logicalSessionId, send, topicLease, toolActivity, socketLease);
5649
6927
  return;
5650
6928
  }
5651
6929
  if (msg.type === "action_needed" && msg.id) {
5652
- if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
5653
- if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
5654
- const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
5655
- const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(session.sessionId) : undefined;
6930
+ const logicalSessionId = this.#logicalSessionId(session);
6931
+ const socketLease = this.#socketLease(session, logicalSessionId);
6932
+ if (!socketLease) return;
6933
+ if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: logicalSessionId, actionId: msg.id });
6934
+ if (
6935
+ this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
6936
+ this.topics.get(logicalSessionId)?.bindingMalformed
6937
+ )
6938
+ return;
6939
+ const topicId = await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session);
6940
+ const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(logicalSessionId) : undefined;
5656
6941
  if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
5657
6942
  if (!topicId) {
5658
6943
  // Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
5659
6944
  if (!(await this.pairedChatIsPrivate())) return;
5660
- await this.notifyThreadedFallback();
6945
+ await this.notifyThreadedFallback(socketLease);
5661
6946
  }
5662
6947
  const threadField = topicLease ? { message_thread_id: Number(topicLease.topicId) } : {};
5663
6948
  const controls: Array<{
@@ -5695,13 +6980,13 @@ export class TelegramNotificationDaemon {
5695
6980
  const options = Array.isArray(msg.options) ? msg.options : [];
5696
6981
  const inline_keyboard = [
5697
6982
  ...buildCompactChoiceGrid(options, (i: number) =>
5698
- this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
6983
+ this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
5699
6984
  ),
5700
6985
  ...controls.map(control => [
5701
6986
  {
5702
6987
  text: control.label,
5703
6988
  callback_data: this.aliasTable.put({
5704
- sessionId: session.sessionId,
6989
+ sessionId: logicalSessionId,
5705
6990
  actionId: msg.id,
5706
6991
  answer: { controlId: control.id },
5707
6992
  }),
@@ -5714,7 +6999,8 @@ export class TelegramNotificationDaemon {
5714
6999
  const chunks = splitTelegramHtml(rendered.text);
5715
7000
  let result: { result?: { message_id?: number } } = {};
5716
7001
  for (let i = 0; i < chunks.length; i++) {
5717
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return undefined;
7002
+ if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
7003
+ return undefined;
5718
7004
  result = (await this.botApi.call("sendMessage", {
5719
7005
  chat_id: this.opts.chatId,
5720
7006
  ...threadField,
@@ -5730,7 +7016,7 @@ export class TelegramNotificationDaemon {
5730
7016
  // Rich (default on): promote to sendRichMessage with a top-level
5731
7017
  // reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
5732
7018
 
5733
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
7019
+ if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease))) return;
5734
7020
  const outcome = await deliverRichActionWithFallback(
5735
7021
  this.botApi,
5736
7022
  { chat_id: this.opts.chatId, ...threadField },
@@ -5749,21 +7035,22 @@ export class TelegramNotificationDaemon {
5749
7035
  );
5750
7036
  // Only asks are reply-routable; idle pings register no route.
5751
7037
  if (kind === "ask" && outcome.messageId !== undefined)
5752
- this.messageRoutes.set(String(outcome.messageId), { sessionId: session.sessionId, actionId: msg.id });
7038
+ this.messageRoutes.set(String(outcome.messageId), { sessionId: logicalSessionId, actionId: msg.id });
5753
7039
  } else {
5754
7040
  // Off: byte-identical to the pre-rich HTML path.
5755
7041
  const messageId = await sendHtmlChunks();
5756
7042
  // Only asks are reply-routable; idle pings register no route (parity
5757
7043
  // with the rich branch and correct even in the byte-identical off path).
5758
7044
  if (kind === "ask" && messageId !== undefined)
5759
- this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
7045
+ this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
5760
7046
  }
5761
7047
  await this.persistAliases();
5762
7048
  } else if (msg.type === "action_resolved" && msg.id) {
5763
7049
  session.pending.delete(msg.id);
5764
- this.deleteMessageRoutes(session.sessionId, msg.id);
7050
+ this.deleteMessageRoutes(this.#logicalSessionId(session), msg.id);
5765
7051
  for (const [alias, route] of this.aliasTable.entries()) {
5766
- if (route.sessionId === session.sessionId && route.actionId === msg.id) this.aliasTable.delete(alias);
7052
+ if (route.sessionId === this.#logicalSessionId(session) && route.actionId === msg.id)
7053
+ this.aliasTable.delete(alias);
5767
7054
  }
5768
7055
  await this.persistAliases();
5769
7056
  }
@@ -5810,7 +7097,9 @@ export class TelegramNotificationDaemon {
5810
7097
  if (
5811
7098
  this.sessions.get(session.sessionId) !== session ||
5812
7099
  this.#logicalSessionId(session) !== route.sessionId ||
5813
- session.ws.readyState !== WebSocket.OPEN
7100
+ session.ws.readyState !== WebSocket.OPEN ||
7101
+ !this.#leaseTokenAllows(route.socketLease) ||
7102
+ (this.topics.get(route.sessionId) !== undefined && !this.topicAuthorityLeaseFromRegistry(route.sessionId))
5814
7103
  ) {
5815
7104
  this.#modelChoiceAliases.delete(alias);
5816
7105
  await this.#sendModelStaleGuidance(callbackId);
@@ -5822,6 +7111,13 @@ export class TelegramNotificationDaemon {
5822
7111
  const updateId = (update as { update_id?: unknown }).update_id;
5823
7112
  const safeUpdateId =
5824
7113
  typeof updateId === "number" && Number.isSafeInteger(updateId) && updateId >= 0 ? updateId : undefined;
7114
+ if (
7115
+ !this.#leaseTokenAllows(route.socketLease) ||
7116
+ (this.topics.get(route.sessionId) !== undefined && !this.topicAuthorityLeaseFromRegistry(route.sessionId))
7117
+ ) {
7118
+ await this.#sendModelStaleGuidance(callbackId);
7119
+ return true;
7120
+ }
5825
7121
  try {
5826
7122
  session.ws.send(
5827
7123
  JSON.stringify({
@@ -6095,7 +7391,40 @@ export class TelegramNotificationDaemon {
6095
7391
  if (!raw.callback_query && (!isAskReply || interceptAskControl || reservedBtw !== undefined)) {
6096
7392
  const inbound = decideThreadedInbound(update as never, {
6097
7393
  pairedChatId: this.opts.chatId,
6098
- topicToSession: t => this.topics.sessionForTopic(t),
7394
+ topicToSession: t => {
7395
+ const topicSessionId = this.topics.sessionForTopic(t);
7396
+ if (!topicSessionId) return undefined;
7397
+ const legacyOwner = this.legacyTopicOwners.get(topicSessionId);
7398
+ if (this.preservedInitiatorTopics.has(topicSessionId)) return undefined;
7399
+ const owner =
7400
+ this.logicalSessionOwners.get(topicSessionId) ??
7401
+ legacyOwner ??
7402
+ [...this.sessions.values()].find(
7403
+ session =>
7404
+ (session.sessionId === topicSessionId || this.#logicalSessionId(session) === topicSessionId) &&
7405
+ this.#leaseAllows(session, topicSessionId),
7406
+ );
7407
+ if (owner) {
7408
+ if (owner.replayPending || owner.recoveryLease?.state === "pending") return undefined;
7409
+ return this.#leaseAllows(owner, topicSessionId) ? owner.logicalSessionId : undefined;
7410
+ }
7411
+ const transportOwner = [...this.sessions.values()].find(
7412
+ candidate => candidate.sessionId === topicSessionId && this.#leaseAllows(candidate),
7413
+ );
7414
+ if (transportOwner) {
7415
+ if (transportOwner.replayPending || transportOwner.recoveryLease?.state === "pending")
7416
+ return undefined;
7417
+ return transportOwner.logicalSessionId;
7418
+ }
7419
+ return [...this.sessions.values()].some(
7420
+ session =>
7421
+ session.sessionId === topicSessionId ||
7422
+ this.#logicalSessionId(session) === topicSessionId ||
7423
+ session.recoveryLease?.logicalSessionId === topicSessionId,
7424
+ )
7425
+ ? undefined
7426
+ : topicSessionId;
7427
+ },
6099
7428
  isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
6100
7429
  });
6101
7430
  if (inbound.kind === "duplicate") return;
@@ -6105,10 +7434,45 @@ export class TelegramNotificationDaemon {
6105
7434
  ? { kind: "none" as const }
6106
7435
  : parseTelegramControlCommand(inbound.text, this.botUsername);
6107
7436
  if (preliminaryControl.kind === "ignored") return;
6108
- const session = this.sessions.get(inbound.sessionId);
7437
+ const session =
7438
+ this.logicalSessionOwners.get(inbound.sessionId) ??
7439
+ this.sessions.get(inbound.sessionId) ??
7440
+ [...this.sessions.values()].find(
7441
+ candidate =>
7442
+ this.#logicalSessionId(candidate) === inbound.sessionId &&
7443
+ this.#leaseAllows(candidate, inbound.sessionId),
7444
+ );
7445
+ if (session && !this.#leaseAllows(session, inbound.sessionId)) return;
7446
+ const topicSessionId = this.topics.sessionForTopic(inbound.threadId);
7447
+ const topicLease = topicSessionId ? this.topicAuthorityLeaseFromRegistry(topicSessionId) : undefined;
7448
+ const topicLeaseAllows = (): boolean => {
7449
+ const current = topicSessionId ? this.topicAuthorityLeaseFromRegistry(topicSessionId) : undefined;
7450
+ return (
7451
+ !!topicLease &&
7452
+ current?.topicId === topicLease.topicId &&
7453
+ current.authorityEpoch === topicLease.authorityEpoch
7454
+ );
7455
+ };
7456
+ const routeLease = session ? this.#socketLease(session, inbound.sessionId) : undefined;
7457
+ const routeAllows = (): boolean =>
7458
+ !!session &&
7459
+ !!routeLease &&
7460
+ session.ws.readyState === WebSocket.OPEN &&
7461
+ this.#leaseTokenAllows(routeLease) &&
7462
+ topicLeaseAllows();
7463
+ const routeLeaseAllows = (): boolean =>
7464
+ topicLeaseAllows() && (!session || (!!routeLease && this.#leaseTokenAllows(routeLease)));
7465
+ const reserveRouteUpdate = async (): Promise<boolean> => {
7466
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return false;
7467
+ if (routeLeaseAllows()) return true;
7468
+ await this.releaseSeenUpdateId(inbound.updateId);
7469
+ return false;
7470
+ };
7471
+
7472
+ if (session && !routeLease) return;
6109
7473
  if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
6110
7474
  if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
6111
- if (await this.reserveSeenUpdateId(inbound.updateId)) {
7475
+ if (await reserveRouteUpdate()) {
6112
7476
  try {
6113
7477
  await this.botApi.call("sendMessage", {
6114
7478
  chat_id: this.opts.chatId,
@@ -6127,36 +7491,41 @@ export class TelegramNotificationDaemon {
6127
7491
  return;
6128
7492
  }
6129
7493
  if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
6130
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7494
+ if (!(await reserveRouteUpdate())) return;
6131
7495
  await this.#sendBtwMessage({
6132
7496
  threadId: inbound.threadId,
6133
7497
  messageId: inbound.messageId,
6134
7498
  text: BTW_USAGE_TEXT,
7499
+ isAuthoritative: routeLeaseAllows,
6135
7500
  });
6136
7501
  return;
6137
7502
  }
6138
7503
  if (reservedBtw?.kind === "question" && this.opts.btw?.enabled === false) {
6139
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7504
+ if (!(await reserveRouteUpdate())) return;
6140
7505
  await this.#sendBtwMessage({
6141
7506
  threadId: inbound.threadId,
6142
7507
  messageId: inbound.messageId,
6143
7508
  text: "Telegram /btw is disabled in local settings.",
7509
+ isAuthoritative: routeLeaseAllows,
6144
7510
  });
6145
7511
  return;
6146
7512
  }
6147
7513
  if (reservedBtw?.kind === "question" && session?.ws.readyState !== WebSocket.OPEN) {
6148
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7514
+ if (!(await reserveRouteUpdate())) return;
6149
7515
  await this.#sendBtwMessage({
6150
7516
  threadId: inbound.threadId,
6151
7517
  messageId: inbound.messageId,
6152
7518
  text: "Restart this GJC session to enable /btw.",
7519
+ isAuthoritative: routeLeaseAllows,
6153
7520
  });
6154
7521
  return;
6155
7522
  }
6156
- if (session?.ws.readyState === WebSocket.OPEN) {
7523
+ if (!session || !routeLease) return;
7524
+ if (routeAllows()) {
6157
7525
  const attachmentResult = inbound.attachment
6158
7526
  ? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
6159
7527
  : undefined;
7528
+ if (!routeAllows()) return;
6160
7529
  const images = attachmentResult?.images ?? [];
6161
7530
  const fileNotes = attachmentResult?.fileNotes ?? [];
6162
7531
  const hasMedia = inbound.attachment !== undefined || images.length > 0 || fileNotes.length > 0;
@@ -6179,11 +7548,12 @@ export class TelegramNotificationDaemon {
6179
7548
  if (btw?.kind === "question") {
6180
7549
  const btwQuestion = btw.question;
6181
7550
  if (!btwQuestion || hasMedia) {
6182
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7551
+ if (!(await reserveRouteUpdate())) return;
6183
7552
  await this.#sendBtwMessage({
6184
7553
  threadId: inbound.threadId,
6185
7554
  messageId: inbound.messageId,
6186
7555
  text: BTW_USAGE_TEXT,
7556
+ isAuthoritative: routeLeaseAllows,
6187
7557
  });
6188
7558
  return;
6189
7559
  }
@@ -6193,28 +7563,31 @@ export class TelegramNotificationDaemon {
6193
7563
  threadId: inbound.threadId,
6194
7564
  messageId: inbound.messageId,
6195
7565
  text: BTW_QUESTION_LIMIT_TEXT,
7566
+ isAuthoritative: routeLeaseAllows,
6196
7567
  });
6197
7568
  } catch {
6198
7569
  logger.warn("notifications: /btw question-limit delivery failed");
6199
7570
  }
6200
- await this.reserveSeenUpdateId(inbound.updateId);
7571
+ await reserveRouteUpdate();
6201
7572
  return;
6202
7573
  }
6203
7574
  if (this.opts.btw?.enabled === false) {
6204
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7575
+ if (!(await reserveRouteUpdate())) return;
6205
7576
  await this.#sendBtwMessage({
6206
7577
  threadId: inbound.threadId,
6207
7578
  messageId: inbound.messageId,
6208
7579
  text: "Telegram /btw is disabled in local settings.",
7580
+ isAuthoritative: routeLeaseAllows,
6209
7581
  });
6210
7582
  return;
6211
7583
  }
6212
7584
  if (!session.ephemeralCapable || session.hostGeneration < 1) {
6213
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7585
+ if (!(await reserveRouteUpdate())) return;
6214
7586
  await this.#sendBtwMessage({
6215
7587
  threadId: inbound.threadId,
6216
7588
  messageId: inbound.messageId,
6217
7589
  text: "Restart this GJC session to enable /btw.",
7590
+ isAuthoritative: routeLeaseAllows,
6218
7591
  });
6219
7592
  return;
6220
7593
  }
@@ -6230,21 +7603,23 @@ export class TelegramNotificationDaemon {
6230
7603
  this.#btwTerminalTombstones.delete(this.#btwTerminalTombstones.keys().next().value!);
6231
7604
  }
6232
7605
  if (this.#pendingBtwTurns.size >= BTW_MAX_PENDING) {
6233
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7606
+ if (!(await reserveRouteUpdate())) return;
6234
7607
  await this.#sendBtwMessage({
6235
7608
  threadId: inbound.threadId,
6236
7609
  messageId: inbound.messageId,
6237
7610
  text: BTW_CAPACITY_TEXT,
7611
+ isAuthoritative: routeLeaseAllows,
6238
7612
  });
6239
7613
  return;
6240
7614
  }
6241
7615
  const transportSessionId = session.sessionId;
6242
7616
  const logicalSessionId = this.#logicalSessionId(session);
6243
7617
  const requestId = `btw:${crypto.randomUUID()}`;
6244
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7618
+ if (!(await reserveRouteUpdate())) return;
6245
7619
  const pending: PendingBtwTurn = {
6246
7620
  transportSessionId,
6247
7621
  logicalSessionId,
7622
+ socketLease: routeLease,
6248
7623
  endpointDigest: session.endpointDigest,
6249
7624
  generation: session.hostGeneration,
6250
7625
  question: btwQuestion,
@@ -6260,6 +7635,7 @@ export class TelegramNotificationDaemon {
6260
7635
  threadId: inbound.threadId,
6261
7636
  messageId: inbound.messageId,
6262
7637
  text: "Unable to start /btw because this GJC session disconnected. Reopen the session and try again.",
7638
+ isAuthoritative: routeLeaseAllows,
6263
7639
  });
6264
7640
  return;
6265
7641
  }
@@ -6267,9 +7643,10 @@ export class TelegramNotificationDaemon {
6267
7643
  }
6268
7644
  const control = hasMedia ? { kind: "none" as const } : preliminaryControl;
6269
7645
  if (control.kind !== "none") {
6270
- await this.rememberSeenUpdateId(inbound.updateId);
7646
+ if (!(await reserveRouteUpdate())) return;
6271
7647
  const sendControlNotice = async (body: string): Promise<void> => {
6272
7648
  try {
7649
+ if (!routeLeaseAllows()) return;
6273
7650
  await this.botApi.call("sendMessage", {
6274
7651
  chat_id: this.opts.chatId,
6275
7652
  message_thread_id: Number(inbound.threadId),
@@ -6288,6 +7665,7 @@ export class TelegramNotificationDaemon {
6288
7665
  await sendControlNotice("Session control unavailable: session is disconnected.");
6289
7666
  return;
6290
7667
  }
7668
+ if (!routeAllows()) return;
6291
7669
  session.ws.send(
6292
7670
  JSON.stringify({
6293
7671
  type: "control_command",
@@ -6307,6 +7685,7 @@ export class TelegramNotificationDaemon {
6307
7685
  // Telegram asks always accept custom text (the SDK maps a string answer
6308
7686
  // to the ask's custom-input slot), so route the latest pending ask here.
6309
7687
  const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
7688
+ if (!routeAllows()) return;
6310
7689
  if (pendingAsk) {
6311
7690
  session.ws.send(
6312
7691
  JSON.stringify({
@@ -6317,9 +7696,11 @@ export class TelegramNotificationDaemon {
6317
7696
  }),
6318
7697
  );
6319
7698
  await this.rememberSeenUpdateId(inbound.updateId);
6320
- if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
7699
+ if (inbound.messageId !== undefined)
7700
+ await this.setReaction(inbound.messageId, QUEUED_REACTION, routeLease);
6321
7701
  return;
6322
7702
  }
7703
+ if (!routeAllows()) return;
6323
7704
  session.ws.send(
6324
7705
  JSON.stringify(
6325
7706
  cfg
@@ -6340,8 +7721,12 @@ export class TelegramNotificationDaemon {
6340
7721
  // flipped to consumed when the session acks the turn that picks it
6341
7722
  // up. Config commands are not user turns and get no reaction.
6342
7723
  if (!cfg && inbound.messageId !== undefined) {
6343
- this.inboundReactions.set(inbound.updateId, { messageId: inbound.messageId });
6344
- await this.setReaction(inbound.messageId, QUEUED_REACTION);
7724
+ if (!routeAllows()) return;
7725
+ this.inboundReactions.set(inbound.updateId, {
7726
+ messageId: inbound.messageId,
7727
+ socketLease: routeLease,
7728
+ });
7729
+ await this.setReaction(inbound.messageId, QUEUED_REACTION, routeLease);
6345
7730
  }
6346
7731
  }
6347
7732
  return;
@@ -6355,8 +7740,14 @@ export class TelegramNotificationDaemon {
6355
7740
  pairedChatId: this.opts.chatId,
6356
7741
  });
6357
7742
  if (decision.kind === "reply") {
6358
- const session = this.sessions.get(decision.sessionId);
6359
- if (session?.ws.readyState !== WebSocket.OPEN || !session.pending.has(decision.actionId)) {
7743
+ const session = this.logicalSessionOwners.get(decision.sessionId) ?? this.sessions.get(decision.sessionId);
7744
+ if (
7745
+ session?.ws.readyState !== WebSocket.OPEN ||
7746
+ !session.pending.has(decision.actionId) ||
7747
+ !this.#leaseAllows(session, decision.sessionId) ||
7748
+ (this.topics.get(decision.sessionId) !== undefined &&
7749
+ !this.topicAuthorityLeaseFromRegistry(decision.sessionId))
7750
+ ) {
6360
7751
  await this.sendStaleGuidance(callbackId);
6361
7752
  return;
6362
7753
  }
@@ -6407,31 +7798,38 @@ export class TelegramNotificationDaemon {
6407
7798
  }
6408
7799
 
6409
7800
  async run(): Promise<void> {
6410
- // Runtime callers can bypass TypeScript's option type. Do not derive an
6411
- // ownership identity from absent credentials; lifecycle startup still owns
6412
- // cleanup of a factory-created server on this fail-closed path.
6413
- if (!validBotToken(this.opts.botToken)) {
6414
- await this.startLifecycleControl();
6415
- return;
6416
- }
6417
- this.running = await renewDaemonHeartbeat({
6418
- settings: this.opts.settings,
6419
- ownerId: this.opts.ownerId,
6420
- acquisitionId: this.opts.ownerId,
6421
- tokenFingerprint: tokenFingerprint(this.opts.botToken),
6422
- chatId: this.opts.chatId,
6423
- fs: this.fsImpl,
6424
- now: this.opts.now,
6425
- pid: this.opts.pid ?? process.pid,
6426
- pidIncarnation: this.opts.pidIncarnation,
6427
- });
6428
- if (!this.running) return;
6429
- this.runtime.start();
6430
- this.startOwnershipHeartbeatTimer();
6431
- this.startFlushTimer();
6432
- this.startScanTimer();
6433
- this.startTypingTimer();
7801
+ // Runtime callers can bypass TypeScript's option type. Without a valid bot
7802
+ // token, there is no authenticated daemon identity or lifecycle authority.
7803
+ if (!validBotToken(this.opts.botToken)) return;
7804
+ let ownershipProved = false;
6434
7805
  try {
7806
+ const renewed = await renewDaemonHeartbeat({
7807
+ settings: this.opts.settings,
7808
+ ownerId: this.opts.ownerId,
7809
+ acquisitionId: this.opts.ownerId,
7810
+ tokenFingerprint: tokenFingerprint(this.opts.botToken),
7811
+ chatId: this.opts.chatId,
7812
+ fs: this.fsImpl,
7813
+ now: this.opts.now,
7814
+ pid: this.opts.pid ?? process.pid,
7815
+ pidIncarnation: this.opts.pidIncarnation,
7816
+ });
7817
+ if (!renewed) return;
7818
+ ownershipProved = true;
7819
+ this.running = !this.stopRequested;
7820
+ if (!this.running) return;
7821
+ // Owner-only: start lifecycle control immediately after ownership proof,
7822
+ // before timers or pre-poll startup work can invalidate this run.
7823
+ // Best-effort; notification delivery remains available on failure.
7824
+ await this.startLifecycleControl();
7825
+ // A stop may arrive while lifecycle startup awaits its control endpoint.
7826
+ // Do not re-enable runtime work after that stop; close the partial server.
7827
+ if (!this.running) return;
7828
+ this.runtime.start();
7829
+ this.startOwnershipHeartbeatTimer();
7830
+ this.startFlushTimer();
7831
+ this.startScanTimer();
7832
+ this.startTypingTimer();
6435
7833
  await this.refreshBotIdentity();
6436
7834
  await this.registerBotCommands();
6437
7835
  await this.loadAliases();
@@ -6439,9 +7837,6 @@ export class TelegramNotificationDaemon {
6439
7837
  await this.loadSeenUpdateIds();
6440
7838
  await this.replyStore.load();
6441
7839
  await this.runScan();
6442
- // Owner-only: start the session-lifecycle control server now that
6443
- // ownership is confirmed (singleton-safe). Best-effort; degrades.
6444
- await this.startLifecycleControl();
6445
7840
  let idleSince = this.runtime.now();
6446
7841
  while (this.running) {
6447
7842
  if (await this.controlStopRequested()) break;
@@ -6493,61 +7888,64 @@ export class TelegramNotificationDaemon {
6493
7888
  await this.runtime.sleep(10);
6494
7889
  }
6495
7890
  } finally {
6496
- let toolShutdownError: unknown;
6497
- try {
6498
- await this.beginToolActivityShutdown();
6499
- } catch (error) {
6500
- toolShutdownError = error;
6501
- }
6502
- this.effects.beginShutdown();
6503
- this.#deliveryAbort.abort();
6504
- let persisted = false;
6505
- const shutdown = this.effects.allowTerminal(async () => {
6506
- if (toolShutdownError) throw toolShutdownError;
6507
- await this.#drainBtwTurns();
6508
- await this.toolTerminalizationChain;
6509
- this.runtime.stop();
6510
- this.stopOwnershipHeartbeatTimer();
6511
- this.stopFlushTimer();
6512
- this.stopScanTimer();
6513
- this.stopTypingTimer();
6514
- this.stopLifecycleControl();
6515
- await this.cleanupAllAttachmentDirs();
6516
- await this.persistAliases();
6517
- await this.persistTopics();
6518
- await this.persistSeenUpdateIds();
6519
- await this.opts.control?.clear?.(this.opts.ownerId);
6520
- persisted = true;
6521
- });
6522
- const deadline = Promise.withResolvers<boolean>();
6523
- const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
6524
- const completed = await Promise.race([
6525
- shutdown.then(
6526
- () => true,
6527
- error => {
6528
- logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
6529
- return false;
6530
- },
6531
- ),
6532
- deadline.promise,
6533
- ]);
6534
- clearTimeout(deadlineTimer);
6535
- const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
6536
- if (!quiesced || !persisted) {
6537
- logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
6538
- } else {
6539
- await releaseDaemonOwnership({
6540
- settings: this.opts.settings,
6541
- ownerId: this.opts.ownerId,
6542
- acquisitionId: this.opts.ownerId,
6543
- tokenFingerprint: tokenFingerprint(this.opts.botToken),
6544
- chatId: this.opts.chatId,
6545
- pid: this.opts.pid ?? process.pid,
6546
- generation: DAEMON_GENERATION,
6547
- pidIncarnation: this.opts.pidIncarnation,
6548
- fs: this.fsImpl,
6549
- now: this.opts.now,
7891
+ // A contender must not mutate durable owner state while unwinding startup.
7892
+ if (ownershipProved) {
7893
+ let toolShutdownError: unknown;
7894
+ try {
7895
+ await this.beginToolActivityShutdown();
7896
+ } catch (error) {
7897
+ toolShutdownError = error;
7898
+ }
7899
+ this.effects.beginShutdown();
7900
+ this.#deliveryAbort.abort();
7901
+ let persisted = false;
7902
+ const shutdown = this.effects.allowTerminal(async () => {
7903
+ if (toolShutdownError) throw toolShutdownError;
7904
+ await this.#drainBtwTurns();
7905
+ await this.toolTerminalizationChain;
7906
+ this.runtime.stop();
7907
+ this.stopOwnershipHeartbeatTimer();
7908
+ this.stopFlushTimer();
7909
+ this.stopScanTimer();
7910
+ this.stopTypingTimer();
7911
+ this.stopLifecycleControl();
7912
+ await this.cleanupAllAttachmentDirs();
7913
+ await this.persistAliases();
7914
+ await this.persistTopics();
7915
+ await this.persistSeenUpdateIds();
7916
+ await this.opts.control?.clear?.(this.opts.ownerId);
7917
+ persisted = true;
6550
7918
  });
7919
+ const deadline = Promise.withResolvers<boolean>();
7920
+ const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
7921
+ const completed = await Promise.race([
7922
+ shutdown.then(
7923
+ () => true,
7924
+ error => {
7925
+ logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
7926
+ return false;
7927
+ },
7928
+ ),
7929
+ deadline.promise,
7930
+ ]);
7931
+ clearTimeout(deadlineTimer);
7932
+ const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
7933
+ if (!quiesced || !persisted) {
7934
+ logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
7935
+ } else {
7936
+ await releaseDaemonOwnership({
7937
+ settings: this.opts.settings,
7938
+ ownerId: this.opts.ownerId,
7939
+ acquisitionId: this.opts.ownerId,
7940
+ tokenFingerprint: tokenFingerprint(this.opts.botToken),
7941
+ chatId: this.opts.chatId,
7942
+ pid: this.opts.pid ?? process.pid,
7943
+ generation: DAEMON_GENERATION,
7944
+ pidIncarnation: this.opts.pidIncarnation,
7945
+ fs: this.fsImpl,
7946
+ now: this.opts.now,
7947
+ });
7948
+ }
6551
7949
  }
6552
7950
  }
6553
7951
  }