@gajae-code/coding-agent 0.11.6 → 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 (131) 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 +56 -6
  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 +2 -1
  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 +8 -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 +25 -4
  40. package/dist/types/setup/credential-import.d.ts +5 -1
  41. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  42. package/dist/types/ssh/utils.d.ts +1 -0
  43. package/dist/types/task/discovery.d.ts +3 -1
  44. package/dist/types/tools/fetch.d.ts +5 -8
  45. package/dist/types/tools/image-gen.d.ts +16 -0
  46. package/dist/types/tools/skill-discovery.d.ts +7 -0
  47. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  48. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  49. package/dist/types/web/insane/bridge.d.ts +1 -0
  50. package/dist/types/web/insane/url-guard.d.ts +17 -0
  51. package/package.json +8 -8
  52. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  53. package/src/capability/index.ts +50 -42
  54. package/src/capability/types.ts +3 -0
  55. package/src/cli/setup-cli.ts +10 -4
  56. package/src/cli.ts +34 -9
  57. package/src/commands/daemon.ts +22 -5
  58. package/src/commands/sdk.ts +42 -9
  59. package/src/config/keybindings.ts +1 -1
  60. package/src/config/model-profiles.ts +18 -0
  61. package/src/config/settings-schema.ts +57 -6
  62. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  63. package/src/discovery/ssh.ts +12 -1
  64. package/src/eval/py/kernel.ts +1 -21
  65. package/src/eval/py/runner-artifact.ts +84 -0
  66. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  67. package/src/gjc-runtime/ultragoal-guard.ts +88 -10
  68. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  69. package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
  70. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  71. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  72. package/src/internal-urls/docs-index.generated.ts +8 -7
  73. package/src/internal-urls/local-protocol.ts +32 -6
  74. package/src/main.ts +86 -27
  75. package/src/modes/action-registry.ts +1 -1
  76. package/src/modes/components/btw-panel.ts +82 -16
  77. package/src/modes/components/model-selector.ts +31 -3
  78. package/src/modes/components/tool-status-header.ts +6 -1
  79. package/src/modes/controllers/btw-controller.ts +119 -36
  80. package/src/modes/controllers/input-controller.ts +31 -0
  81. package/src/modes/controllers/selector-controller.ts +106 -12
  82. package/src/modes/interactive-mode.ts +16 -7
  83. package/src/modes/types.ts +1 -0
  84. package/src/prompts/system/btw-user.md +3 -8
  85. package/src/prompts/tools/skill-discovery.md +1 -0
  86. package/src/runtime-mcp/client.ts +41 -55
  87. package/src/runtime-mcp/content-limits.ts +71 -0
  88. package/src/runtime-mcp/json-rpc.ts +23 -9
  89. package/src/runtime-mcp/transports/http.ts +50 -16
  90. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  91. package/src/sdk/bus/index.ts +4 -5
  92. package/src/sdk/bus/lifecycle-commands.ts +16 -7
  93. package/src/sdk/bus/notification-service.ts +41 -18
  94. package/src/sdk/bus/telegram-daemon-contract.ts +8 -3
  95. package/src/sdk/bus/telegram-daemon-control.ts +2 -2
  96. package/src/sdk/bus/telegram-daemon.ts +1663 -375
  97. package/src/sdk/bus/topic-registry.ts +451 -12
  98. package/src/sdk/client/discovery.ts +19 -3
  99. package/src/sdk/client/liveness.ts +49 -0
  100. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  101. package/src/sdk/session.ts +29 -5
  102. package/src/sdk/transport/auth-preface.ts +82 -0
  103. package/src/sdk/transport/index.ts +18 -0
  104. package/src/sdk/transport/relay.ts +252 -0
  105. package/src/sdk/transport/serve-cli.ts +105 -0
  106. package/src/sdk/transport/socket.ts +197 -0
  107. package/src/sdk/transport/stdio.ts +17 -0
  108. package/src/session/agent-session.ts +240 -149
  109. package/src/session/blob-store.ts +19 -6
  110. package/src/session/btw-contract.ts +59 -0
  111. package/src/session/internal/managed-session-scope.ts +313 -57
  112. package/src/session/internal/managed-session-storage.ts +146 -35
  113. package/src/session/session-manager.ts +319 -85
  114. package/src/setup/credential-import.ts +28 -3
  115. package/src/setup/provider-onboarding.ts +30 -1
  116. package/src/setup/provider-presets.json +2 -1
  117. package/src/slash-commands/builtin-registry.ts +1 -1
  118. package/src/ssh/utils.ts +14 -0
  119. package/src/task/discovery.ts +8 -2
  120. package/src/tools/ask.ts +1 -1
  121. package/src/tools/fetch.ts +13 -131
  122. package/src/tools/image-gen.ts +301 -20
  123. package/src/tools/read.ts +2 -0
  124. package/src/tools/skill-discovery.ts +39 -3
  125. package/src/tools/sqlite-reader.ts +63 -5
  126. package/src/utils/pasted-image-path.ts +15 -6
  127. package/src/utils/shell-snapshot.ts +173 -63
  128. package/src/web/insane/bridge.ts +5 -1
  129. package/src/web/insane/url-guard.ts +138 -27
  130. package/src/web/scrapers/types.ts +19 -49
  131. 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 {
@@ -1050,7 +1067,7 @@ function classifyForeignLiveOwner(input: {
1050
1067
  if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
1051
1068
  // A stopped tombstone with a canonical acquisition is not a live owner.
1052
1069
  if (isStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
1053
- const currentIncarnation = input.pidIncarnation?.(state.pid) ?? defaultPidIncarnation(state.pid);
1070
+ const currentIncarnation = input.pidIncarnation ? input.pidIncarnation(state.pid) : defaultPidIncarnation(state.pid);
1054
1071
  if (
1055
1072
  !hasSafeDaemonStateShape(state) ||
1056
1073
  !isProcessIncarnation(state.incarnation) ||
@@ -1239,6 +1256,16 @@ export async function acquireDaemonOwnership(input: {
1239
1256
  return { acquired: false, attached: false, provisional: true };
1240
1257
  };
1241
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 };
1242
1269
  const foreignOwner = classifyForeignLiveOwner({
1243
1270
  state: existing,
1244
1271
  tokenFingerprint: input.tokenFingerprint,
@@ -1858,8 +1885,8 @@ function defaultPidAlive(pid: number): boolean {
1858
1885
  try {
1859
1886
  process.kill(pid, 0);
1860
1887
  return true;
1861
- } catch {
1862
- return false;
1888
+ } catch (error) {
1889
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
1863
1890
  }
1864
1891
  }
1865
1892
 
@@ -2044,35 +2071,49 @@ export async function spawnTelegramDaemonOwner(
2044
2071
  }
2045
2072
 
2046
2073
  /**
2047
- * Owner-bound reclamation of a confirmed-dead daemon owner's lock, mirroring the
2048
- * daemon step of `gjc notify recovery`. Removes only the lock of a recorded owner
2049
- * that is still the same, still-dead process — never a live or superseded owner,
2050
- * because it re-checks ownership and liveness while holding the same steal-mutex
2051
- * the daemon's own takeover path uses. Lets a fresh SDK session self-heal a stale
2052
- * owner (e.g. after a crash, or a `stop --force` that left the record behind)
2053
- * instead of requiring manual reconciliation. Returns true when a stale lock was
2054
- * cleared and a spawn retry is worthwhile.
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.
2055
2079
  */
2056
- async function reclaimDeadDaemonOwner(input: {
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: {
2057
2097
  settings: Settings;
2098
+ endpointDir?: string;
2058
2099
  fs?: TelegramDaemonFs;
2059
2100
  now?: () => number;
2060
2101
  pidAlive?: (pid: number) => boolean;
2061
2102
  pidIncarnation?: (pid: number) => string | undefined;
2062
- }): Promise<boolean> {
2103
+ }): Promise<DeadOwnerRecoveryResult> {
2063
2104
  const fsImpl = input.fs ?? nodeFs;
2064
2105
  const now = input.now ?? Date.now;
2065
2106
  const pidAlive = input.pidAlive ?? defaultPidAlive;
2066
2107
  const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
2067
2108
  const paths = daemonPaths(input.settings.getAgentDir());
2068
2109
  const state = await readDaemonState(input.settings, fsImpl);
2069
- if (!state || pidAlive(state.pid)) return false;
2110
+ if (!state || !hasSafeDaemonStateShape(state) || pidAlive(state.pid))
2111
+ return { recovered: false, reason: "not-confirmed-dead" };
2070
2112
  const lock = await readOwnershipLock(fsImpl, paths.lock);
2071
- if (lock.kind === "missing") return false;
2072
2113
  if (
2073
2114
  !(await ownershipLockIsReclaimable({ fs: fsImpl, path: paths.lock, lock, now: now(), pidAlive, pidIncarnation }))
2074
2115
  )
2075
- return false;
2116
+ return { recovered: false, reason: "unsafe-lock" };
2076
2117
  const transition = await acquireDaemonTransitionLock({
2077
2118
  fs: fsImpl,
2078
2119
  path: paths.steal,
@@ -2080,18 +2121,79 @@ async function reclaimDeadDaemonOwner(input: {
2080
2121
  pidAlive: defaultPidAlive,
2081
2122
  pidIncarnation: defaultPidIncarnation,
2082
2123
  });
2083
- if (!transition) return false;
2124
+ const readEndpointFile = fsImpl.readEndpointFile;
2125
+ if (!transition || !readEndpointFile || !fsImpl.exactUnlink)
2126
+ return { recovered: false, reason: "transition-contended" };
2084
2127
  try {
2085
2128
  const current = await readDaemonState(input.settings, fsImpl);
2086
- if (!current || current.ownerId !== state.ownerId || current.pid !== state.pid) return false;
2087
- if (pidAlive(current.pid)) return false;
2088
- if (!(await daemonTransitionLockIsHeld({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
2089
- try {
2090
- await fsImpl.unlink(paths.lock);
2091
- return true;
2092
- } catch {
2093
- return false;
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
+ }
2094
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" };
2095
2197
  } finally {
2096
2198
  await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
2097
2199
  }
@@ -2109,6 +2211,24 @@ export async function ensureTelegramDaemonRunningDetailed(
2109
2211
  if (!isTelegramConfigured(cfg)) return "disabled";
2110
2212
  const root = notificationRootForCwd(input.cwd);
2111
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
+ }
2112
2232
  let spawned = await withTelegramSetupLease(
2113
2233
  cfg.botToken,
2114
2234
  async () =>
@@ -2150,31 +2270,31 @@ export async function ensureTelegramDaemonRunningDetailed(
2150
2270
  );
2151
2271
  }
2152
2272
  }
2153
- if (
2154
- spawned.result === "blocked" &&
2155
- !spawned.warnings[0]?.includes("provisional") &&
2156
- (await reclaimDeadDaemonOwner({
2273
+ let recoveryReason: Extract<DeadOwnerRecoveryResult, { recovered: false }>["reason"] | undefined;
2274
+ if (spawned.result === "blocked" && !spawned.warnings[0]?.includes("provisional")) {
2275
+ const recovery = await reclaimDeadDaemonOwner({
2157
2276
  settings: input.settings,
2277
+ endpointDir: path.join(root, "sdk"),
2158
2278
  fs: deps.fs,
2159
2279
  now: deps.now,
2160
2280
  pidAlive: deps.pidAlive,
2161
2281
  pidIncarnation: deps.pidIncarnation,
2162
- }))
2163
- ) {
2164
- // A crashed owner (or a `stop --force` that found no live process) can leave a
2165
- // stale lock behind that blocks re-acquisition. Automate the recovery step so a
2166
- // fresh session self-heals instead of requiring a manual `gjc notify recovery`.
2167
- spawned = await withTelegramSetupLease(
2168
- cfg.botToken,
2169
- async () =>
2170
- await spawnTelegramDaemonOwner(
2171
- { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2172
- deps,
2173
- ),
2174
- );
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;
2175
2293
  }
2176
2294
  if (spawned.result === "blocked") {
2177
- 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
+ );
2178
2298
  return "blocked_identity";
2179
2299
  }
2180
2300
  if (spawned.result === "attached" && spawned.reloadRequired) {
@@ -2521,7 +2641,10 @@ export class TelegramUpdatePoller {
2521
2641
  /** Mutable dispatch state shared by session frames and inbound Telegram updates. */
2522
2642
  export class TelegramEventDispatchState {
2523
2643
  readonly busy = new Set<string>();
2524
- 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
+ >();
2525
2648
  readonly seenUpdateIds = new Set<number>();
2526
2649
  }
2527
2650
 
@@ -2610,11 +2733,16 @@ interface SessionSocket {
2610
2733
  sessionId: string;
2611
2734
  /** Current logical session carried by valid threaded frames on this transport. */
2612
2735
  logicalSessionId: string;
2736
+ /** Endpoint metadata proved this logical id belongs to this discovery record. */
2737
+ logicalSessionIdTrusted: boolean;
2613
2738
  token: string;
2614
2739
  endpointKey: string;
2615
2740
  endpointDigest: string;
2616
2741
  hostGeneration: number;
2617
2742
  ws: WebSocket;
2743
+ /** Timestamp (via opts.now) at which this socket began connecting. */
2744
+ connectingSince: number;
2745
+
2618
2746
  pending: Map<string, { sessionId: string; actionId: string }>;
2619
2747
  /** True once the server advertised the `client_ping_pong` capability. */
2620
2748
  capable: boolean;
@@ -2630,11 +2758,20 @@ interface SessionSocket {
2630
2758
  /** Queues live frames until startup replay is applied. */
2631
2759
  replayPending: boolean;
2632
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
+ };
2633
2768
  }
2634
2769
 
2635
2770
  interface ModelChoiceRoute {
2636
2771
  /** Exact transport socket that rendered this choice. */
2637
2772
  session: SessionSocket;
2773
+ /** Immutable owner lease captured when this choice was rendered. */
2774
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
2638
2775
  /** Logical session current when this choice was rendered. */
2639
2776
  sessionId: string;
2640
2777
  selector: string;
@@ -2665,7 +2802,8 @@ interface ToolActivityOwner {
2665
2802
  interface PendingThreadedFrame {
2666
2803
  send: ThreadedSend;
2667
2804
  msg: Record<string, unknown>;
2668
- endpointDigest: string;
2805
+ logicalSessionId: string;
2806
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
2669
2807
  toolActivity?: ToolActivityOwner;
2670
2808
  }
2671
2809
 
@@ -2690,6 +2828,8 @@ interface SelectedAckQueueItem {
2690
2828
  requestId: string;
2691
2829
  commitKey: string;
2692
2830
  session: SessionSocket;
2831
+ /** Immutable owner lease captured when this acknowledgement was admitted. */
2832
+ socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
2693
2833
  state: "queued" | "dispatching" | "sending";
2694
2834
  controller?: AbortController;
2695
2835
  followers: Array<{ pendingKey: string; requestId: string; commitKey: string }>;
@@ -2698,6 +2838,8 @@ interface SelectedAckQueueItem {
2698
2838
  interface TelegramQueuePayload {
2699
2839
  send: ThreadedSend;
2700
2840
  topicLease?: TopicAuthorityLease;
2841
+ /** Immutable owner lease captured when the work was admitted. */
2842
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string };
2701
2843
  selectedAck?: SelectedAckQueueItem;
2702
2844
  btwDelivery?: BtwQueuedDelivery;
2703
2845
  toolActivity?: ToolActivityOwner;
@@ -2708,6 +2850,10 @@ interface PendingBtwTurn {
2708
2850
  transportSessionId: string;
2709
2851
  /** Logical session id supplied to the session endpoint. */
2710
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;
2711
2857
  endpointDigest: string;
2712
2858
  generation: number;
2713
2859
  question: string;
@@ -2810,12 +2956,22 @@ export class TelegramNotificationDaemon {
2810
2956
  private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
2811
2957
  private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
2812
2958
  private running = false;
2959
+ /** Once set, a concurrent startup await can never restore a running daemon. */
2960
+ private stopRequested = false;
2961
+
2813
2962
  private readonly fsImpl: TelegramDaemonFs;
2814
2963
  private readonly botApi: BotApi;
2815
2964
  private readonly effects = new TelegramEffectSupervisor();
2816
2965
  private readonly topics = new TopicRegistry();
2817
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>();
2818
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
+
2819
2975
  /** Daemon edit attempts that can race an accepted user service message. */
2820
2976
  private readonly daemonRenameAttempts = new Map<string, number>();
2821
2977
  private readonly selectedAckPending = new Map<string, SelectedAckQueueItem>();
@@ -2837,10 +2993,17 @@ export class TelegramNotificationDaemon {
2837
2993
  private readonly draftStream = new DraftStreamState();
2838
2994
  /** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
2839
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>();
2840
3000
  /** Non-identity frames held until identity creates the correct thread. */
2841
3001
  private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
2842
- /** Endpoint generation tombstones for sessions that already sent session_closed. */
2843
- 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;
2844
3007
  /** True once the daemon has nudged the user to enable Threaded Mode. */
2845
3008
  private threadedFallbackNoticeSent = false;
2846
3009
  /** Sessions whose identity header was already sent flat (Threaded Mode off). */
@@ -2854,7 +3017,10 @@ export class TelegramNotificationDaemon {
2854
3017
  return this.dispatchState.busy;
2855
3018
  }
2856
3019
  /** Inbound update id → originating Telegram message, for delivery reactions. */
2857
- 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
+ > {
2858
3024
  return this.dispatchState.inboundReactions;
2859
3025
  }
2860
3026
  /**
@@ -2890,7 +3056,7 @@ export class TelegramNotificationDaemon {
2890
3056
  this.selectedAckPending.delete(item.pendingKey);
2891
3057
  for (const follower of item.followers) this.selectedAckPending.delete(follower.pendingKey);
2892
3058
  this.cacheSelectedAck(item.cacheKey, outcome);
2893
- if (item.session.ws.readyState === WebSocket.OPEN) {
3059
+ if (this.#leaseTokenAllows(item.socketLease) && item.session.ws.readyState === WebSocket.OPEN) {
2894
3060
  for (const result of [{ requestId: item.requestId, commitKey: item.commitKey }, ...item.followers]) {
2895
3061
  item.session.ws.send(
2896
3062
  JSON.stringify({
@@ -2910,6 +3076,8 @@ export class TelegramNotificationDaemon {
2910
3076
  * ~25s getUpdates timeout. Safe to call from a signal handler.
2911
3077
  */
2912
3078
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
3079
+ this.stopRequested = true;
3080
+
2913
3081
  const toolShutdown = this.beginToolActivityShutdown();
2914
3082
  void toolShutdown
2915
3083
  .finally(() => {
@@ -3231,7 +3399,7 @@ export class TelegramNotificationDaemon {
3231
3399
 
3232
3400
  const frame = this.buildLifecycleFrame(parsed, updateId ?? Date.now());
3233
3401
  const response = await this.submitLifecycleFrame(frame);
3234
- await reply(this.formatLifecycleResponse(response));
3402
+ await reply(this.formatLifecycleResponse(response, verb));
3235
3403
  return true;
3236
3404
  }
3237
3405
 
@@ -3247,8 +3415,8 @@ export class TelegramNotificationDaemon {
3247
3415
  }
3248
3416
 
3249
3417
  /** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
3250
- private formatLifecycleResponse(r: SessionLifecycleResponse): string {
3251
- return formatLifecycleOutcome(r);
3418
+ private formatLifecycleResponse(r: SessionLifecycleResponse, verb: LifecycleCommandVerb): string {
3419
+ return formatLifecycleOutcome(r, verb);
3252
3420
  }
3253
3421
 
3254
3422
  constructor(private readonly opts: TelegramDaemonOptions) {
@@ -3330,10 +3498,14 @@ export class TelegramNotificationDaemon {
3330
3498
  finishImmediately({ status: "failed", reason: "route_missing" });
3331
3499
  return;
3332
3500
  }
3333
- 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);
3334
3505
  if (
3335
- this.topics.get(session.sessionId)?.authorityState === "delete_pending" ||
3336
- (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))
3337
3509
  ) {
3338
3510
  finishImmediately({ status: "failed", reason: "route_missing" });
3339
3511
  return;
@@ -3359,13 +3531,14 @@ export class TelegramNotificationDaemon {
3359
3531
  requestId,
3360
3532
  commitKey,
3361
3533
  session,
3534
+ socketLease,
3362
3535
  state: "queued",
3363
3536
  followers: [],
3364
3537
  };
3365
3538
  this.selectedAckPending.set(pendingKey, item);
3366
3539
  this.submitPool({
3367
- sessionId: session.sessionId,
3368
3540
  lane: "ask",
3541
+ sessionId: logicalSessionId,
3369
3542
  itemId: item.itemId,
3370
3543
  deadlineAt,
3371
3544
  payload: {
@@ -3442,34 +3615,117 @@ export class TelegramNotificationDaemon {
3442
3615
  name: "activity",
3443
3616
  matches: msg => msg.type === "activity",
3444
3617
  handle: async (session, msg) => {
3618
+ const logicalSessionId = this.#logicalSessionId(session);
3445
3619
  if (msg.state === "busy") {
3446
- this.busy.add(session.sessionId);
3447
- await this.sendTyping(session.sessionId);
3620
+ this.busy.add(logicalSessionId);
3621
+ await this.sendTyping(logicalSessionId, this.#socketLease(session, logicalSessionId));
3448
3622
  } else {
3449
- this.busy.delete(session.sessionId);
3623
+ this.busy.delete(logicalSessionId);
3450
3624
  }
3451
3625
  },
3452
3626
  })
3453
3627
  .add({
3454
3628
  name: "inbound_ack",
3455
3629
  matches: msg => msg.type === "inbound_ack" && typeof msg.updateId === "number",
3456
- handle: async (_session, msg) => {
3630
+ handle: async (session, msg) => {
3457
3631
  const target = this.inboundReactions.get(msg.updateId as number);
3458
3632
  if (target && msg.state === "consumed") {
3459
3633
  this.inboundReactions.delete(msg.updateId as number);
3460
- await this.setReaction(target.messageId, CONSUMED_REACTION);
3634
+ await this.setReaction(
3635
+ target.messageId,
3636
+ CONSUMED_REACTION,
3637
+ target.socketLease ?? this.#socketLease(session),
3638
+ );
3461
3639
  }
3462
3640
  },
3463
3641
  })
3464
3642
  .add({
3465
3643
  name: "session_closed",
3466
3644
  matches: msg => msg.type === "session_closed",
3467
- handle: async session => {
3468
- this.busy.delete(session.sessionId);
3469
- 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);
3470
3659
  await this.#terminalizeBtwTurnsForSession(session, true);
3471
- await this.deleteTopic(session.sessionId);
3472
- 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
+ });
3473
3729
  },
3474
3730
  });
3475
3731
  }
@@ -3550,7 +3806,27 @@ export class TelegramNotificationDaemon {
3550
3806
  return true;
3551
3807
  }
3552
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
+
3553
3828
  async scanRoots(): Promise<void> {
3829
+ await this.reconcilePendingTopicDeletes();
3554
3830
  const paths = daemonPaths(this.opts.settings.getAgentDir());
3555
3831
  const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
3556
3832
  const endpointSessionIds = new Set<string>();
@@ -3573,6 +3849,11 @@ export class TelegramNotificationDaemon {
3573
3849
  // A hard-killed owner can leave both its endpoint file and socket map
3574
3850
  // entry behind; skipping the read in that case permanently preserves
3575
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
+ }
3576
3857
  const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
3577
3858
  if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) {
3578
3859
  const connected = this.sessions.get(sessionId);
@@ -3581,22 +3862,36 @@ export class TelegramNotificationDaemon {
3581
3862
  await this.observeOrphanedTopic(sessionId);
3582
3863
  continue;
3583
3864
  }
3865
+
3584
3866
  if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
3585
3867
  const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
3586
3868
  const connected = this.sessions.get(sessionId);
3587
3869
  if (connected) {
3588
- 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
+ )
3589
3875
  this.connectSession(sessionId, endpoint.url, endpoint.token);
3590
3876
  continue;
3591
3877
  }
3592
- if (this.closedEndpointKeys.get(sessionId) === endpointKey) continue;
3593
- 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
+ }
3594
3884
  this.connectSession(sessionId, endpoint.url, endpoint.token);
3595
3885
  } catch {}
3596
3886
  }
3597
3887
  }
3598
3888
  if (allRootsReadable) {
3599
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
+ }
3600
3895
  if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId)) {
3601
3896
  this.#terminalizeBtwTurnsForTransportSession(sessionId);
3602
3897
  await this.observeOrphanedTopic(sessionId);
@@ -3610,7 +3905,6 @@ export class TelegramNotificationDaemon {
3610
3905
  const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
3611
3906
  const endpointKey = endpointGenerationKey(url, token);
3612
3907
  const endpointDigest = endpointAuthorityDigest(url, token);
3613
- this.closedEndpointKeys.delete(sessionId);
3614
3908
  const existing = this.sessions.get(sessionId);
3615
3909
  if (existing) {
3616
3910
  this.dropSession(
@@ -3625,12 +3919,13 @@ export class TelegramNotificationDaemon {
3625
3919
  const session: SessionSocket = {
3626
3920
  sessionId,
3627
3921
  logicalSessionId: sessionId,
3628
-
3922
+ logicalSessionIdTrusted: false,
3629
3923
  token,
3630
3924
  endpointKey,
3631
3925
  endpointDigest,
3632
3926
  hostGeneration: 0,
3633
3927
  ws,
3928
+ connectingSince: this.runtime.now(),
3634
3929
  pending: new Map(),
3635
3930
  capable: false,
3636
3931
  ephemeralCapable: false,
@@ -3642,13 +3937,20 @@ export class TelegramNotificationDaemon {
3642
3937
  replayQueue: [],
3643
3938
  };
3644
3939
  this.sessions.set(sessionId, session);
3940
+ if (this.topics.get(sessionId)) this.preservedInitiatorTopics.add(sessionId);
3941
+
3645
3942
  // Bidirectional capability advertisement: announce client_ping_pong once the
3646
3943
  // socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
3647
3944
  // while CONNECTING — and liveness starts only after a capable ServerHello.
3648
3945
  ws.addEventListener("open", () => {
3946
+ if (this.sessions.get(sessionId) !== session) return;
3649
3947
  session.replayPending = true;
3650
3948
  session.replayQueue = [];
3651
- 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;
3652
3954
  if (session.ws.readyState === WebSocket.OPEN) {
3653
3955
  try {
3654
3956
  session.ws.send(
@@ -3676,12 +3978,20 @@ export class TelegramNotificationDaemon {
3676
3978
  );
3677
3979
  } catch {}
3678
3980
  }
3679
- // Eagerly create the session's Telegram topic as soon as it connects, so
3680
- // a thread exists the moment a notifications-enabled session is live —
3681
- // not lazily on the first delivered frame (which only arrives once the
3682
- // user sends a prompt). A provisional "GJC <id>" name is used; the
3683
- // identity_header frame renames it to "{repo}/{branch} - {title}" later.
3684
- 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
+ );
3685
3995
  });
3686
3996
  ws.addEventListener("message", ev => {
3687
3997
  // Identity guard: a delayed frame from a superseded socket must not act
@@ -3856,7 +4166,12 @@ export class TelegramNotificationDaemon {
3856
4166
  }
3857
4167
 
3858
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" };
3859
4173
  const isCurrentSession = this.sessions.get(session.sessionId) === session;
4174
+ if (isCurrentSession) this.droppedSessions.add(session);
3860
4175
  if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
3861
4176
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
3862
4177
  if (session.pingTimer) {
@@ -3874,6 +4189,12 @@ export class TelegramNotificationDaemon {
3874
4189
  if (isCurrentSession) {
3875
4190
  this.#clearModelChoiceAliasesForSocket(session);
3876
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
+ }
3877
4198
  }
3878
4199
 
3879
4200
  for (const item of new Set(this.selectedAckPending.values())) {
@@ -3951,6 +4272,7 @@ export class TelegramNotificationDaemon {
3951
4272
  void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
3952
4273
  }
3953
4274
  }
4275
+
3954
4276
  #resumeBtwTurnsForSession(session: SessionSocket): void {
3955
4277
  if (!session.ephemeralCapable || session.hostGeneration < 1 || session.ws.readyState !== WebSocket.OPEN) return;
3956
4278
  const logicalSessionId = this.#logicalSessionId(session);
@@ -3961,6 +4283,7 @@ export class TelegramNotificationDaemon {
3961
4283
  continue;
3962
4284
  }
3963
4285
  if (
4286
+ !this.#leaseTokenAllows(pending.socketLease) ||
3964
4287
  pending.transportSessionId !== session.sessionId ||
3965
4288
  pending.logicalSessionId !== logicalSessionId ||
3966
4289
  pending.endpointDigest !== session.endpointDigest ||
@@ -3971,6 +4294,9 @@ export class TelegramNotificationDaemon {
3971
4294
  }
3972
4295
  }
3973
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;
3974
4300
  try {
3975
4301
  session.ws.send(
3976
4302
  JSON.stringify({
@@ -3984,6 +4310,7 @@ export class TelegramNotificationDaemon {
3984
4310
  messageId: pending.messageId,
3985
4311
  }),
3986
4312
  );
4313
+ pending.dispatchedSocketLeaseToken = pending.socketLease.token;
3987
4314
  return true;
3988
4315
  } catch {
3989
4316
  return false;
@@ -4041,8 +4368,14 @@ export class TelegramNotificationDaemon {
4041
4368
  parseMode?: typeof TELEGRAM_PARSE_MODE;
4042
4369
  allowWhileStopping?: boolean;
4043
4370
  signal?: AbortSignal;
4371
+ isAuthoritative?: () => boolean;
4044
4372
  }): Promise<unknown> {
4045
- 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;
4046
4379
  const signals = [this.#btwDeliveryAbort.signal, AbortSignal.timeout(30_000)];
4047
4380
  if (input.signal) signals.unshift(input.signal);
4048
4381
  return this.botApi.call(
@@ -4175,12 +4508,149 @@ export class TelegramNotificationDaemon {
4175
4508
  return session.logicalSessionId ?? session.sessionId;
4176
4509
  }
4177
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
+
4178
4634
  #clearModelChoiceAliases(sessionId: string): void {
4179
4635
  for (const [alias, route] of this.#modelChoiceAliases) {
4180
4636
  if (route.sessionId === sessionId) this.#modelChoiceAliases.delete(alias);
4181
4637
  }
4182
4638
  }
4183
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
+
4184
4654
  #clearModelChoiceAliasesForSocket(session: SessionSocket): void {
4185
4655
  for (const [alias, route] of this.#modelChoiceAliases) {
4186
4656
  if (route.session === session) this.#modelChoiceAliases.delete(alias);
@@ -4224,8 +4694,8 @@ export class TelegramNotificationDaemon {
4224
4694
  "reasoning_summary",
4225
4695
  ]);
4226
4696
 
4227
- /** Rekey model controls before a valid server-threaded frame can render or route output. */
4228
- #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> {
4229
4699
  if (
4230
4700
  typeof msg.type !== "string" ||
4231
4701
  !TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type) ||
@@ -4235,10 +4705,187 @@ export class TelegramNotificationDaemon {
4235
4705
  (msg.type !== "config_update" && !renderThreadedFrame(msg))
4236
4706
  )
4237
4707
  return;
4238
- void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
4239
- this.#clearModelChoiceAliasesForSocket(session);
4240
- this.#clearModelChoiceAliases(msg.sessionId);
4241
- 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;
4242
4889
  }
4243
4890
 
4244
4891
  private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
@@ -4320,13 +4967,6 @@ export class TelegramNotificationDaemon {
4320
4967
  return undefined;
4321
4968
  }
4322
4969
 
4323
- private sessionCanClaimIdentity(session: SessionSocket, msg: { repo?: unknown; branch?: unknown }): boolean {
4324
- const current = this.sessions.get(session.sessionId);
4325
- if (current) return current === session;
4326
- const ownerId = this.topicOwnerForIdentity(msg);
4327
- return !ownerId || ownerId === session.sessionId;
4328
- }
4329
-
4330
4970
  private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
4331
4971
  if (msg.type !== "tool_activity") return undefined;
4332
4972
  const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
@@ -4334,7 +4974,7 @@ export class TelegramNotificationDaemon {
4334
4974
  const phase = typeof msg.phase === "string" ? msg.phase : undefined;
4335
4975
  if (!toolCallId || !toolName || !phase) return undefined;
4336
4976
  return {
4337
- sessionId: session.sessionId,
4977
+ sessionId: this.#logicalSessionId(session),
4338
4978
  toolCallId,
4339
4979
  toolName,
4340
4980
  endpointDigest: session.endpointDigest,
@@ -4345,7 +4985,8 @@ export class TelegramNotificationDaemon {
4345
4985
 
4346
4986
  private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
4347
4987
  if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
4348
- const session = this.sessions.get(toolActivity.sessionId);
4988
+ const session =
4989
+ this.logicalSessionOwners.get(toolActivity.sessionId) ?? this.sessions.get(toolActivity.sessionId);
4349
4990
  if (toolActivity.endpointDigest === undefined) return session === undefined;
4350
4991
  return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
4351
4992
  }
@@ -4354,12 +4995,18 @@ export class TelegramNotificationDaemon {
4354
4995
  send: ThreadedSend,
4355
4996
  topicLease: TopicAuthorityLease,
4356
4997
  toolActivity?: ToolActivityOwner,
4998
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
4357
4999
  ): Promise<void> {
4358
5000
  this.submitPool({
4359
5001
  sessionId,
4360
5002
  lane: send.lane,
4361
5003
  coalesceKey: send.coalesceKey,
4362
- payload: { send, topicLease, ...(toolActivity ? { toolActivity } : {}) },
5004
+ payload: {
5005
+ send,
5006
+ topicLease,
5007
+ ...(socketLease ? { socketLease } : {}),
5008
+ ...(toolActivity ? { toolActivity } : {}),
5009
+ },
4363
5010
  });
4364
5011
  await this.flushPool();
4365
5012
  }
@@ -4375,14 +5022,15 @@ export class TelegramNotificationDaemon {
4375
5022
 
4376
5023
  private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
4377
5024
  const topic = this.topics.get(sessionId);
4378
- if (!topic || topic.authorityState === "delete_pending") return undefined;
5025
+ if (!topic || !this.topics.isActiveUnambiguous(sessionId) || topic.bindingMalformed) return undefined;
4379
5026
  return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
4380
5027
  }
4381
5028
 
4382
5029
  private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
4383
5030
  const topic = this.topics.get(lease.sessionId);
4384
5031
  return (
4385
- topic?.authorityState !== "delete_pending" &&
5032
+ this.topics.isActiveUnambiguous(lease.sessionId) &&
5033
+ !topic?.bindingMalformed &&
4386
5034
  topic?.topicId === lease.topicId &&
4387
5035
  (topic.authorityEpoch ?? 0) === lease.authorityEpoch
4388
5036
  );
@@ -4427,26 +5075,29 @@ export class TelegramNotificationDaemon {
4427
5075
  msg: Record<string, unknown>,
4428
5076
  toolActivity?: ToolActivityOwner,
4429
5077
  ): void {
4430
- 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) ?? [];
4431
5082
  frames.push({
4432
5083
  send,
4433
5084
  msg,
4434
- endpointDigest: session.endpointDigest,
5085
+ logicalSessionId,
5086
+ socketLease,
4435
5087
  ...(toolActivity ? { toolActivity } : {}),
4436
5088
  });
4437
5089
  if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
4438
- this.pendingThreadedFrames.set(session.sessionId, frames);
5090
+ this.pendingThreadedFrames.set(logicalSessionId, frames);
4439
5091
  }
4440
5092
 
4441
5093
  private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
4442
5094
  const frames = this.pendingThreadedFrames.get(sessionId);
4443
5095
  if (!frames || frames.length === 0) return;
4444
5096
  this.pendingThreadedFrames.delete(sessionId);
4445
- const session = this.sessions.get(sessionId);
4446
5097
  for (const frame of frames) {
4447
- if (session && session.endpointDigest !== frame.endpointDigest) continue;
5098
+ if (frame.logicalSessionId !== sessionId || !this.#leaseTokenAllows(frame.socketLease)) continue;
4448
5099
  if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
4449
- await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity);
5100
+ await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, frame.socketLease);
4450
5101
  }
4451
5102
  }
4452
5103
 
@@ -4456,36 +5107,163 @@ export class TelegramNotificationDaemon {
4456
5107
  * `undefined`; callers then flat-deliver to a private paired chat (with a
4457
5108
  * one-time nudge) or drop fail-closed for a non-private chat.
4458
5109
  */
4459
- 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> {
4460
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;
4461
5120
  const existing = this.topics.get(sessionId);
4462
- if (existing?.authorityState === "delete_pending") return undefined;
5121
+ if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
4463
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;
4464
5135
  try {
4465
5136
  const rec = await this.topics.getOrCreateTopic(
4466
5137
  sessionId,
4467
5138
  async () => {
4468
- const res = await this.botApi.call("createForumTopic", {
4469
- chat_id: this.opts.chatId,
4470
- name,
4471
- });
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 });
4472
5142
  if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
4473
5143
  const tid = (res as { result?: { message_thread_id?: unknown } }).result?.message_thread_id;
4474
5144
  if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0)
4475
5145
  throw new Error("createForumTopic: invalid message_thread_id");
4476
- 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;
4477
5180
  },
4478
5181
  this.opts.now,
4479
- // The create winner records the name it actually used; callers that
4480
- // merely JOIN an in-flight create must not overwrite it locally, or a
4481
- // later identity rename would be wrongly skipped (topic stuck at the
4482
- // provisional name on Telegram).
4483
5182
  name,
5183
+ creationBinding,
5184
+ () => this.persistTopics(),
5185
+ session,
4484
5186
  );
4485
- 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
+ }
4486
5216
  return rec.topicId;
4487
5217
  } catch (err) {
4488
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;
4489
5267
  logger.warn(
4490
5268
  `notifications: Telegram topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
4491
5269
  );
@@ -4499,24 +5277,39 @@ export class TelegramNotificationDaemon {
4499
5277
  }
4500
5278
 
4501
5279
  private async observeOrphanedTopic(sessionId: string): Promise<void> {
4502
- if (this.topics.markOrphaned(sessionId, this.runtime.now())) await this.persistTopics();
4503
- if (!this.topicPastOrphanGrace(sessionId)) return;
4504
- 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
+ });
4505
5292
  }
4506
5293
 
4507
5294
  /** Best-effort delete of a session topic once its local notification endpoint shuts down. */
4508
- private async deleteTopic(sessionId: string): Promise<void> {
4509
- let record = this.topics.beginDelete(sessionId);
4510
- // Persist even an absent-record fence: it revokes a create that was admitted
4511
- // 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";
4512
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);
4513
5308
  if (!record) {
4514
5309
  await this.topics.awaitInflight(sessionId);
4515
5310
  record = this.topics.get(sessionId);
4516
- // A remotely accepted revoked create installs a delete_pending record while
4517
- // its caller rejects; durably retain it before compensating remotely.
4518
5311
  await this.persistTopics();
4519
- if (!record) return;
5312
+ if (!record) return "settled";
4520
5313
  }
4521
5314
  const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
4522
5315
  for (const item of removed) {
@@ -4526,11 +5319,12 @@ export class TelegramNotificationDaemon {
4526
5319
  }
4527
5320
  try {
4528
5321
  await this.flushPool();
5322
+ if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
4529
5323
  const res = (await this.botApi.call("deleteForumTopic", {
4530
5324
  chat_id: this.opts.chatId,
4531
5325
  message_thread_id: Number(record.topicId),
4532
5326
  })) as { ok?: boolean };
4533
- if (!topicDeleteSettled(res)) return;
5327
+ if (!topicDeleteSettled(res)) return "post_dispatch_pending";
4534
5328
  this.topics.settleDelete(sessionId, record.topicId);
4535
5329
  for (const k of [...this.liveMessages.keys()])
4536
5330
  if (k.startsWith(`${sessionId}:`)) {
@@ -4541,32 +5335,119 @@ export class TelegramNotificationDaemon {
4541
5335
  if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
4542
5336
  });
4543
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 {
4544
5377
  await this.persistTopics();
4545
5378
  } catch {
4546
- // Retain the persisted delete fence on transport ambiguity.
5379
+ await this.persistTopics();
4547
5380
  }
4548
5381
  }
4549
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
+
4550
5401
  private persistTopics(): Promise<void> {
4551
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();
4552
5407
  const paths = daemonPaths(this.opts.settings.getAgentDir());
4553
5408
  await ensureDir(this.fsImpl, paths.dir);
4554
- 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);
4555
5410
  });
4556
5411
  this.topicsPersistQueue = pending.catch(() => undefined);
4557
5412
  return pending;
4558
5413
  }
4559
5414
 
5415
+ #topicStateForPersistence(): TopicRegistryState {
5416
+ return { ...this.topics.serialize(), closedEndpoints: Object.fromEntries(this.closedEndpointKeys) };
5417
+ }
5418
+
4560
5419
  async loadTopics(): Promise<void> {
4561
5420
  const paths = daemonPaths(this.opts.settings.getAgentDir());
4562
5421
  const raw = await readJson<TopicRegistryState>(this.fsImpl, path.join(paths.dir, "telegram-topics.json"));
4563
5422
  // Restore the full serialized registry (topicId + identitySent + name) so a
4564
5423
  // fresh daemon after reload does not resend identity headers or lose renames.
4565
- 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
+ }
4566
5438
  }
4567
5439
 
4568
- /** Download a Telegram file by its file_path (from getFile) into memory. */
4569
- 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> {
4570
5451
  const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
4571
5452
  const fetchImpl = this.opts.fetchImpl ?? fetch;
4572
5453
  // `filePath` is remote metadata from getFile; reject suspicious segments
@@ -4574,17 +5455,68 @@ export class TelegramNotificationDaemon {
4574
5455
  // composing the download URL.
4575
5456
  if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
4576
5457
  logger.warn("notifications: rejecting suspicious Telegram file_path");
4577
- return undefined;
5458
+ return { failure: "download_failed" };
4578
5459
  }
4579
5460
  const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
4580
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 });
4581
5474
  try {
4582
- const res = await fetchImpl(url);
4583
- if (!res.ok) return undefined;
4584
- 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) };
4585
5512
  } catch (e) {
5513
+ await cancelActiveReader?.();
5514
+ controller.abort();
4586
5515
  logger.warn(`notifications: file download failed: ${sanitizeDiagnostic(String(e))}`);
4587
- return undefined;
5516
+ return { failure: "download_failed" };
5517
+ } finally {
5518
+ controller.signal.removeEventListener("abort", cancelReader);
5519
+ if (timeout !== undefined) clearTimeoutImpl(timeout);
4588
5520
  }
4589
5521
  }
4590
5522
 
@@ -4594,6 +5526,8 @@ export class TelegramNotificationDaemon {
4594
5526
  * removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
4595
5527
  */
4596
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>>();
4597
5531
 
4598
5532
  /** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
4599
5533
  private async ensureAttachmentDir(sessionId: string): Promise<string> {
@@ -4611,6 +5545,8 @@ export class TelegramNotificationDaemon {
4611
5545
  private async cleanupAllAttachmentDirs(): Promise<void> {
4612
5546
  const dirs = [...this.attachmentDirs.values()];
4613
5547
  this.attachmentDirs.clear();
5548
+ this.#attachmentUsage.clear();
5549
+ this.#attachmentChains.clear();
4614
5550
  await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
4615
5551
  }
4616
5552
 
@@ -4626,22 +5562,83 @@ export class TelegramNotificationDaemon {
4626
5562
  private async resolveInboundAttachment(
4627
5563
  att: InboundAttachment,
4628
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,
4629
5581
  ): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
4630
5582
  const images: { data: string; mime?: string }[] = [];
4631
5583
  const fileNotes: string[] = [];
4632
5584
  const label = att.fileName ?? att.kind;
5585
+ let timeout: NodeJS.Timeout | undefined;
4633
5586
  try {
4634
- const got = (await this.botApi.call("getFile", { file_id: att.fileId })) as {
4635
- 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 };
4636
5602
  };
4637
5603
  const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
4638
5604
  if (!filePath) {
4639
5605
  fileNotes.push(`[attachment unavailable: ${label}]`);
4640
5606
  return { images, fileNotes };
4641
5607
  }
4642
- const bytes = await this.downloadTelegramFile(filePath);
4643
- if (!bytes) {
4644
- 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]`);
4645
5642
  return { images, fileNotes };
4646
5643
  }
4647
5644
  const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
@@ -4658,12 +5655,20 @@ export class TelegramNotificationDaemon {
4658
5655
  // Unguessable, non-colliding name inside the private 0700 dir; the
4659
5656
  // exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
4660
5657
  const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
4661
- 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
+ }
4662
5664
  fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
4663
5665
  }
5666
+ this.#attachmentUsage.set(sessionId, { count: current.count + 1, bytes: current.bytes + accountedBytes });
4664
5667
  } catch (e) {
4665
5668
  logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
4666
5669
  fileNotes.push(`[attachment error: ${label}]`);
5670
+ } finally {
5671
+ if (timeout !== undefined) (this.opts.clearTimeoutImpl ?? clearTimeout)(timeout);
4667
5672
  }
4668
5673
  return { images, fileNotes };
4669
5674
  }
@@ -4769,7 +5774,9 @@ export class TelegramNotificationDaemon {
4769
5774
  const controller = new AbortController();
4770
5775
  selectedAck.controller = controller;
4771
5776
  const routeAvailable =
4772
- (await this.pairedChatIsPrivate()) && (!topicLease || this.topicLeaseIsCurrent(topicLease));
5777
+ this.#leaseTokenAllows(selectedAck.socketLease) &&
5778
+ (await this.pairedChatIsPrivate()) &&
5779
+ (!topicLease || this.topicLeaseIsCurrent(topicLease));
4773
5780
  if (this.selectedAckPending.get(selectedAck.pendingKey) !== selectedAck) {
4774
5781
  this.pool.settle(item.itemId!, "removed");
4775
5782
  continue;
@@ -4791,6 +5798,14 @@ export class TelegramNotificationDaemon {
4791
5798
  Math.min(8_000, remaining),
4792
5799
  );
4793
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
+ }
4794
5809
  const response = (await this.botApi.call(
4795
5810
  "sendMessage",
4796
5811
  {
@@ -4815,8 +5830,11 @@ export class TelegramNotificationDaemon {
4815
5830
  }
4816
5831
  continue;
4817
5832
  }
4818
- const { send, topicLease } = item.payload;
4819
- 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
+ ) {
4820
5838
  this.pool.settle(item.itemId!, "rejected");
4821
5839
  continue;
4822
5840
  }
@@ -4825,7 +5843,10 @@ export class TelegramNotificationDaemon {
4825
5843
  this.pool.settle(item.itemId!, "rejected");
4826
5844
  continue;
4827
5845
  }
4828
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
5846
+ if (
5847
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
5848
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
5849
+ ) {
4829
5850
  this.pool.settle(item.itemId!, "rejected");
4830
5851
  continue;
4831
5852
  }
@@ -4870,7 +5891,10 @@ export class TelegramNotificationDaemon {
4870
5891
  }
4871
5892
  }
4872
5893
  }
4873
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
5894
+ if (
5895
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
5896
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
5897
+ ) {
4874
5898
  disposition = "rejected";
4875
5899
  continue;
4876
5900
  }
@@ -4910,7 +5934,11 @@ export class TelegramNotificationDaemon {
4910
5934
  // non-editable, HTML-only pool items (rich markers stripped) — same
4911
5935
  // per-token discipline as the non-rich split path.
4912
5936
  const chunks = splitTelegramHtml(send.text!);
4913
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
5937
+ if (
5938
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
5939
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
5940
+ )
5941
+ return;
4914
5942
  await this.botApi.call("sendMessage", {
4915
5943
  chat_id: this.opts.chatId,
4916
5944
  ...threadField,
@@ -4935,6 +5963,7 @@ export class TelegramNotificationDaemon {
4935
5963
  richClass: undefined,
4936
5964
  },
4937
5965
  topicLease,
5966
+ socketLease,
4938
5967
  },
4939
5968
  });
4940
5969
  }
@@ -4969,6 +5998,11 @@ export class TelegramNotificationDaemon {
4969
5998
  // transport error) resends so the first chunk is never lost.
4970
5999
  let edited = false;
4971
6000
  try {
6001
+ if (
6002
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6003
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
6004
+ )
6005
+ return;
4972
6006
  const res = (await this.botApi.call("editMessageText", {
4973
6007
  chat_id: this.opts.chatId,
4974
6008
  message_id: existingId,
@@ -4981,7 +6015,10 @@ export class TelegramNotificationDaemon {
4981
6015
  }
4982
6016
  if (edited) {
4983
6017
  firstMessageId = existingId;
4984
- } else if (!topicLease || this.topicLeaseIsCurrent(topicLease)) {
6018
+ } else if (
6019
+ (!socketLease || this.#leaseTokenAllows(socketLease)) &&
6020
+ (!topicLease || this.topicLeaseIsCurrent(topicLease))
6021
+ ) {
4985
6022
  const res = (await this.botApi.call("sendMessage", {
4986
6023
  chat_id: this.opts.chatId,
4987
6024
  ...threadField,
@@ -4993,6 +6030,11 @@ export class TelegramNotificationDaemon {
4993
6030
  } else {
4994
6031
  // No streamed message to edit: a single granted slot maps to a
4995
6032
  // single Telegram send.
6033
+ if (
6034
+ (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6035
+ (topicLease && !this.topicLeaseIsCurrent(topicLease))
6036
+ )
6037
+ return;
4996
6038
  const res = (await this.botApi.call("sendMessage", {
4997
6039
  chat_id: this.opts.chatId,
4998
6040
  ...threadField,
@@ -5026,6 +6068,7 @@ export class TelegramNotificationDaemon {
5026
6068
  richClass: undefined,
5027
6069
  },
5028
6070
  topicLease,
6071
+ socketLease,
5029
6072
  },
5030
6073
  });
5031
6074
  }
@@ -5103,17 +6146,21 @@ export class TelegramNotificationDaemon {
5103
6146
  sessionId: string,
5104
6147
  send: ThreadedSend,
5105
6148
  toolActivity?: ToolActivityOwner,
6149
+ socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
5106
6150
  ): Promise<void> {
5107
- if (!(await this.pairedChatIsPrivate())) return;
5108
- 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;
5109
6155
  if (send.identity && this.flatIdentitySent.has(sessionId)) return;
5110
6156
  this.submitPool({
5111
6157
  sessionId,
5112
6158
  lane: send.lane,
5113
6159
  coalesceKey: send.coalesceKey,
5114
- payload: { send, ...(toolActivity ? { toolActivity } : {}) },
6160
+ payload: { send, ...(socketLease ? { socketLease } : {}), ...(toolActivity ? { toolActivity } : {}) },
5115
6161
  });
5116
6162
  await this.flushPool();
6163
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5117
6164
  if (send.identity) this.flatIdentitySent.add(sessionId);
5118
6165
  }
5119
6166
 
@@ -5156,10 +6203,21 @@ export class TelegramNotificationDaemon {
5156
6203
  }
5157
6204
 
5158
6205
  /** Tell the user once (per daemon run) how to enable Threaded Mode. */
5159
- private async notifyThreadedFallback(): Promise<void> {
5160
- 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;
5161
6218
  this.threadedFallbackNoticeSent = true;
5162
6219
  try {
6220
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5163
6221
  await this.botApi.call("sendMessage", {
5164
6222
  chat_id: this.opts.chatId,
5165
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.",
@@ -5234,14 +6292,20 @@ export class TelegramNotificationDaemon {
5234
6292
  }
5235
6293
 
5236
6294
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
5237
- 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;
5238
6302
  const topicLease = await this.topicAuthorityLease(sessionId);
5239
- if (!topicLease || !this.topicLeaseIsCurrent(topicLease)) return;
5240
- const topicId = topicLease.topicId;
6303
+ if (!topicLease || !this.topicLeaseIsCurrent(topicLease) || !this.#leaseTokenAllows(socketLease)) return;
5241
6304
  try {
6305
+ if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return;
5242
6306
  await this.botApi.call("sendChatAction", {
5243
6307
  chat_id: this.opts.chatId,
5244
- message_thread_id: Number(topicId),
6308
+ message_thread_id: Number(topicLease.topicId),
5245
6309
  action: "typing",
5246
6310
  });
5247
6311
  } catch {
@@ -5250,9 +6314,14 @@ export class TelegramNotificationDaemon {
5250
6314
  }
5251
6315
 
5252
6316
  /** Set a native reaction on an inbound thread message (best-effort). */
5253
- private async setReaction(messageId: number, emoji: string): Promise<void> {
5254
- 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;
5255
6323
  try {
6324
+ if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
5256
6325
  await this.botApi.call("setMessageReaction", {
5257
6326
  chat_id: this.opts.chatId,
5258
6327
  message_id: messageId,
@@ -5276,7 +6345,10 @@ export class TelegramNotificationDaemon {
5276
6345
 
5277
6346
  /** Render successful `/model` lists as session-bound, one-shot inline choices. */
5278
6347
  async #renderModelChoices(session: SessionSocket, msg: Record<string, unknown>): Promise<boolean> {
5279
- 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);
5280
6352
  if (
5281
6353
  msg.type !== "control_command_result" ||
5282
6354
  msg.status !== "ok" ||
@@ -5285,6 +6357,8 @@ export class TelegramNotificationDaemon {
5285
6357
  this.sessions.get(session.sessionId) !== session
5286
6358
  )
5287
6359
  return false;
6360
+ const socketLease = this.#socketLease(session, logicalSessionId);
6361
+ if (!socketLease) return false;
5288
6362
 
5289
6363
  const choices: RenderedModelChoice[] = [];
5290
6364
  for (const choice of msg.modelChoices) {
@@ -5299,21 +6373,27 @@ export class TelegramNotificationDaemon {
5299
6373
  const rendered = renderThreadedFrame({ ...msg, type: "control_command_result" });
5300
6374
  if (!rendered?.text) return false;
5301
6375
  const topicId =
5302
- (await this.existingTopicForPrivateChat(session.sessionId)) ??
5303
- (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
5304
- 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);
5305
6384
  if (!topicId || !topicLease || topicLease.topicId !== topicId) return false;
6385
+ if (!session.logicalSessionIdTrusted) this.legacyTopicOwners.set(logicalSessionId, session);
5306
6386
 
5307
6387
  // Each logical session owns only its most recently rendered menu.
5308
6388
  this.#clearModelChoiceAliases(logicalSessionId);
5309
6389
  const aliases = choices.map(choice =>
5310
- this.#putModelChoiceAlias({ session, sessionId: logicalSessionId, selector: choice.selector }),
6390
+ this.#putModelChoiceAlias({ session, socketLease, sessionId: logicalSessionId, selector: choice.selector }),
5311
6391
  );
5312
6392
  const inline_keyboard = buildButtonGrid(
5313
6393
  choices.map(choice => choice.label),
5314
6394
  index => aliases[index]!,
5315
6395
  );
5316
- if (!this.topicLeaseIsCurrent(topicLease)) return false;
6396
+ if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return false;
5317
6397
  try {
5318
6398
  const response = await this.botApi.call("sendMessage", {
5319
6399
  chat_id: this.opts.chatId,
@@ -5336,6 +6416,18 @@ export class TelegramNotificationDaemon {
5336
6416
  }
5337
6417
 
5338
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
+ }
5339
6431
  if (session.replayPending) {
5340
6432
  if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
5341
6433
  return;
@@ -5346,34 +6438,100 @@ export class TelegramNotificationDaemon {
5346
6438
  session.replayQueue.push(frame);
5347
6439
  return;
5348
6440
  }
5349
- session.replayPending = false;
5350
6441
  const replayValid =
6442
+ msg.ok === true &&
5351
6443
  Number.isSafeInteger(msg.generation) &&
5352
6444
  msg.generation >= 1 &&
5353
6445
  Number.isSafeInteger(msg.lastSeq) &&
5354
6446
  msg.lastSeq >= 0 &&
5355
- Array.isArray(msg.events);
5356
- if (replayValid) {
5357
- session.hostGeneration = msg.generation;
5358
- }
6447
+ msg.gap === undefined &&
6448
+ Array.isArray(msg.events) &&
6449
+ msg.events.every((event: unknown) => event !== null && typeof event === "object" && !Array.isArray(event));
5359
6450
  const replayed: Record<string, unknown>[] = replayValid
5360
- ? (msg.events as unknown[]).flatMap((event: unknown): Record<string, unknown>[] => {
5361
- if (!event || typeof event !== "object" || Array.isArray(event)) return [];
5362
- const envelope = event as Record<string, unknown>;
5363
- const payload = envelope.payload;
5364
- return [
5365
- payload && typeof payload === "object" && !Array.isArray(payload)
5366
- ? (payload as Record<string, unknown>)
5367
- : envelope,
5368
- ];
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;
5369
6456
  })
5370
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;
5371
6531
  // Replay restores durable attachment state only. Live notification effects
5372
6532
  // (turn streams, context updates, lifecycle messages) may already have been
5373
6533
  // delivered before a reconnect and must never be rendered a second time.
5374
- const identityIndex = replayed.findLastIndex(frame => frame.type === "identity_header");
5375
6534
  const currentGeneration = identityIndex < 0 ? replayed : replayed.slice(identityIndex);
5376
- const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
5377
6535
  const latestActions = new Map<string, Record<string, unknown>>();
5378
6536
  for (const frame of currentGeneration) {
5379
6537
  if ((frame.type === "action_needed" || frame.type === "action_resolved") && typeof frame.id === "string")
@@ -5384,7 +6542,15 @@ export class TelegramNotificationDaemon {
5384
6542
  for (const frame of replayState) {
5385
6543
  const fingerprint = JSON.stringify(frame);
5386
6544
  replayCounts.set(fingerprint, (replayCounts.get(fingerprint) ?? 0) + 1);
5387
- 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
+ }
5388
6554
  }
5389
6555
  const queued = session.replayQueue.splice(0);
5390
6556
  for (const frame of queued) {
@@ -5402,16 +6568,19 @@ export class TelegramNotificationDaemon {
5402
6568
  }
5403
6569
  await this.handleSessionMessage(session, frame);
5404
6570
  }
5405
- if (replayValid) {
5406
- this.#terminalizeBtwTurnsForGenerationChange(session);
5407
- this.#resumeBtwTurnsForSession(session);
5408
- if (this.topics.markReplayCursor(session.sessionId, msg.generation, msg.lastSeq))
5409
- await this.persistTopics();
5410
- }
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();
5411
6579
  return;
5412
6580
  }
5413
6581
  if (msg?.type === "event_replay_result") return;
5414
- 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;
5415
6584
  if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
5416
6585
  if (await this.#renderModelChoices(session, msg as Record<string, unknown>)) return;
5417
6586
 
@@ -5445,7 +6614,7 @@ export class TelegramNotificationDaemon {
5445
6614
  msg.threadId !== pending.threadId ||
5446
6615
  msg.updateId !== pending.updateId ||
5447
6616
  msg.messageId !== pending.messageId ||
5448
- this.topics.sessionForTopic(pending.threadId) !== pending.transportSessionId
6617
+ this.topics.sessionForTopic(pending.threadId) !== pending.logicalSessionId
5449
6618
  )
5450
6619
  return;
5451
6620
  if (
@@ -5459,6 +6628,21 @@ export class TelegramNotificationDaemon {
5459
6628
  return;
5460
6629
  if (msg.status === "ok" && typeof msg.text !== "string") return;
5461
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;
5462
6646
  const finished = Promise.withResolvers<void>();
5463
6647
  const terminalDelivery: PendingBtwDelivery = {
5464
6648
  pending,
@@ -5487,6 +6671,7 @@ export class TelegramNotificationDaemon {
5487
6671
  messageId: pending.messageId,
5488
6672
  text,
5489
6673
  signal: terminalDelivery.controller.signal,
6674
+ isAuthoritative,
5490
6675
  });
5491
6676
  deliveryOutcome =
5492
6677
  response && typeof response === "object" && (response as { ok?: unknown }).ok === true
@@ -5501,20 +6686,6 @@ export class TelegramNotificationDaemon {
5501
6686
  return;
5502
6687
  }
5503
6688
  const markdown = msg.text;
5504
- const isAuthoritative = (): boolean =>
5505
- !this.#stoppingBtw &&
5506
- this.sessions.get(session.sessionId) === session &&
5507
- session.ws.readyState === WebSocket.OPEN &&
5508
- pending.endpointDigest === session.endpointDigest &&
5509
- pending.generation === session.hostGeneration &&
5510
- pending.logicalSessionId === this.#logicalSessionId(session) &&
5511
- pending.transportSessionId === session.sessionId &&
5512
- requestId === msg.requestId &&
5513
- pending.logicalSessionId === msg.sessionId &&
5514
- pending.messageId === msg.messageId &&
5515
- pending.threadId === msg.threadId &&
5516
- pending.updateId === msg.updateId &&
5517
- this.topics.sessionForTopic(pending.threadId) === pending.transportSessionId;
5518
6689
  const signal = AbortSignal.any([
5519
6690
  this.#btwDeliveryAbort.signal,
5520
6691
  terminalDelivery.controller.signal,
@@ -5641,13 +6812,13 @@ export class TelegramNotificationDaemon {
5641
6812
  toolAdmissionEpoch === this.toolActivityPolicyEpoch);
5642
6813
  const abandonStaleToolStart = (): void => {
5643
6814
  if (toolActivity?.phase !== "started") return;
5644
- const key = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
6815
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
5645
6816
  const owner = this.toolActivityOwners.get(key);
5646
6817
  if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
5647
6818
  this.toolActivityOwners.delete(key);
5648
6819
  };
5649
6820
  if (toolActivity) {
5650
- const liveKey = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
6821
+ const liveKey = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
5651
6822
  const currentOwner = this.toolActivityOwners.get(liveKey);
5652
6823
  if (toolActivity.phase === "started") {
5653
6824
  if (!toolStartIsCurrent()) return;
@@ -5672,102 +6843,106 @@ export class TelegramNotificationDaemon {
5672
6843
  }
5673
6844
  const send = renderThreadedFrame(msg);
5674
6845
  if (!send) return;
5675
- 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);
5676
6858
  if (!toolStartIsCurrent()) {
5677
6859
  abandonStaleToolStart();
5678
6860
  return;
5679
6861
  }
5680
- if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
5681
- if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
5682
- if (!toolStartIsCurrent()) {
5683
- abandonStaleToolStart();
5684
- return;
5685
- }
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)) {
5686
6868
  this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
5687
6869
  return;
5688
6870
  }
5689
- if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
5690
- const ownerId = this.topicOwnerForIdentity(msg);
5691
- const ownerLease = ownerId ? await this.topicAuthorityLease(ownerId) : undefined;
5692
- if (ownerId && ownerId !== session.sessionId && ownerLease) {
5693
- await this.flushPendingThreadedFrames(session.sessionId, ownerLease);
5694
- return;
5695
- }
5696
- }
5697
6871
  const topicId =
5698
- existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
5699
- const topicLease = await this.topicAuthorityLease(session.sessionId);
5700
- if (!toolStartIsCurrent()) {
5701
- abandonStaleToolStart();
5702
- return;
5703
- }
6872
+ existingTopic ??
6873
+ (await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session));
6874
+ const topicLease = await this.topicAuthorityLease(logicalSessionId);
5704
6875
  if (!topicId || !topicLease || topicLease.topicId !== topicId) {
5705
- if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
5706
- 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);
5707
6882
  return;
5708
6883
  }
5709
6884
  if (send.identity) {
5710
6885
  const identityKey = this.topicIdentityKey(msg);
5711
6886
  if (identityKey) {
5712
- this.topicOwnerByIdentity.set(identityKey, session.sessionId);
5713
- 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();
5714
6889
  }
5715
- // Explicit Telegram-side user renames own the topic title. Pending user
5716
- // reconciliation runs before daemon identity naming, so retries and daemon
5717
- // restarts cannot silently replace the preserved name.
5718
6890
  await this.reconcileUserTopicName(topicLease);
5719
- const name = this.topicNameFor(session.sessionId, msg);
5720
- if (this.topics.needsRename(session.sessionId, name)) {
5721
- this.daemonRenameAttempts.set(
5722
- session.sessionId,
5723
- (this.daemonRenameAttempts.get(session.sessionId) ?? 0) + 1,
5724
- );
6891
+ const name = this.topicNameFor(logicalSessionId, msg);
6892
+ if (this.topics.needsRename(logicalSessionId, name)) {
5725
6893
  try {
5726
- 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
+ );
5727
6899
  const response = await this.botApi.call("editForumTopic", {
5728
6900
  chat_id: this.opts.chatId,
5729
6901
  message_thread_id: Number(topicId),
5730
6902
  name,
5731
6903
  });
5732
- if (topicRenameApplied(response)) this.topics.markNameApplied(session.sessionId, name);
6904
+ if (topicRenameApplied(response)) this.topics.markNameApplied(logicalSessionId, name);
5733
6905
  } catch {
5734
- // Best-effort rename; leave daemon-owned names unchanged so a
5735
- // later identity frame retries.
6906
+ // A later identity frame retries a transient daemon rename failure.
5736
6907
  } finally {
5737
- const remaining = (this.daemonRenameAttempts.get(session.sessionId) ?? 1) - 1;
5738
- if (remaining > 0) this.daemonRenameAttempts.set(session.sessionId, remaining);
5739
- else {
5740
- this.daemonRenameAttempts.delete(session.sessionId);
5741
- await this.reconcileUserTopicName(topicLease);
5742
- }
6908
+ const attempts = (this.daemonRenameAttempts.get(logicalSessionId) ?? 1) - 1;
6909
+ if (attempts > 0) this.daemonRenameAttempts.set(logicalSessionId, attempts);
6910
+ else this.daemonRenameAttempts.delete(logicalSessionId);
5743
6911
  }
5744
6912
  }
5745
- // Send the full bulleted identity header EXACTLY ONCE per topic.
5746
- if (this.topics.needsIdentity(session.sessionId)) {
5747
- await this.submitThreadedFrame(session.sessionId, send, topicLease);
5748
- 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);
5749
6916
  }
5750
- await this.flushPendingThreadedFrames(session.sessionId, topicLease);
5751
6917
  await this.persistTopics();
6918
+ await this.flushPendingThreadedFrames(logicalSessionId, topicLease);
6919
+ await this.reconcileUserTopicName(topicLease);
5752
6920
  return;
5753
6921
  }
5754
6922
  if (!toolStartIsCurrent()) {
5755
6923
  abandonStaleToolStart();
5756
6924
  return;
5757
6925
  }
5758
- await this.submitThreadedFrame(session.sessionId, send, topicLease, toolActivity);
6926
+ await this.submitThreadedFrame(logicalSessionId, send, topicLease, toolActivity, socketLease);
5759
6927
  return;
5760
6928
  }
5761
6929
  if (msg.type === "action_needed" && msg.id) {
5762
- if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
5763
- if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
5764
- const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
5765
- 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;
5766
6941
  if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
5767
6942
  if (!topicId) {
5768
6943
  // Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
5769
6944
  if (!(await this.pairedChatIsPrivate())) return;
5770
- await this.notifyThreadedFallback();
6945
+ await this.notifyThreadedFallback(socketLease);
5771
6946
  }
5772
6947
  const threadField = topicLease ? { message_thread_id: Number(topicLease.topicId) } : {};
5773
6948
  const controls: Array<{
@@ -5805,13 +6980,13 @@ export class TelegramNotificationDaemon {
5805
6980
  const options = Array.isArray(msg.options) ? msg.options : [];
5806
6981
  const inline_keyboard = [
5807
6982
  ...buildCompactChoiceGrid(options, (i: number) =>
5808
- this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
6983
+ this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
5809
6984
  ),
5810
6985
  ...controls.map(control => [
5811
6986
  {
5812
6987
  text: control.label,
5813
6988
  callback_data: this.aliasTable.put({
5814
- sessionId: session.sessionId,
6989
+ sessionId: logicalSessionId,
5815
6990
  actionId: msg.id,
5816
6991
  answer: { controlId: control.id },
5817
6992
  }),
@@ -5824,7 +6999,8 @@ export class TelegramNotificationDaemon {
5824
6999
  const chunks = splitTelegramHtml(rendered.text);
5825
7000
  let result: { result?: { message_id?: number } } = {};
5826
7001
  for (let i = 0; i < chunks.length; i++) {
5827
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return undefined;
7002
+ if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
7003
+ return undefined;
5828
7004
  result = (await this.botApi.call("sendMessage", {
5829
7005
  chat_id: this.opts.chatId,
5830
7006
  ...threadField,
@@ -5840,7 +7016,7 @@ export class TelegramNotificationDaemon {
5840
7016
  // Rich (default on): promote to sendRichMessage with a top-level
5841
7017
  // reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
5842
7018
 
5843
- if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
7019
+ if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease))) return;
5844
7020
  const outcome = await deliverRichActionWithFallback(
5845
7021
  this.botApi,
5846
7022
  { chat_id: this.opts.chatId, ...threadField },
@@ -5859,21 +7035,22 @@ export class TelegramNotificationDaemon {
5859
7035
  );
5860
7036
  // Only asks are reply-routable; idle pings register no route.
5861
7037
  if (kind === "ask" && outcome.messageId !== undefined)
5862
- this.messageRoutes.set(String(outcome.messageId), { sessionId: session.sessionId, actionId: msg.id });
7038
+ this.messageRoutes.set(String(outcome.messageId), { sessionId: logicalSessionId, actionId: msg.id });
5863
7039
  } else {
5864
7040
  // Off: byte-identical to the pre-rich HTML path.
5865
7041
  const messageId = await sendHtmlChunks();
5866
7042
  // Only asks are reply-routable; idle pings register no route (parity
5867
7043
  // with the rich branch and correct even in the byte-identical off path).
5868
7044
  if (kind === "ask" && messageId !== undefined)
5869
- this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
7045
+ this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
5870
7046
  }
5871
7047
  await this.persistAliases();
5872
7048
  } else if (msg.type === "action_resolved" && msg.id) {
5873
7049
  session.pending.delete(msg.id);
5874
- this.deleteMessageRoutes(session.sessionId, msg.id);
7050
+ this.deleteMessageRoutes(this.#logicalSessionId(session), msg.id);
5875
7051
  for (const [alias, route] of this.aliasTable.entries()) {
5876
- 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);
5877
7054
  }
5878
7055
  await this.persistAliases();
5879
7056
  }
@@ -5920,7 +7097,9 @@ export class TelegramNotificationDaemon {
5920
7097
  if (
5921
7098
  this.sessions.get(session.sessionId) !== session ||
5922
7099
  this.#logicalSessionId(session) !== route.sessionId ||
5923
- 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))
5924
7103
  ) {
5925
7104
  this.#modelChoiceAliases.delete(alias);
5926
7105
  await this.#sendModelStaleGuidance(callbackId);
@@ -5932,6 +7111,13 @@ export class TelegramNotificationDaemon {
5932
7111
  const updateId = (update as { update_id?: unknown }).update_id;
5933
7112
  const safeUpdateId =
5934
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
+ }
5935
7121
  try {
5936
7122
  session.ws.send(
5937
7123
  JSON.stringify({
@@ -6205,7 +7391,40 @@ export class TelegramNotificationDaemon {
6205
7391
  if (!raw.callback_query && (!isAskReply || interceptAskControl || reservedBtw !== undefined)) {
6206
7392
  const inbound = decideThreadedInbound(update as never, {
6207
7393
  pairedChatId: this.opts.chatId,
6208
- 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
+ },
6209
7428
  isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
6210
7429
  });
6211
7430
  if (inbound.kind === "duplicate") return;
@@ -6215,10 +7434,45 @@ export class TelegramNotificationDaemon {
6215
7434
  ? { kind: "none" as const }
6216
7435
  : parseTelegramControlCommand(inbound.text, this.botUsername);
6217
7436
  if (preliminaryControl.kind === "ignored") return;
6218
- 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;
6219
7473
  if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
6220
7474
  if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
6221
- if (await this.reserveSeenUpdateId(inbound.updateId)) {
7475
+ if (await reserveRouteUpdate()) {
6222
7476
  try {
6223
7477
  await this.botApi.call("sendMessage", {
6224
7478
  chat_id: this.opts.chatId,
@@ -6237,36 +7491,41 @@ export class TelegramNotificationDaemon {
6237
7491
  return;
6238
7492
  }
6239
7493
  if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
6240
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7494
+ if (!(await reserveRouteUpdate())) return;
6241
7495
  await this.#sendBtwMessage({
6242
7496
  threadId: inbound.threadId,
6243
7497
  messageId: inbound.messageId,
6244
7498
  text: BTW_USAGE_TEXT,
7499
+ isAuthoritative: routeLeaseAllows,
6245
7500
  });
6246
7501
  return;
6247
7502
  }
6248
7503
  if (reservedBtw?.kind === "question" && this.opts.btw?.enabled === false) {
6249
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7504
+ if (!(await reserveRouteUpdate())) return;
6250
7505
  await this.#sendBtwMessage({
6251
7506
  threadId: inbound.threadId,
6252
7507
  messageId: inbound.messageId,
6253
7508
  text: "Telegram /btw is disabled in local settings.",
7509
+ isAuthoritative: routeLeaseAllows,
6254
7510
  });
6255
7511
  return;
6256
7512
  }
6257
7513
  if (reservedBtw?.kind === "question" && session?.ws.readyState !== WebSocket.OPEN) {
6258
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7514
+ if (!(await reserveRouteUpdate())) return;
6259
7515
  await this.#sendBtwMessage({
6260
7516
  threadId: inbound.threadId,
6261
7517
  messageId: inbound.messageId,
6262
7518
  text: "Restart this GJC session to enable /btw.",
7519
+ isAuthoritative: routeLeaseAllows,
6263
7520
  });
6264
7521
  return;
6265
7522
  }
6266
- if (session?.ws.readyState === WebSocket.OPEN) {
7523
+ if (!session || !routeLease) return;
7524
+ if (routeAllows()) {
6267
7525
  const attachmentResult = inbound.attachment
6268
7526
  ? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
6269
7527
  : undefined;
7528
+ if (!routeAllows()) return;
6270
7529
  const images = attachmentResult?.images ?? [];
6271
7530
  const fileNotes = attachmentResult?.fileNotes ?? [];
6272
7531
  const hasMedia = inbound.attachment !== undefined || images.length > 0 || fileNotes.length > 0;
@@ -6289,11 +7548,12 @@ export class TelegramNotificationDaemon {
6289
7548
  if (btw?.kind === "question") {
6290
7549
  const btwQuestion = btw.question;
6291
7550
  if (!btwQuestion || hasMedia) {
6292
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7551
+ if (!(await reserveRouteUpdate())) return;
6293
7552
  await this.#sendBtwMessage({
6294
7553
  threadId: inbound.threadId,
6295
7554
  messageId: inbound.messageId,
6296
7555
  text: BTW_USAGE_TEXT,
7556
+ isAuthoritative: routeLeaseAllows,
6297
7557
  });
6298
7558
  return;
6299
7559
  }
@@ -6303,28 +7563,31 @@ export class TelegramNotificationDaemon {
6303
7563
  threadId: inbound.threadId,
6304
7564
  messageId: inbound.messageId,
6305
7565
  text: BTW_QUESTION_LIMIT_TEXT,
7566
+ isAuthoritative: routeLeaseAllows,
6306
7567
  });
6307
7568
  } catch {
6308
7569
  logger.warn("notifications: /btw question-limit delivery failed");
6309
7570
  }
6310
- await this.reserveSeenUpdateId(inbound.updateId);
7571
+ await reserveRouteUpdate();
6311
7572
  return;
6312
7573
  }
6313
7574
  if (this.opts.btw?.enabled === false) {
6314
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7575
+ if (!(await reserveRouteUpdate())) return;
6315
7576
  await this.#sendBtwMessage({
6316
7577
  threadId: inbound.threadId,
6317
7578
  messageId: inbound.messageId,
6318
7579
  text: "Telegram /btw is disabled in local settings.",
7580
+ isAuthoritative: routeLeaseAllows,
6319
7581
  });
6320
7582
  return;
6321
7583
  }
6322
7584
  if (!session.ephemeralCapable || session.hostGeneration < 1) {
6323
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7585
+ if (!(await reserveRouteUpdate())) return;
6324
7586
  await this.#sendBtwMessage({
6325
7587
  threadId: inbound.threadId,
6326
7588
  messageId: inbound.messageId,
6327
7589
  text: "Restart this GJC session to enable /btw.",
7590
+ isAuthoritative: routeLeaseAllows,
6328
7591
  });
6329
7592
  return;
6330
7593
  }
@@ -6340,21 +7603,23 @@ export class TelegramNotificationDaemon {
6340
7603
  this.#btwTerminalTombstones.delete(this.#btwTerminalTombstones.keys().next().value!);
6341
7604
  }
6342
7605
  if (this.#pendingBtwTurns.size >= BTW_MAX_PENDING) {
6343
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7606
+ if (!(await reserveRouteUpdate())) return;
6344
7607
  await this.#sendBtwMessage({
6345
7608
  threadId: inbound.threadId,
6346
7609
  messageId: inbound.messageId,
6347
7610
  text: BTW_CAPACITY_TEXT,
7611
+ isAuthoritative: routeLeaseAllows,
6348
7612
  });
6349
7613
  return;
6350
7614
  }
6351
7615
  const transportSessionId = session.sessionId;
6352
7616
  const logicalSessionId = this.#logicalSessionId(session);
6353
7617
  const requestId = `btw:${crypto.randomUUID()}`;
6354
- if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
7618
+ if (!(await reserveRouteUpdate())) return;
6355
7619
  const pending: PendingBtwTurn = {
6356
7620
  transportSessionId,
6357
7621
  logicalSessionId,
7622
+ socketLease: routeLease,
6358
7623
  endpointDigest: session.endpointDigest,
6359
7624
  generation: session.hostGeneration,
6360
7625
  question: btwQuestion,
@@ -6370,6 +7635,7 @@ export class TelegramNotificationDaemon {
6370
7635
  threadId: inbound.threadId,
6371
7636
  messageId: inbound.messageId,
6372
7637
  text: "Unable to start /btw because this GJC session disconnected. Reopen the session and try again.",
7638
+ isAuthoritative: routeLeaseAllows,
6373
7639
  });
6374
7640
  return;
6375
7641
  }
@@ -6377,9 +7643,10 @@ export class TelegramNotificationDaemon {
6377
7643
  }
6378
7644
  const control = hasMedia ? { kind: "none" as const } : preliminaryControl;
6379
7645
  if (control.kind !== "none") {
6380
- await this.rememberSeenUpdateId(inbound.updateId);
7646
+ if (!(await reserveRouteUpdate())) return;
6381
7647
  const sendControlNotice = async (body: string): Promise<void> => {
6382
7648
  try {
7649
+ if (!routeLeaseAllows()) return;
6383
7650
  await this.botApi.call("sendMessage", {
6384
7651
  chat_id: this.opts.chatId,
6385
7652
  message_thread_id: Number(inbound.threadId),
@@ -6398,6 +7665,7 @@ export class TelegramNotificationDaemon {
6398
7665
  await sendControlNotice("Session control unavailable: session is disconnected.");
6399
7666
  return;
6400
7667
  }
7668
+ if (!routeAllows()) return;
6401
7669
  session.ws.send(
6402
7670
  JSON.stringify({
6403
7671
  type: "control_command",
@@ -6417,6 +7685,7 @@ export class TelegramNotificationDaemon {
6417
7685
  // Telegram asks always accept custom text (the SDK maps a string answer
6418
7686
  // to the ask's custom-input slot), so route the latest pending ask here.
6419
7687
  const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
7688
+ if (!routeAllows()) return;
6420
7689
  if (pendingAsk) {
6421
7690
  session.ws.send(
6422
7691
  JSON.stringify({
@@ -6427,9 +7696,11 @@ export class TelegramNotificationDaemon {
6427
7696
  }),
6428
7697
  );
6429
7698
  await this.rememberSeenUpdateId(inbound.updateId);
6430
- 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);
6431
7701
  return;
6432
7702
  }
7703
+ if (!routeAllows()) return;
6433
7704
  session.ws.send(
6434
7705
  JSON.stringify(
6435
7706
  cfg
@@ -6450,8 +7721,12 @@ export class TelegramNotificationDaemon {
6450
7721
  // flipped to consumed when the session acks the turn that picks it
6451
7722
  // up. Config commands are not user turns and get no reaction.
6452
7723
  if (!cfg && inbound.messageId !== undefined) {
6453
- this.inboundReactions.set(inbound.updateId, { messageId: inbound.messageId });
6454
- 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);
6455
7730
  }
6456
7731
  }
6457
7732
  return;
@@ -6465,8 +7740,14 @@ export class TelegramNotificationDaemon {
6465
7740
  pairedChatId: this.opts.chatId,
6466
7741
  });
6467
7742
  if (decision.kind === "reply") {
6468
- const session = this.sessions.get(decision.sessionId);
6469
- 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
+ ) {
6470
7751
  await this.sendStaleGuidance(callbackId);
6471
7752
  return;
6472
7753
  }
@@ -6517,31 +7798,38 @@ export class TelegramNotificationDaemon {
6517
7798
  }
6518
7799
 
6519
7800
  async run(): Promise<void> {
6520
- // Runtime callers can bypass TypeScript's option type. Do not derive an
6521
- // ownership identity from absent credentials; lifecycle startup still owns
6522
- // cleanup of a factory-created server on this fail-closed path.
6523
- if (!validBotToken(this.opts.botToken)) {
6524
- await this.startLifecycleControl();
6525
- return;
6526
- }
6527
- this.running = await renewDaemonHeartbeat({
6528
- settings: this.opts.settings,
6529
- ownerId: this.opts.ownerId,
6530
- acquisitionId: this.opts.ownerId,
6531
- tokenFingerprint: tokenFingerprint(this.opts.botToken),
6532
- chatId: this.opts.chatId,
6533
- fs: this.fsImpl,
6534
- now: this.opts.now,
6535
- pid: this.opts.pid ?? process.pid,
6536
- pidIncarnation: this.opts.pidIncarnation,
6537
- });
6538
- if (!this.running) return;
6539
- this.runtime.start();
6540
- this.startOwnershipHeartbeatTimer();
6541
- this.startFlushTimer();
6542
- this.startScanTimer();
6543
- 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;
6544
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();
6545
7833
  await this.refreshBotIdentity();
6546
7834
  await this.registerBotCommands();
6547
7835
  await this.loadAliases();
@@ -6549,9 +7837,6 @@ export class TelegramNotificationDaemon {
6549
7837
  await this.loadSeenUpdateIds();
6550
7838
  await this.replyStore.load();
6551
7839
  await this.runScan();
6552
- // Owner-only: start the session-lifecycle control server now that
6553
- // ownership is confirmed (singleton-safe). Best-effort; degrades.
6554
- await this.startLifecycleControl();
6555
7840
  let idleSince = this.runtime.now();
6556
7841
  while (this.running) {
6557
7842
  if (await this.controlStopRequested()) break;
@@ -6603,61 +7888,64 @@ export class TelegramNotificationDaemon {
6603
7888
  await this.runtime.sleep(10);
6604
7889
  }
6605
7890
  } finally {
6606
- let toolShutdownError: unknown;
6607
- try {
6608
- await this.beginToolActivityShutdown();
6609
- } catch (error) {
6610
- toolShutdownError = error;
6611
- }
6612
- this.effects.beginShutdown();
6613
- this.#deliveryAbort.abort();
6614
- let persisted = false;
6615
- const shutdown = this.effects.allowTerminal(async () => {
6616
- if (toolShutdownError) throw toolShutdownError;
6617
- await this.#drainBtwTurns();
6618
- await this.toolTerminalizationChain;
6619
- this.runtime.stop();
6620
- this.stopOwnershipHeartbeatTimer();
6621
- this.stopFlushTimer();
6622
- this.stopScanTimer();
6623
- this.stopTypingTimer();
6624
- this.stopLifecycleControl();
6625
- await this.cleanupAllAttachmentDirs();
6626
- await this.persistAliases();
6627
- await this.persistTopics();
6628
- await this.persistSeenUpdateIds();
6629
- await this.opts.control?.clear?.(this.opts.ownerId);
6630
- persisted = true;
6631
- });
6632
- const deadline = Promise.withResolvers<boolean>();
6633
- const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
6634
- const completed = await Promise.race([
6635
- shutdown.then(
6636
- () => true,
6637
- error => {
6638
- logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
6639
- return false;
6640
- },
6641
- ),
6642
- deadline.promise,
6643
- ]);
6644
- clearTimeout(deadlineTimer);
6645
- const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
6646
- if (!quiesced || !persisted) {
6647
- logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
6648
- } else {
6649
- await releaseDaemonOwnership({
6650
- settings: this.opts.settings,
6651
- ownerId: this.opts.ownerId,
6652
- acquisitionId: this.opts.ownerId,
6653
- tokenFingerprint: tokenFingerprint(this.opts.botToken),
6654
- chatId: this.opts.chatId,
6655
- pid: this.opts.pid ?? process.pid,
6656
- generation: DAEMON_GENERATION,
6657
- pidIncarnation: this.opts.pidIncarnation,
6658
- fs: this.fsImpl,
6659
- 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;
6660
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
+ }
6661
7949
  }
6662
7950
  }
6663
7951
  }