@gajae-code/coding-agent 0.10.2 → 0.11.1

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 (476) hide show
  1. package/CHANGELOG.md +58 -2
  2. package/dist/types/append-only-mode.d.ts +1 -1
  3. package/dist/types/async/job-manager.d.ts +46 -7
  4. package/dist/types/cli/args.d.ts +4 -5
  5. package/dist/types/cli/daemon-cli.d.ts +12 -1
  6. package/dist/types/cli/notify-cli.d.ts +21 -1
  7. package/dist/types/cli.d.ts +3 -0
  8. package/dist/types/commands/daemon.d.ts +39 -0
  9. package/dist/types/commands/launch.d.ts +3 -0
  10. package/dist/types/commands/notify.d.ts +28 -1
  11. package/dist/types/commands/sdk.d.ts +33 -0
  12. package/dist/types/config/atomic-yaml-patch.d.ts +85 -0
  13. package/dist/types/config/model-profile-activation.d.ts +35 -15
  14. package/dist/types/config/model-profiles.d.ts +11 -5
  15. package/dist/types/config/model-registry.d.ts +3 -3
  16. package/dist/types/config/model-resolver.d.ts +39 -6
  17. package/dist/types/config/model-selector-value.d.ts +17 -0
  18. package/dist/types/config/models-config-schema.d.ts +10 -6
  19. package/dist/types/config/settings-schema.d.ts +133 -6
  20. package/dist/types/config/settings.d.ts +63 -35
  21. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -6
  22. package/dist/types/coordinator-mcp/server.d.ts +6 -49
  23. package/dist/types/daemon/builtin.d.ts +6 -5
  24. package/dist/types/daemon/control-types.d.ts +1 -1
  25. package/dist/types/discovery/mcp-json.d.ts +12 -1
  26. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  27. package/dist/types/extensibility/extensions/compact-handler.d.ts +4 -2
  28. package/dist/types/extensibility/extensions/get-commands-handler.d.ts +2 -2
  29. package/dist/types/extensibility/extensions/loader.d.ts +10 -1
  30. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  31. package/dist/types/extensibility/extensions/types.d.ts +141 -16
  32. package/dist/types/extensibility/hooks/types.d.ts +1 -1
  33. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -2
  34. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  35. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  36. package/dist/types/gjc-runtime/team-runtime.d.ts +3 -4
  37. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +13 -0
  38. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -1
  39. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
  40. package/dist/types/gjc-runtime/windows-powershell-command.d.ts +9 -0
  41. package/dist/types/harness-control-plane/operate.d.ts +5 -5
  42. package/dist/types/harness-control-plane/owner.d.ts +18 -4
  43. package/dist/types/harness-control-plane/receipts.d.ts +0 -1
  44. package/dist/types/harness-control-plane/sdk-transport.d.ts +43 -0
  45. package/dist/types/harness-control-plane/session-transport.d.ts +48 -0
  46. package/dist/types/harness-control-plane/types.d.ts +3 -4
  47. package/dist/types/lsp/index.d.ts +1 -1
  48. package/dist/types/main.d.ts +32 -38
  49. package/dist/types/modes/acp/acp-agent.d.ts +224 -53
  50. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -0
  51. package/dist/types/modes/acp/acp-mode.d.ts +6 -15
  52. package/dist/types/modes/acp/permission-mode.d.ts +4 -0
  53. package/dist/types/modes/acp/startup-options.d.ts +6 -0
  54. package/dist/types/modes/components/agent-dashboard.d.ts +3 -0
  55. package/dist/types/modes/components/model-selector.d.ts +1 -0
  56. package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
  57. package/dist/types/modes/components/settings-selector.d.ts +6 -4
  58. package/dist/types/modes/components/welcome.d.ts +1 -1
  59. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  60. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
  61. package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
  62. package/dist/types/modes/index.d.ts +0 -4
  63. package/dist/types/modes/interactive-mode.d.ts +5 -2
  64. package/dist/types/modes/runtime-init.d.ts +2 -2
  65. package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
  66. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
  67. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
  68. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
  69. package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
  70. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +170 -30
  71. package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
  72. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
  73. package/dist/types/modes/types.d.ts +7 -2
  74. package/dist/types/runtime-mcp/client.d.ts +2 -0
  75. package/dist/types/runtime-mcp/config.d.ts +5 -1
  76. package/dist/types/runtime-mcp/manager.d.ts +8 -1
  77. package/dist/types/runtime-mcp/types.d.ts +4 -0
  78. package/dist/types/sdk/acp/adapter.d.ts +67 -0
  79. package/dist/types/sdk/acp/index.d.ts +2 -0
  80. package/dist/types/sdk/broker/broker.d.ts +53 -0
  81. package/dist/types/sdk/broker/discovery.d.ts +30 -0
  82. package/dist/types/sdk/broker/ensure.d.ts +42 -0
  83. package/dist/types/sdk/broker/identity.d.ts +3 -0
  84. package/dist/types/sdk/broker/index.d.ts +8 -0
  85. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
  86. package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
  87. package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
  88. package/dist/types/sdk/broker/runtime.d.ts +33 -0
  89. package/dist/types/sdk/broker/session-index.d.ts +57 -0
  90. package/dist/types/sdk/broker/state-version.d.ts +8 -0
  91. package/dist/types/sdk/broker/transport.d.ts +9 -0
  92. package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
  93. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
  94. package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
  95. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
  96. package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
  97. package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
  98. package/dist/types/sdk/bus/config.d.ts +274 -0
  99. package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
  100. package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
  101. package/dist/types/sdk/bus/discord-daemon.d.ts +50 -0
  102. package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
  103. package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
  104. package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
  105. package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
  106. package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
  107. package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
  108. package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
  109. package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
  110. package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
  111. package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
  112. package/dist/types/sdk/bus/session-control.d.ts +83 -0
  113. package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
  114. package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
  115. package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
  116. package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
  117. package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
  118. package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
  119. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
  120. package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
  121. package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +78 -17
  122. package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
  123. package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
  124. package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
  125. package/dist/types/sdk/cli/index.d.ts +1 -0
  126. package/dist/types/sdk/cli/session-cli.d.ts +19 -0
  127. package/dist/types/sdk/client/client.d.ts +1 -0
  128. package/dist/types/sdk/client/discovery.d.ts +27 -0
  129. package/dist/types/sdk/client/index.d.ts +2 -0
  130. package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
  131. package/dist/types/sdk/host/control/index.d.ts +2 -0
  132. package/dist/types/sdk/host/control/operations.d.ts +65 -0
  133. package/dist/types/sdk/host/events.d.ts +35 -0
  134. package/dist/types/sdk/host/host.d.ts +35 -0
  135. package/dist/types/sdk/host/index.d.ts +4 -0
  136. package/dist/types/sdk/host/query/cursor.d.ts +62 -0
  137. package/dist/types/sdk/host/query/handlers.d.ts +70 -0
  138. package/dist/types/sdk/host/query/index.d.ts +3 -0
  139. package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
  140. package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
  141. package/dist/types/sdk/host/types.d.ts +32 -0
  142. package/dist/types/sdk/index.d.ts +6 -0
  143. package/dist/types/sdk/lifecycle-session.d.ts +14 -0
  144. package/dist/types/sdk/mcp/index.d.ts +1 -0
  145. package/dist/types/sdk/mcp/server.d.ts +37 -0
  146. package/dist/types/sdk/models.d.ts +44 -0
  147. package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
  148. package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
  149. package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
  150. package/dist/types/sdk/startup-capability.d.ts +50 -0
  151. package/dist/types/session/agent-session.d.ts +114 -21
  152. package/dist/types/session/default-model-selection.d.ts +25 -0
  153. package/dist/types/session/fallback-chain-controller.d.ts +52 -0
  154. package/dist/types/session/session-manager.d.ts +58 -1
  155. package/dist/types/setup/credential-auto-import.d.ts +36 -6
  156. package/dist/types/tools/ask-answer-registry.d.ts +1 -1
  157. package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
  158. package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
  159. package/dist/types/tools/browser/launch.d.ts +22 -0
  160. package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
  161. package/dist/types/tools/browser/profile-posture.d.ts +37 -0
  162. package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
  163. package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
  164. package/dist/types/tools/browser/registry.d.ts +19 -0
  165. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  166. package/dist/types/tools/index.d.ts +8 -8
  167. package/dist/types/tools/subagent-render.d.ts +1 -0
  168. package/dist/types/tools/subagent.d.ts +6 -1
  169. package/package.json +34 -13
  170. package/scripts/build-sdk-package-smoke.ts +99 -0
  171. package/scripts/capture-notifications-settings-showcase.ts +435 -0
  172. package/scripts/compile-args.ts +4 -2
  173. package/scripts/g005-daemon-smoke.ts +4 -5
  174. package/scripts/g011-daemon-path-smoke.ts +5 -5
  175. package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
  176. package/scripts/generate-sdk-operation-inventory.ts +867 -0
  177. package/scripts/generate-telegram-baseline-manifest.ts +128 -0
  178. package/scripts/manifest-schema.ts +124 -0
  179. package/scripts/run-test-manifest.ts +153 -0
  180. package/scripts/run-threat-model-check.ts +157 -0
  181. package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
  182. package/scripts/verify-gjc-sdk-rename.ts +90 -0
  183. package/src/append-only-mode.ts +1 -1
  184. package/src/async/job-manager.ts +406 -63
  185. package/src/cli/args.ts +43 -12
  186. package/src/cli/auth-gateway-cli.ts +2 -2
  187. package/src/cli/config-cli.ts +2 -1
  188. package/src/cli/daemon-cli.ts +63 -14
  189. package/src/cli/list-models.ts +1 -1
  190. package/src/cli/notify-cli.ts +288 -286
  191. package/src/cli.ts +21 -2
  192. package/src/commands/daemon.ts +50 -6
  193. package/src/commands/harness.ts +93 -36
  194. package/src/commands/launch.ts +5 -2
  195. package/src/commands/mcp-serve.ts +19 -12
  196. package/src/commands/notify.ts +36 -3
  197. package/src/commands/sdk.ts +426 -0
  198. package/src/commit/agentic/agent.ts +1 -0
  199. package/src/config/atomic-yaml-patch.ts +423 -0
  200. package/src/config/model-profile-activation.ts +240 -89
  201. package/src/config/model-profiles.ts +18 -15
  202. package/src/config/model-registry.ts +51 -26
  203. package/src/config/model-resolver.ts +138 -51
  204. package/src/config/model-selector-value.ts +50 -0
  205. package/src/config/models-config-schema.ts +14 -21
  206. package/src/config/settings-schema.ts +128 -10
  207. package/src/config/settings.ts +1120 -158
  208. package/src/coordinator-mcp/model-preset.ts +74 -20
  209. package/src/coordinator-mcp/server.ts +1597 -3052
  210. package/src/daemon/builtin.ts +8 -5
  211. package/src/daemon/control-types.ts +1 -1
  212. package/src/defaults/gjc/skills/team/SKILL.md +4 -4
  213. package/src/discovery/mcp-json.ts +327 -20
  214. package/src/extensibility/custom-tools/types.ts +1 -1
  215. package/src/extensibility/extensions/compact-handler.ts +3 -3
  216. package/src/extensibility/extensions/get-commands-handler.ts +2 -2
  217. package/src/extensibility/extensions/loader.ts +69 -5
  218. package/src/extensibility/extensions/runner.ts +112 -4
  219. package/src/extensibility/extensions/types.ts +176 -17
  220. package/src/extensibility/hooks/types.ts +1 -1
  221. package/src/gjc-runtime/launch-tmux.ts +170 -86
  222. package/src/gjc-runtime/psmux-detect.ts +82 -4
  223. package/src/gjc-runtime/session-state-sidecar.ts +41 -31
  224. package/src/gjc-runtime/team-runtime.ts +54 -35
  225. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
  226. package/src/gjc-runtime/tmux-owner-isolation.ts +1 -1
  227. package/src/gjc-runtime/tmux-sessions.ts +19 -13
  228. package/src/gjc-runtime/ultragoal-guard.ts +108 -14
  229. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
  230. package/src/gjc-runtime/ultragoal-runtime.ts +227 -11
  231. package/src/gjc-runtime/windows-powershell-command.ts +58 -0
  232. package/src/harness-control-plane/operate.ts +9 -9
  233. package/src/harness-control-plane/owner.ts +283 -39
  234. package/src/harness-control-plane/receipts.ts +0 -1
  235. package/src/harness-control-plane/sdk-transport.ts +395 -0
  236. package/src/harness-control-plane/session-transport.ts +94 -0
  237. package/src/harness-control-plane/types.ts +1 -1
  238. package/src/internal-urls/docs-index.generated.ts +34 -35
  239. package/src/lsp/client.ts +42 -6
  240. package/src/lsp/index.ts +2 -2
  241. package/src/main.ts +299 -204
  242. package/src/modes/DESIGN.md +258 -0
  243. package/src/modes/acp/acp-agent.ts +1228 -2612
  244. package/src/modes/acp/acp-client-bridge.ts +2 -20
  245. package/src/modes/acp/acp-event-mapper.ts +18 -2
  246. package/src/modes/acp/acp-mode.ts +8 -36
  247. package/src/modes/acp/permission-mode.ts +25 -0
  248. package/src/modes/acp/startup-options.ts +6 -0
  249. package/src/modes/components/agent-dashboard.ts +37 -17
  250. package/src/modes/components/model-selector.ts +93 -35
  251. package/src/modes/components/notifications-settings-editor.ts +1379 -0
  252. package/src/modes/components/settings-selector.ts +60 -11
  253. package/src/modes/components/welcome.ts +4 -4
  254. package/src/modes/controllers/command-controller.ts +10 -17
  255. package/src/modes/controllers/event-controller.ts +11 -16
  256. package/src/modes/controllers/extension-ui-controller.ts +396 -28
  257. package/src/modes/controllers/selector-controller.ts +674 -36
  258. package/src/modes/index.ts +0 -22
  259. package/src/modes/interactive-mode.ts +87 -36
  260. package/src/modes/runtime-init.ts +298 -6
  261. package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
  262. package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
  263. package/src/modes/shared/agent-wire/event-contract.ts +7 -8
  264. package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
  265. package/src/modes/shared/agent-wire/event-observation.ts +16 -7
  266. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1258 -87
  267. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
  268. package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
  269. package/src/modes/types.ts +7 -2
  270. package/src/runtime-mcp/client.ts +94 -19
  271. package/src/runtime-mcp/config.ts +27 -15
  272. package/src/runtime-mcp/manager.ts +343 -105
  273. package/src/runtime-mcp/oauth-flow.ts +42 -12
  274. package/src/runtime-mcp/transports/http.ts +49 -19
  275. package/src/runtime-mcp/transports/stdio.ts +47 -28
  276. package/src/runtime-mcp/types.ts +10 -0
  277. package/src/sdk/acp/adapter.ts +418 -0
  278. package/src/sdk/acp/index.ts +2 -0
  279. package/src/sdk/broker/broker.ts +642 -0
  280. package/src/sdk/broker/discovery.ts +113 -0
  281. package/src/sdk/broker/ensure.ts +307 -0
  282. package/src/sdk/broker/identity.ts +42 -0
  283. package/src/sdk/broker/index.ts +8 -0
  284. package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
  285. package/src/sdk/broker/internal-source.bunfig.toml +1 -0
  286. package/src/sdk/broker/lifecycle-ledger.ts +286 -0
  287. package/src/sdk/broker/lifecycle.ts +2157 -0
  288. package/src/sdk/broker/process-incarnation.ts +159 -0
  289. package/src/sdk/broker/runtime.ts +164 -0
  290. package/src/sdk/broker/session-index.ts +318 -0
  291. package/src/sdk/broker/state-version.ts +23 -0
  292. package/src/sdk/broker/transport.ts +125 -0
  293. package/src/sdk/bus/chat-command-policy.ts +107 -0
  294. package/src/sdk/bus/chat-daemon-cli.ts +214 -0
  295. package/src/sdk/bus/chat-daemon-control.ts +713 -0
  296. package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
  297. package/src/sdk/bus/chat-effect-journal.ts +355 -0
  298. package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
  299. package/src/sdk/bus/config.ts +433 -0
  300. package/src/sdk/bus/conversation-store.ts +391 -0
  301. package/src/sdk/bus/discord-conversation.ts +85 -0
  302. package/src/sdk/bus/discord-daemon.ts +2292 -0
  303. package/src/sdk/bus/discord-live-provider.ts +521 -0
  304. package/src/sdk/bus/discord-provider.ts +56 -0
  305. package/src/{notifications → sdk/bus}/html-format.ts +2 -2
  306. package/src/sdk/bus/index.ts +3979 -0
  307. package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
  308. package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
  309. package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
  310. package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
  311. package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
  312. package/src/sdk/bus/natives-augment.d.ts +38 -0
  313. package/src/sdk/bus/notification-orchestration.ts +535 -0
  314. package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
  315. package/src/sdk/bus/session-control.ts +333 -0
  316. package/src/sdk/bus/slack-conversation.ts +74 -0
  317. package/src/sdk/bus/slack-daemon.ts +2144 -0
  318. package/src/sdk/bus/slack-live-provider.ts +380 -0
  319. package/src/sdk/bus/slack-provider.ts +76 -0
  320. package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
  321. package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
  322. package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
  323. package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
  324. package/src/{notifications → sdk/bus}/telegram-daemon.ts +603 -63
  325. package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
  326. package/src/sdk/bus/telegram-setup.ts +663 -0
  327. package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
  328. package/src/sdk/cli/index.ts +1 -0
  329. package/src/sdk/cli/session-cli.ts +275 -0
  330. package/src/sdk/client/client.ts +1 -0
  331. package/src/sdk/client/discovery.ts +108 -0
  332. package/src/sdk/client/index.ts +2 -0
  333. package/src/sdk/host/control/dispatch.ts +367 -0
  334. package/src/sdk/host/control/index.ts +10 -0
  335. package/src/sdk/host/control/operations.ts +63 -0
  336. package/src/sdk/host/events.ts +91 -0
  337. package/src/sdk/host/host.ts +296 -0
  338. package/src/sdk/host/index.ts +4 -0
  339. package/src/sdk/host/query/cursor.ts +189 -0
  340. package/src/sdk/host/query/handlers.ts +533 -0
  341. package/src/sdk/host/query/index.ts +3 -0
  342. package/src/sdk/host/query/revision-store.ts +1126 -0
  343. package/src/sdk/host/reverse-leases.ts +325 -0
  344. package/src/sdk/host/types.ts +29 -0
  345. package/src/sdk/index.ts +13 -0
  346. package/src/sdk/lifecycle-session.ts +39 -0
  347. package/src/sdk/mcp/index.ts +1 -0
  348. package/src/sdk/mcp/server.ts +332 -0
  349. package/src/sdk/models.ts +168 -0
  350. package/src/sdk/protocol/adapter-validation.ts +48 -0
  351. package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
  352. package/src/sdk/protocol/operation-registry.ts +312 -0
  353. package/src/{sdk.ts → sdk/session.ts} +339 -212
  354. package/src/sdk/startup-capability.ts +189 -0
  355. package/src/session/agent-session.ts +2650 -944
  356. package/src/session/default-model-selection.ts +93 -0
  357. package/src/session/fallback-chain-controller.ts +136 -0
  358. package/src/session/session-manager.ts +438 -11
  359. package/src/session/session-storage.ts +8 -1
  360. package/src/setup/credential-auto-import.ts +290 -35
  361. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
  362. package/src/slash-commands/builtin-registry.ts +2 -1
  363. package/src/task/executor.ts +62 -13
  364. package/src/task/index.ts +26 -16
  365. package/src/tools/ask-answer-registry.ts +1 -1
  366. package/src/tools/ask.ts +13 -8
  367. package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
  368. package/src/tools/browser/benchmark/detector-report.ts +157 -0
  369. package/src/tools/browser/benchmark/run-suite.ts +39 -0
  370. package/src/tools/browser/launch.ts +124 -19
  371. package/src/tools/browser/profile-discovery.ts +63 -0
  372. package/src/tools/browser/profile-posture.ts +59 -0
  373. package/src/tools/browser/profile-reuse.ts +102 -0
  374. package/src/tools/browser/profile-warmup.ts +109 -0
  375. package/src/tools/browser/registry.ts +87 -11
  376. package/src/tools/browser/tab-protocol.ts +1 -0
  377. package/src/tools/browser/tab-supervisor.ts +1 -0
  378. package/src/tools/browser/tab-worker.ts +1 -1
  379. package/src/tools/browser.ts +6 -0
  380. package/src/tools/index.ts +8 -8
  381. package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
  382. package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
  383. package/src/tools/subagent-render.ts +19 -8
  384. package/src/tools/subagent.ts +84 -32
  385. package/src/tools/telegram-send.ts +2 -2
  386. package/vendor/insane-search/MANIFEST.json +2 -1
  387. package/vendor/insane-search/engine/bias_check.py +4 -1
  388. package/vendor/insane-search/engine/phase0.py +26 -1
  389. package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
  390. package/dist/types/modes/bridge/auth.d.ts +0 -12
  391. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
  392. package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
  393. package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
  394. package/dist/types/modes/bridge/event-stream.d.ts +0 -8
  395. package/dist/types/modes/rpc/host-tools.d.ts +0 -1
  396. package/dist/types/modes/rpc/host-uris.d.ts +0 -1
  397. package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
  398. package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
  399. package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
  400. package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
  401. package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
  402. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
  403. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
  404. package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
  405. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
  406. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
  407. package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
  408. package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
  409. package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
  410. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
  411. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
  412. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
  413. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
  414. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
  415. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
  416. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
  417. package/dist/types/notifications/config.d.ts +0 -132
  418. package/src/harness-control-plane/rpc-adapter.ts +0 -290
  419. package/src/modes/bridge/auth.ts +0 -41
  420. package/src/modes/bridge/bridge-client-bridge.ts +0 -47
  421. package/src/modes/bridge/bridge-mode.ts +0 -675
  422. package/src/modes/bridge/bridge-ui-context.ts +0 -200
  423. package/src/modes/bridge/event-stream.ts +0 -70
  424. package/src/modes/rpc/host-tools.ts +0 -1
  425. package/src/modes/rpc/host-uris.ts +0 -1
  426. package/src/modes/rpc/rpc-client.ts +0 -1166
  427. package/src/modes/rpc/rpc-mode.ts +0 -1029
  428. package/src/modes/rpc/rpc-socket-security.ts +0 -115
  429. package/src/modes/rpc/rpc-types.ts +0 -637
  430. package/src/modes/shared/agent-wire/command-contract.ts +0 -18
  431. package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
  432. package/src/modes/shared/agent-wire/command-validation.ts +0 -181
  433. package/src/modes/shared/agent-wire/handshake.ts +0 -157
  434. package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
  435. package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
  436. package/src/modes/shared/agent-wire/protocol.ts +0 -46
  437. package/src/modes/shared/agent-wire/responses.ts +0 -17
  438. package/src/modes/shared/agent-wire/scopes.ts +0 -94
  439. package/src/modes/shared/agent-wire/session-registry.ts +0 -109
  440. package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
  441. package/src/modes/shared/agent-wire/ui-result.ts +0 -48
  442. package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
  443. package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
  444. package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
  445. package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
  446. package/src/notifications/config.ts +0 -219
  447. package/src/notifications/index.ts +0 -1835
  448. package/src/notifications/telegram-daemon-cli.ts +0 -243
  449. package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
  450. /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
  451. /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
  452. /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
  453. /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
  454. /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
  455. /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
  456. /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
  457. /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
  458. /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
  459. /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
  460. /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
  461. /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
  462. /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
  463. /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
  464. /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
  465. /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
  466. /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
  467. /package/src/{notifications → sdk/bus}/engine.ts +0 -0
  468. /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
  469. /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
  470. /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
  471. /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
  472. /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
  473. /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
  474. /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
  475. /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
  476. /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
@@ -0,0 +1,2292 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { SdkClientError } from "../client/client";
3
+ import type { ChatDeliveryError } from "./chat-daemon-runtime";
4
+
5
+ import {
6
+ type ChatEffect,
7
+ ChatEffectJournal,
8
+ type ChatEffectLease,
9
+ type ChatEffectReceipt,
10
+ } from "./chat-effect-journal";
11
+ import { ConversationStore } from "./conversation-store";
12
+
13
+ import {
14
+ type DiscordConversation,
15
+ type DiscordInboundDispatchReceipt,
16
+ discordConversationKey,
17
+ normalizeDiscordConversation,
18
+ } from "./discord-conversation";
19
+ import type { DiscordInboundEvent, DiscordMessageComponent, DiscordProvider, DiscordThread } from "./discord-provider";
20
+
21
+ const FAILURE = "This conversation is no longer available.";
22
+
23
+ export class DiscordEndpointBindingError extends Error {
24
+ constructor(message = "Discord session endpoint changed before outbound publication.") {
25
+ super(message);
26
+ this.name = "DiscordEndpointBindingError";
27
+ }
28
+ }
29
+
30
+ type DiscordClosingIntent = Readonly<{ nonce: string; at: number }>;
31
+
32
+ function closingIntent(record: DiscordConversation | undefined): DiscordClosingIntent | undefined {
33
+ const candidate = record as { state?: unknown; closingNonce?: unknown; closingAt?: unknown } | undefined;
34
+ return candidate?.state === "closing" &&
35
+ typeof candidate.closingNonce === "string" &&
36
+ typeof candidate.closingAt === "number"
37
+ ? { nonce: candidate.closingNonce, at: candidate.closingAt }
38
+ : undefined;
39
+ }
40
+
41
+ function withClosingIntent(record: DiscordConversation, nonce: string, at: number): DiscordConversation {
42
+ return {
43
+ ...record,
44
+ generation: record.generation + 1,
45
+ state: "closing",
46
+ closingNonce: nonce,
47
+ closingAt: at,
48
+ pendingActionId: undefined,
49
+ pendingActionNonce: undefined,
50
+ pendingActionEffectId: undefined,
51
+ } as unknown as DiscordConversation;
52
+ }
53
+
54
+ function withoutClosingIntent(record: DiscordConversation, closedAt: number): DiscordConversation {
55
+ const {
56
+ closingNonce: _closingNonce,
57
+ closingAt: _closingAt,
58
+ ...rest
59
+ } = record as DiscordConversation & {
60
+ closingNonce?: unknown;
61
+ closingAt?: unknown;
62
+ };
63
+ return {
64
+ ...rest,
65
+ generation: record.generation + 1,
66
+ state: "closed",
67
+ closedAt,
68
+ pendingActionId: undefined,
69
+ pendingActionNonce: undefined,
70
+ pendingActionEffectId: undefined,
71
+ };
72
+ }
73
+
74
+ /** A captured live SDK transport. `isCurrent` must fail closed after replacement or removal. */
75
+ export interface DiscordEndpointBinding {
76
+ generation: number;
77
+ isCurrent(): boolean;
78
+ send(frame: Record<string, unknown>): void | Promise<void>;
79
+ }
80
+
81
+ export interface DiscordLeaseRecoveryScheduler {
82
+ setTimeout(callback: () => void | Promise<void>, delayMs: number): unknown;
83
+ clearTimeout(handle: unknown): void;
84
+ }
85
+
86
+ export interface DiscordNotificationDaemonOptions {
87
+ agentDir: string;
88
+ repo: string;
89
+ guildId: string;
90
+ parentChannelId: string;
91
+ provider: DiscordProvider;
92
+ now?: () => number;
93
+ leaseRecoveryScheduler?: DiscordLeaseRecoveryScheduler;
94
+ resolveEndpoint: (sessionId: string, expectedGeneration?: number) => Promise<DiscordEndpointBinding | null>;
95
+ onCommand?: (
96
+ sessionId: string,
97
+ content: string,
98
+ endpoint: DiscordEndpointBinding,
99
+ idempotencyKey: string,
100
+ ) => Promise<boolean>;
101
+ }
102
+
103
+ export interface DiscordNotificationInput {
104
+ sessionId: string;
105
+ endpointGeneration: number;
106
+ content: string;
107
+ threadName?: string;
108
+ actionId?: string;
109
+ options?: string[];
110
+ }
111
+
112
+ type DiscordInboundEffectPayload =
113
+ | {
114
+ type: "command";
115
+ content: string;
116
+ idempotencyKey: string;
117
+ routing: DiscordInboundRouting;
118
+ }
119
+ | {
120
+ type: "reply";
121
+ id: string;
122
+ answer: string | number;
123
+ idempotencyKey: string;
124
+ routing: DiscordInboundRouting;
125
+ };
126
+
127
+ type DiscordInboundRouting = {
128
+ guildId: string;
129
+ parentId: string;
130
+ threadId: string;
131
+ eventId: string;
132
+ interactionId?: string;
133
+ kind: "command" | "action";
134
+ actionId?: string;
135
+ actionNonce?: string;
136
+ };
137
+
138
+ function backfilledEffectIncarnationId(record: DiscordConversation): string {
139
+ if (record.effectIncarnationId) return record.effectIncarnationId;
140
+ if (record.createNonce) return record.createNonce;
141
+ return `legacy-${createHash("sha256")
142
+ .update(
143
+ [record.appId, record.guildId, record.parentChannelId, record.threadId ?? "", record.sessionId ?? ""].join(
144
+ "\u0000",
145
+ ),
146
+ )
147
+ .digest("hex")
148
+ .slice(0, 32)}`;
149
+ }
150
+
151
+ type DiscordInboundClaim = {
152
+ receipt: DiscordInboundDispatchReceipt;
153
+ liveCallbackEffect?: ChatEffect<DiscordInboundEffectPayload>;
154
+ };
155
+
156
+ /** SDK-only Discord threaded notification daemon. It owns no AgentSession and never retains endpoint credentials. */
157
+ export class DiscordNotificationDaemon {
158
+ readonly #store: ConversationStore<DiscordConversation>;
159
+ readonly #now: () => number;
160
+ readonly #leaseRecoveryScheduler: DiscordLeaseRecoveryScheduler;
161
+ readonly #creates = new Map<string, Promise<DiscordConversation>>();
162
+ readonly #resumes = new Map<string, Promise<DiscordConversation | undefined>>();
163
+ readonly #archives = new Map<string, Promise<void>>();
164
+ readonly #resolveEndpoint: (
165
+ sessionId: string,
166
+ expectedGeneration?: number,
167
+ ) => Promise<DiscordEndpointBinding | null>;
168
+ readonly #effects: ChatEffectJournal;
169
+ readonly #activeWork = new Set<Promise<unknown>>();
170
+ readonly #inflightInbound = new Set<string>();
171
+ #started = false;
172
+ #lifecycleGeneration = 0;
173
+ #startTask: Promise<void> | undefined;
174
+ #stopTask: Promise<void> | undefined;
175
+ #providerStarting = false;
176
+
177
+ #leaseRecoveryTimer: unknown;
178
+ #leaseRecoveryAt: number | undefined;
179
+ #leaseRecoveryFailures = 0;
180
+
181
+ readonly #dispatchOwner = randomUUID();
182
+ readonly #dispatchLeaseMs = 60_000;
183
+ readonly #providerOwner = randomUUID();
184
+ readonly #providerLeaseMs = 60_000;
185
+ constructor(private readonly options: DiscordNotificationDaemonOptions) {
186
+ this.#store = new ConversationStore({ agentDir: options.agentDir, kind: "discord", now: options.now });
187
+ this.#effects = new ChatEffectJournal({ agentDir: options.agentDir, transport: "discord", now: options.now });
188
+ this.#now = options.now ?? Date.now;
189
+ this.#leaseRecoveryScheduler = options.leaseRecoveryScheduler ?? {
190
+ setTimeout: (callback, delayMs) => setTimeout(() => void callback(), delayMs),
191
+ clearTimeout: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
192
+ };
193
+ this.#resolveEndpoint = options.resolveEndpoint;
194
+ }
195
+
196
+ async start(): Promise<void> {
197
+ if (this.#started && !this.#stopTask) return;
198
+ if (this.#stopTask) {
199
+ await this.#stopTask;
200
+ return await this.start();
201
+ }
202
+ if (this.#startTask) return await this.#startTask;
203
+
204
+ const lifecycleGeneration = ++this.#lifecycleGeneration;
205
+ const task = this.#start(lifecycleGeneration);
206
+ this.#startTask = task;
207
+ try {
208
+ await task;
209
+ } finally {
210
+ if (this.#startTask === task) this.#startTask = undefined;
211
+ }
212
+ }
213
+
214
+ async #start(lifecycleGeneration: number): Promise<void> {
215
+ try {
216
+ // Provider start is the delivery boundary; complete crash recovery first.
217
+ await this.#reconcileTerminalInboundReceipts();
218
+ const closingBeforeProviderRecoveryFailed = await this.#recoverClosingConversations();
219
+ const providerRecoveryFailed = await this.#drainProviderEffects();
220
+ await this.#reconcileTerminalInboundReceipts();
221
+ const closingAfterProviderRecoveryFailed = await this.#recoverClosingConversations();
222
+ const inboundRecoveryFailed = await this.#drainPendingDispatches();
223
+ const recoveryFailed =
224
+ closingBeforeProviderRecoveryFailed ||
225
+ providerRecoveryFailed ||
226
+ closingAfterProviderRecoveryFailed ||
227
+ inboundRecoveryFailed;
228
+ if (lifecycleGeneration !== this.#lifecycleGeneration) return;
229
+
230
+ this.#started = true;
231
+ await this.#scheduleLeaseRecovery(recoveryFailed);
232
+ // stop() invalidates this generation before awaiting recovery or start. This
233
+ // guard is immediately adjacent to the Gateway-open boundary.
234
+ if (lifecycleGeneration !== this.#lifecycleGeneration) {
235
+ this.#started = false;
236
+ return;
237
+ }
238
+ this.#providerStarting = true;
239
+ try {
240
+ await this.options.provider.start(
241
+ event => this.#track(this.handleInbound(event)),
242
+ () => {},
243
+ );
244
+ } finally {
245
+ this.#providerStarting = false;
246
+ }
247
+ if (lifecycleGeneration !== this.#lifecycleGeneration) {
248
+ this.#started = false;
249
+ await this.options.provider.stop();
250
+ }
251
+ } catch (error) {
252
+ if (lifecycleGeneration === this.#lifecycleGeneration) {
253
+ this.#started = false;
254
+ if (this.#leaseRecoveryTimer !== undefined)
255
+ this.#leaseRecoveryScheduler.clearTimeout(this.#leaseRecoveryTimer);
256
+ this.#leaseRecoveryTimer = undefined;
257
+ this.#leaseRecoveryAt = undefined;
258
+ }
259
+ throw error;
260
+ }
261
+ }
262
+
263
+ async stop(): Promise<void> {
264
+ if (this.#stopTask) return await this.#stopTask;
265
+
266
+ const starting = this.#startTask;
267
+ const stopProvider = this.#started || this.#providerStarting;
268
+ ++this.#lifecycleGeneration;
269
+ this.#started = false;
270
+ if (this.#leaseRecoveryTimer !== undefined) this.#leaseRecoveryScheduler.clearTimeout(this.#leaseRecoveryTimer);
271
+ this.#leaseRecoveryTimer = undefined;
272
+ this.#leaseRecoveryAt = undefined;
273
+
274
+ const task = this.#stop(starting, stopProvider);
275
+ this.#stopTask = task;
276
+ try {
277
+ await task;
278
+ } finally {
279
+ if (this.#stopTask === task) this.#stopTask = undefined;
280
+ }
281
+ }
282
+
283
+ async #stop(starting: Promise<void> | undefined, stopProvider: boolean): Promise<void> {
284
+ // Calling provider.stop() before awaiting start lets a provider cancel a
285
+ // Gateway open that is already in flight. #start rechecks the generation
286
+ // after it resolves and closes any late open.
287
+ if (stopProvider) await this.options.provider.stop();
288
+ try {
289
+ await starting;
290
+ } catch {
291
+ // The caller of start() owns its recovery/start error; stop still drains it.
292
+ }
293
+ if (this.#providerStarting || this.#started) {
294
+ this.#started = false;
295
+ await this.options.provider.stop();
296
+ }
297
+ // Drain until quiescent: a tracked task can schedule further tracked work while
298
+ // we await, and any that outlives stop() would bleed timing pressure into the
299
+ // next daemon/test. #started is false, so no new recovery timers arm and the
300
+ // loop terminates.
301
+ while (this.#activeWork.size > 0) await Promise.all([...this.#activeWork]);
302
+ }
303
+
304
+ async notify(input: DiscordNotificationInput): Promise<DiscordConversation> {
305
+ await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
306
+ const conversation = await this.#ensureConversation(input);
307
+ await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
308
+ if (conversation.state !== "active" || !conversation.threadId) throw new Error("Discord thread is unavailable");
309
+ const pendingActionId = input.actionId;
310
+ const authoritative = pendingActionId
311
+ ? await this.#ensureActionPublication(conversation, pendingActionId)
312
+ : conversation;
313
+ await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
314
+ const components =
315
+ pendingActionId && authoritative.pendingActionNonce && input.options && input.options.length > 0
316
+ ? actionComponents(
317
+ authoritative.endpointGeneration!,
318
+ pendingActionId,
319
+ authoritative.pendingActionNonce,
320
+ input.options,
321
+ )
322
+ : undefined;
323
+
324
+ // The durable action publication intent, rather than this notification call,
325
+ // owns the provider-visible effect identity. This makes retries reconcile the
326
+ // original post and preserves the original component route.
327
+ const effectId = pendingActionId
328
+ ? authoritative.pendingActionEffectId!
329
+ : `notification:${authoritative.threadId}:${randomUUID()}`;
330
+ await this.#postEffect(effectId, authoritative, input.content, components, pendingActionId !== undefined);
331
+
332
+ return authoritative;
333
+ }
334
+
335
+ /** Posts a safe command outcome to the active mapped conversation. */
336
+ async postCommandResult(sessionId: string, content: string): Promise<boolean> {
337
+ const record = await this.#bySession(sessionId);
338
+ if (record?.state !== "active" || !record.threadId) return false;
339
+ await this.#postEffect(`command-result:${record.threadId}:${randomUUID()}`, record, content);
340
+ return true;
341
+ }
342
+
343
+ async close(sessionId: string): Promise<void> {
344
+ const closing = await this.#markClosing(sessionId);
345
+ if (closing) await this.#driveClose(closing);
346
+ }
347
+
348
+ async archive(sessionId: string): Promise<void> {
349
+ const running = this.#archives.get(sessionId);
350
+ if (running) return await running;
351
+ const task = this.#archive(sessionId);
352
+ this.#archives.set(sessionId, task);
353
+ try {
354
+ await task;
355
+ } finally {
356
+ this.#archives.delete(sessionId);
357
+ }
358
+ }
359
+
360
+ async #archive(sessionId: string): Promise<void> {
361
+ const record = await this.#bySession(sessionId);
362
+ if (!record?.threadId || record.state !== "active") return;
363
+ await this.#requireLiveBinding(record.sessionId!, record.endpointGeneration!);
364
+ const archiving = await this.#beginArchive(record);
365
+ const occurrenceId = archiving.archiveOccurrenceId;
366
+ if (!occurrenceId || !archiving.archiveEffectId) throw new Error("Discord archive occurrence is unavailable");
367
+ await this.#threadEffect(archiving.archiveEffectId, archiving, "archive", false, false, occurrenceId);
368
+ await this.#requireLiveBinding(archiving.sessionId!, archiving.endpointGeneration!);
369
+ await this.#completeArchive(archiving, occurrenceId);
370
+ }
371
+
372
+ async resume(sessionId: string, endpointGeneration: number): Promise<DiscordConversation | undefined> {
373
+ const running = this.#resumes.get(sessionId);
374
+ if (running) return await running;
375
+ const task = this.#resume(sessionId, endpointGeneration);
376
+ this.#resumes.set(sessionId, task);
377
+ try {
378
+ return await task;
379
+ } finally {
380
+ this.#resumes.delete(sessionId);
381
+ }
382
+ }
383
+
384
+ async #resume(sessionId: string, endpointGeneration: number): Promise<DiscordConversation | undefined> {
385
+ const record = await this.#bySession(sessionId);
386
+ if (!record?.threadId || record.state === "closed") return undefined;
387
+ if (closingIntent(record)) {
388
+ await this.#driveClose(record);
389
+ return undefined;
390
+ }
391
+ await this.#requireLiveBinding(sessionId, endpointGeneration);
392
+ const resumeOccurrenceId = randomUUID();
393
+ const effectIncarnationId = backfilledEffectIncarnationId(record);
394
+ const resuming = await this.#replace(record, {
395
+ ...record,
396
+ state: "resuming",
397
+ endpointGeneration,
398
+ effectIncarnationId,
399
+ resumeOccurrenceId,
400
+ resumeEffectId: `unarchive:${record.threadId}:${effectIncarnationId}:${resumeOccurrenceId}`,
401
+ pendingActionId: undefined,
402
+ pendingActionNonce: undefined,
403
+ pendingActionEffectId: undefined,
404
+ });
405
+ try {
406
+ const occurrenceId = resuming.resumeOccurrenceId;
407
+ if (!occurrenceId || !resuming.resumeEffectId) throw new Error("Discord resume occurrence is unavailable");
408
+ await this.#threadEffect(resuming.resumeEffectId, resuming, "unarchive", false, false, occurrenceId);
409
+ await this.#requireLiveBinding(sessionId, endpointGeneration);
410
+ return await this.#completeResume(resuming, occurrenceId);
411
+ } catch {
412
+ await this.#requireLiveBinding(sessionId, endpointGeneration);
413
+ const superseded = await this.#replace(resuming, {
414
+ ...resuming,
415
+ state: "archived",
416
+ resumeOccurrenceId: undefined,
417
+ resumeEffectId: undefined,
418
+ pendingActionId: undefined,
419
+ pendingActionNonce: undefined,
420
+ pendingActionEffectId: undefined,
421
+ });
422
+
423
+ const replacement = await this.#create(sessionId, endpointGeneration, `resume-${randomUUID()}`);
424
+ await this.#requireLiveBinding(sessionId, endpointGeneration);
425
+ await this.#replace(superseded, { ...superseded, supersededByThreadId: replacement.threadId });
426
+ return replacement;
427
+ }
428
+ }
429
+
430
+ async resolveAction(sessionId: string, actionId: string): Promise<void> {
431
+ const record = await this.#bySession(sessionId);
432
+ if (record?.pendingActionId === actionId && record.pendingActionNonce)
433
+ await this.#clearPending(record, actionId, record.pendingActionNonce);
434
+ }
435
+
436
+ async handleInbound(event: DiscordInboundEvent): Promise<void> {
437
+ if (event.bot || event.authorId === this.options.provider.botUserId) return;
438
+ await this.#reconcileTerminalInboundReceipts();
439
+ const record = await this.#byThread(event.guildId, event.parentId, event.threadId);
440
+ if (!record?.sessionId) {
441
+ await this.#fail(event.threadId);
442
+ return;
443
+ }
444
+ const claim = await this.#claimInbound(record, event);
445
+ if (claim === "invalid") {
446
+ await this.#fail(event.threadId);
447
+ return;
448
+ }
449
+ if (!claim || this.#inflightInbound.has(claim.receipt.effectId)) return;
450
+ this.#inflightInbound.add(claim.receipt.effectId);
451
+ try {
452
+ // Interaction callbacks have a short provider deadline. The mapping and
453
+ // journal claim are sufficient to acknowledge; endpoint discovery waits.
454
+ const endpoint = event.interaction
455
+ ? null
456
+ : await this.#resolveEndpoint(record.sessionId, record.endpointGeneration);
457
+ if (!event.interaction && !this.#matches(record, endpoint)) {
458
+ await this.#fail(event.threadId);
459
+ return;
460
+ }
461
+ await this.#dispatchInbound(record, endpoint, claim.receipt, event.interaction, claim.liveCallbackEffect);
462
+ } finally {
463
+ this.#inflightInbound.delete(claim.receipt.effectId);
464
+ await this.#scheduleLeaseRecovery();
465
+ }
466
+ }
467
+
468
+ #track<T>(work: Promise<T>): Promise<T> {
469
+ this.#activeWork.add(work);
470
+ return work.finally(() => this.#activeWork.delete(work));
471
+ }
472
+
473
+ async #claimInbound(
474
+ record: DiscordConversation,
475
+ event: DiscordInboundEvent,
476
+ ): Promise<DiscordInboundClaim | "invalid" | undefined> {
477
+ if (record.state !== "active" || closingIntent(record)) return "invalid";
478
+ const route = event.interaction ? decodeCustomId(event.interaction.customId) : undefined;
479
+ const command = !event.interaction && event.content?.startsWith("/sdk ");
480
+ if (!command && (!route || !event.interaction || route.generation !== record.endpointGeneration))
481
+ return "invalid";
482
+ const key = discordConversationKey({
483
+ appId: record.appId,
484
+ guildId: record.guildId,
485
+ parentChannelId: record.parentChannelId,
486
+ threadId: record.threadId!,
487
+ });
488
+ let receipt: DiscordInboundDispatchReceipt | undefined;
489
+ let valid = false;
490
+ const effectId = `discord:${record.appId}:${record.guildId}:${record.parentChannelId}:${record.threadId}:${event.id}`;
491
+ const idempotencyKey = effectId;
492
+ const routing: DiscordInboundRouting = {
493
+ guildId: event.guildId,
494
+ parentId: event.parentId,
495
+ threadId: event.threadId,
496
+ eventId: event.id,
497
+ ...(event.interaction ? { interactionId: event.interaction.id } : {}),
498
+ kind: command ? "command" : "action",
499
+ ...(!command ? { actionId: route!.actionId, actionNonce: route!.actionNonce } : {}),
500
+ };
501
+ const payload: DiscordInboundEffectPayload = command
502
+ ? { type: "command", content: event.content!, idempotencyKey, routing }
503
+ : {
504
+ type: "reply",
505
+ id: route!.actionId,
506
+ answer: componentAnswer(event.interaction!.value ?? ""),
507
+ idempotencyKey,
508
+ routing,
509
+ };
510
+ let liveCallbackEffect: ChatEffect<DiscordInboundEffectPayload> | undefined;
511
+ if (event.interaction) {
512
+ liveCallbackEffect = await this.#rescheduleAfterEffectTransition(
513
+ this.#effects.enqueueAndClaim(
514
+ {
515
+ id: effectId,
516
+ kind: "discord.inbound.action",
517
+ transport: "discord",
518
+ sessionId: record.sessionId,
519
+ endpointGeneration: record.endpointGeneration!,
520
+ payload,
521
+ },
522
+ this.#dispatchOwner,
523
+ this.#dispatchLeaseMs,
524
+ ),
525
+ );
526
+ } else {
527
+ await this.#rescheduleAfterEffectTransition(
528
+ this.#effects.enqueue({
529
+ id: effectId,
530
+ kind: "discord.inbound.command",
531
+ transport: "discord",
532
+ sessionId: record.sessionId,
533
+ endpointGeneration: record.endpointGeneration!,
534
+ payload,
535
+ }),
536
+ );
537
+ }
538
+
539
+ await this.#store.transact(key, current => {
540
+ if (current?.state !== "active" || current.endpointGeneration !== record.endpointGeneration) return current;
541
+ const interactionId = event.interaction?.id;
542
+ const existing = (current.inboundDispatches ?? []).find(
543
+ item => item.eventId === event.id || (interactionId !== undefined && item.interactionId === interactionId),
544
+ );
545
+ if (existing) {
546
+ valid = true;
547
+ receipt = existing;
548
+ return current;
549
+ }
550
+ if (
551
+ !command &&
552
+ (current.pendingActionId !== route!.actionId || current.pendingActionNonce !== route!.actionNonce)
553
+ )
554
+ return current;
555
+ if (
556
+ !command &&
557
+ (current.inboundDispatches ?? []).some(
558
+ item =>
559
+ item.kind === "action" &&
560
+ item.actionId === route!.actionId &&
561
+ item.actionNonce === route!.actionNonce,
562
+ )
563
+ )
564
+ return current;
565
+ valid = true;
566
+ receipt = command
567
+ ? {
568
+ key: event.id,
569
+ eventId: event.id,
570
+ kind: "command",
571
+ endpointGeneration: record.endpointGeneration!,
572
+ effectId,
573
+ idempotencyKey,
574
+ }
575
+ : {
576
+ key: event.id,
577
+ eventId: event.id,
578
+ interactionId: interactionId!,
579
+ kind: "action",
580
+ actionId: route!.actionId,
581
+ actionNonce: route!.actionNonce,
582
+ endpointGeneration: record.endpointGeneration!,
583
+ effectId,
584
+ idempotencyKey,
585
+ };
586
+ return normalizeDiscordConversation({
587
+ ...current,
588
+ generation: current.generation + 1,
589
+ updatedAt: this.#now(),
590
+ inboundDispatches: [...(current.inboundDispatches ?? []), receipt!],
591
+ });
592
+ });
593
+ if (!valid || !receipt) {
594
+ await this.#terminalizeRejectedInbound(
595
+ effectId,
596
+ liveCallbackEffect ? { owner: this.#dispatchOwner, epoch: liveCallbackEffect.epoch } : undefined,
597
+ );
598
+ return "invalid";
599
+ }
600
+ if (liveCallbackEffect && liveCallbackEffect.id !== receipt.effectId) {
601
+ await this.#terminalizeRejectedInbound(liveCallbackEffect.id, {
602
+ owner: this.#dispatchOwner,
603
+ epoch: liveCallbackEffect.epoch,
604
+ });
605
+ liveCallbackEffect = undefined;
606
+ }
607
+ return { receipt, liveCallbackEffect };
608
+ }
609
+ async #dispatchInbound(
610
+ record: DiscordConversation,
611
+ initialEndpoint: DiscordEndpointBinding | null,
612
+ receipt: DiscordInboundDispatchReceipt,
613
+ interaction?: DiscordInboundEvent["interaction"],
614
+ liveCallbackEffect?: ChatEffect<DiscordInboundEffectPayload>,
615
+ ): Promise<void> {
616
+ const claimedEffect =
617
+ liveCallbackEffect ??
618
+ (await this.#rescheduleAfterEffectTransition(
619
+ this.#effects.claim<DiscordInboundEffectPayload>(
620
+ receipt.effectId,
621
+ this.#dispatchOwner,
622
+ this.#dispatchLeaseMs,
623
+ ),
624
+ ));
625
+ if (!claimedEffect) return;
626
+ let effect: ChatEffect<DiscordInboundEffectPayload> = claimedEffect;
627
+ let lease = { owner: this.#dispatchOwner, epoch: effect.epoch };
628
+
629
+ const current = await this.#currentInboundRecord(record, receipt);
630
+ if (!current) {
631
+ await this.#terminalizeInbound(record, receipt, "rejected", lease);
632
+
633
+ return;
634
+ }
635
+ record = current;
636
+ if (receipt.kind === "action" && !this.#hasDurableDeferIntent(effect)) {
637
+ // Callback tokens cannot survive a restart. Persist the intent before remote
638
+ // I/O so recovery can safely resume SDK delivery whether defer reached Discord
639
+ // or the process stopped first.
640
+ if (!interaction) {
641
+ await this.#terminalizeInbound(record, receipt, "callback_token_unavailable", lease);
642
+
643
+ return;
644
+ }
645
+
646
+ let callbackLeaseLost = false;
647
+ let callbackRenewal: Promise<boolean> | undefined;
648
+ const renewCallbackLease = async (): Promise<boolean> => {
649
+ if (callbackLeaseLost) return false;
650
+ if (callbackRenewal) return await callbackRenewal;
651
+ const renewal = (async (): Promise<boolean> => {
652
+ if (
653
+ !(await this.#rescheduleAfterEffectTransition(
654
+ this.#effects.renew(effect.id, lease, this.#dispatchLeaseMs),
655
+ ))
656
+ )
657
+ callbackLeaseLost = true;
658
+ return !callbackLeaseLost;
659
+ })();
660
+ callbackRenewal = renewal;
661
+ try {
662
+ return await renewal;
663
+ } finally {
664
+ if (callbackRenewal === renewal) callbackRenewal = undefined;
665
+ }
666
+ };
667
+ const timer = setInterval(
668
+ () => {
669
+ void renewCallbackLease().catch(() => {});
670
+ },
671
+ Math.max(1, Math.floor(this.#dispatchLeaseMs / 3)),
672
+ );
673
+ try {
674
+ if (!(await renewCallbackLease())) return;
675
+ const prepared = await this.#rescheduleAfterEffectTransition(
676
+ this.#effects.recordReceipt<DiscordInboundEffectPayload>(effect.id, lease, { status: "defer_intent" }),
677
+ );
678
+ if (!prepared) return;
679
+ effect = prepared;
680
+ if (!(await renewCallbackLease())) return;
681
+ await this.options.provider.deferInteraction({ id: interaction.id, token: interaction.token });
682
+ if (!(await renewCallbackLease())) return;
683
+ const deferred = await this.#rescheduleAfterEffectTransition(
684
+ this.#effects.record(effect.id, lease, "accepted", { status: "deferred" }),
685
+ );
686
+ if (!deferred) return;
687
+ const reclaimed = await this.#rescheduleAfterEffectTransition(
688
+ this.#effects.claim<DiscordInboundEffectPayload>(effect.id, this.#dispatchOwner, this.#dispatchLeaseMs),
689
+ );
690
+ if (!reclaimed) return;
691
+ effect = reclaimed;
692
+ lease = { owner: this.#dispatchOwner, epoch: effect.epoch };
693
+ } catch (error) {
694
+ const definitelyUnsent = this.#isDefiniteCallbackPreSendFailure(error);
695
+ await this.#rescheduleAfterEffectTransition(
696
+ this.#effects.record(effect.id, lease, "accepted", {
697
+ status: definitelyUnsent ? "callback_pre_send_failure" : "defer_intent",
698
+ }),
699
+ );
700
+ throw new Error("Discord interaction callback failed");
701
+ } finally {
702
+ clearInterval(timer);
703
+ }
704
+ }
705
+
706
+ const dispatchable = await this.#currentInboundRecord(record, receipt);
707
+ if (!dispatchable) {
708
+ await this.#terminalizeInbound(record, receipt, "stale_binding", lease);
709
+
710
+ return;
711
+ }
712
+ record = dispatchable;
713
+ const endpoint = initialEndpoint ?? (await this.#resolveEndpoint(record.sessionId!, receipt.endpointGeneration));
714
+ if (!this.#matches(record, endpoint) || receipt.endpointGeneration !== endpoint.generation) {
715
+ await this.#rescheduleAfterEffectTransition(
716
+ this.#effects.record(effect.id, lease, "accepted", {
717
+ status: this.#inboundAcceptedStatus(effect, "pre_send_binding_changed"),
718
+ }),
719
+ );
720
+ return;
721
+ }
722
+
723
+ let leaseLost = false;
724
+ let renewal: Promise<boolean> | undefined;
725
+ const renew = async (): Promise<boolean> => {
726
+ if (leaseLost) return false;
727
+ if (renewal) return await renewal;
728
+ const currentRenewal = (async (): Promise<boolean> => {
729
+ if (
730
+ !(await this.#rescheduleAfterEffectTransition(
731
+ this.#effects.renew(effect.id, lease, this.#dispatchLeaseMs),
732
+ ))
733
+ )
734
+ leaseLost = true;
735
+ return !leaseLost;
736
+ })();
737
+ renewal = currentRenewal;
738
+ try {
739
+ return await currentRenewal;
740
+ } finally {
741
+ if (renewal === currentRenewal) renewal = undefined;
742
+ }
743
+ };
744
+ const timer = setInterval(
745
+ () => {
746
+ void renew().catch(() => {});
747
+ },
748
+ Math.max(1, Math.floor(this.#dispatchLeaseMs / 3)),
749
+ );
750
+ try {
751
+ if (!(await renew())) return;
752
+ if (!this.#matches(record, endpoint) || receipt.endpointGeneration !== endpoint.generation) {
753
+ await this.#rescheduleAfterEffectTransition(
754
+ this.#effects.record(effect.id, lease, "accepted", {
755
+ status: this.#inboundAcceptedStatus(effect, "pre_send_binding_changed"),
756
+ }),
757
+ );
758
+ return;
759
+ }
760
+
761
+ if (!(await renew())) return;
762
+ const beforeSend = await this.#currentInboundRecord(record, receipt);
763
+ if (!beforeSend) {
764
+ await this.#terminalizeInbound(record, receipt, "stale_binding", lease);
765
+
766
+ return;
767
+ }
768
+ record = beforeSend;
769
+ if (!this.#matches(record, endpoint) || receipt.endpointGeneration !== endpoint.generation) {
770
+ await this.#rescheduleAfterEffectTransition(
771
+ this.#effects.record(effect.id, lease, "accepted", {
772
+ status: this.#inboundAcceptedStatus(effect, "pre_send_binding_changed"),
773
+ }),
774
+ );
775
+ return;
776
+ }
777
+
778
+ if (effect.payload.type === "command")
779
+ await this.options.onCommand?.(
780
+ record.sessionId!,
781
+ effect.payload.content,
782
+ endpoint,
783
+ effect.payload.idempotencyKey,
784
+ );
785
+ else await endpoint.send(effect.payload);
786
+ if (!leaseLost && (await renew()) && (await this.#effects.record(effect.id, lease, "terminal")))
787
+ await this.#finishInbound(record, receipt);
788
+ } catch (error) {
789
+ if (!leaseLost) {
790
+ const state = this.#isDefiniteSdkPreSendFailure(error) ? "accepted" : "uncertain";
791
+ await this.#rescheduleAfterEffectTransition(
792
+ this.#effects.record(effect.id, lease, state, {
793
+ status: state === "accepted" ? this.#inboundAcceptedStatus(effect, "pre_send_failure") : "uncertain",
794
+ }),
795
+ );
796
+ }
797
+ } finally {
798
+ clearInterval(timer);
799
+ }
800
+ }
801
+ #hasDurableDeferIntent(effect: ChatEffect<DiscordInboundEffectPayload>): boolean {
802
+ return (
803
+ effect.kind === "discord.inbound.action" &&
804
+ (effect.receipt?.status === "defer_intent" || effect.receipt?.status === "deferred")
805
+ );
806
+ }
807
+ #inboundAcceptedStatus(effect: ChatEffect<DiscordInboundEffectPayload>, fallback: string): string {
808
+ return this.#hasDurableDeferIntent(effect) ? (effect.receipt?.status ?? fallback) : fallback;
809
+ }
810
+
811
+ #hasLiveCallbackLease(effect: ChatEffect | undefined): boolean {
812
+ return (
813
+ effect?.kind === "discord.inbound.action" &&
814
+ effect.state === "leased" &&
815
+ typeof effect.owner === "string" &&
816
+ (effect.leaseExpiresAt ?? 0) > this.#now()
817
+ );
818
+ }
819
+
820
+ async #drainPendingDispatches(): Promise<boolean> {
821
+ await this.#reconcileTerminalInboundReceipts();
822
+ let recoveryFailed = false;
823
+ const dispatched = new Set<string>();
824
+ for (const record of Object.values((await this.#store.load()).conversations)) {
825
+ if (!record.threadId || !record.sessionId || record.state !== "active") continue;
826
+ let endpoint: DiscordEndpointBinding | null = null;
827
+ let endpointResolved = false;
828
+ for (const [index, receipt] of (record.inboundDispatches ?? []).entries()) {
829
+ // Never race a foreground handleInbound() dispatch: while an effect is
830
+ // in-flight it briefly unowns itself (record "accepted"/"deferred" before
831
+ // reclaiming), a window in which recovery could otherwise claim it and
832
+ // deliver its reply on the recovery timer instead of the awaited path.
833
+ if (this.#inflightInbound.has(receipt.effectId)) {
834
+ dispatched.add(receipt.effectId);
835
+ continue;
836
+ }
837
+ if (
838
+ receipt.kind === "action" &&
839
+ (record.inboundDispatches ?? [])
840
+ .slice(0, index)
841
+ .some(
842
+ candidate =>
843
+ candidate.kind === "action" &&
844
+ candidate.actionId === receipt.actionId &&
845
+ candidate.actionNonce === receipt.actionNonce,
846
+ )
847
+ ) {
848
+ await this.#terminalizeInbound(record, receipt, "duplicate_action");
849
+ continue;
850
+ }
851
+ const effect = await this.#effects.read(receipt.effectId);
852
+ if (!effect) {
853
+ // Referenced effects are retained by terminal pruning. Preserve the
854
+ // receipt and retry recovery rather than acknowledging lost authority.
855
+ recoveryFailed = true;
856
+ continue;
857
+ }
858
+ if (effect?.state === "terminal") {
859
+ await this.#finishInbound(record, receipt);
860
+ continue;
861
+ }
862
+ if (this.#hasLiveCallbackLease(effect)) {
863
+ dispatched.add(receipt.effectId);
864
+ continue;
865
+ }
866
+ if (!endpointResolved) {
867
+ endpoint = await this.#resolveEndpoint(record.sessionId, receipt.endpointGeneration);
868
+ endpointResolved = true;
869
+ }
870
+ if (!this.#matches(record, endpoint) || endpoint.generation !== receipt.endpointGeneration) {
871
+ await this.#terminalizeInbound(record, receipt, "stale_binding");
872
+ continue;
873
+ }
874
+ dispatched.add(receipt.effectId);
875
+ await this.#dispatchInbound(record, endpoint, receipt);
876
+ }
877
+ }
878
+
879
+ // Effects are the authority: a crash between enqueue and mapping receipt
880
+ // publication must not strand a command at restart. Adoption revalidates
881
+ // the current mapping before an orphan can reach the SDK.
882
+ for (const effect of await this.#effects.list()) {
883
+ if (
884
+ effect.transport !== "discord" ||
885
+ effect.state === "terminal" ||
886
+ !effect.kind.startsWith("discord.inbound.") ||
887
+ dispatched.has(effect.id) ||
888
+ this.#inflightInbound.has(effect.id) ||
889
+ this.#hasLiveCallbackLease(effect)
890
+ )
891
+ continue;
892
+ const payload = effect.payload as DiscordInboundEffectPayload;
893
+ const routing = payload.routing;
894
+ if (!routing) continue;
895
+ const adopted = await this.#adoptOrphanInbound(
896
+ effect.id,
897
+ effect.sessionId,
898
+ effect.endpointGeneration,
899
+ payload,
900
+ routing,
901
+ );
902
+ if (!adopted) continue;
903
+ const endpoint = await this.#resolveEndpoint(adopted.record.sessionId!, effect.endpointGeneration);
904
+ if (!this.#matches(adopted.record, endpoint) || endpoint.generation !== effect.endpointGeneration) {
905
+ await this.#terminalizeInbound(adopted.record, adopted.receipt, "stale_binding");
906
+ continue;
907
+ }
908
+ await this.#dispatchInbound(adopted.record, endpoint, adopted.receipt);
909
+ }
910
+ return recoveryFailed;
911
+ }
912
+ async #adoptOrphanInbound(
913
+ effectId: string,
914
+ sessionId: string | undefined,
915
+ endpointGeneration: number,
916
+ payload: DiscordInboundEffectPayload,
917
+ routing: DiscordInboundRouting,
918
+ ): Promise<{ record: DiscordConversation; receipt: DiscordInboundDispatchReceipt } | undefined> {
919
+ const key = discordConversationKey({
920
+ appId: this.options.provider.applicationId,
921
+ guildId: routing.guildId,
922
+ parentChannelId: routing.parentId,
923
+ threadId: routing.threadId,
924
+ });
925
+ const expectedId = `discord:${this.options.provider.applicationId}:${routing.guildId}:${routing.parentId}:${routing.threadId}:${routing.eventId}`;
926
+ let record: DiscordConversation | undefined;
927
+ let receipt: DiscordInboundDispatchReceipt | undefined;
928
+ const structurallyValid =
929
+ effectId === expectedId &&
930
+ payload.idempotencyKey === effectId &&
931
+ (payload.type === "command"
932
+ ? routing.kind === "command"
933
+ : routing.kind === "action" && payload.id === routing.actionId && typeof routing.actionNonce === "string");
934
+ await this.#store.transact(key, current => {
935
+ if (
936
+ !structurallyValid ||
937
+ !current ||
938
+ current.state !== "active" ||
939
+ current.sessionId !== sessionId ||
940
+ current.endpointGeneration !== endpointGeneration
941
+ )
942
+ return current;
943
+ const existing = (current.inboundDispatches ?? []).find(
944
+ candidate =>
945
+ candidate.eventId === routing.eventId ||
946
+ (routing.interactionId !== undefined && candidate.interactionId === routing.interactionId),
947
+ );
948
+ if (existing) {
949
+ if (
950
+ existing.effectId !== effectId ||
951
+ existing.idempotencyKey !== payload.idempotencyKey ||
952
+ existing.endpointGeneration !== endpointGeneration ||
953
+ existing.kind !== routing.kind ||
954
+ (existing.kind === "action" &&
955
+ (existing.actionId !== routing.actionId ||
956
+ existing.actionNonce !== routing.actionNonce ||
957
+ existing.interactionId !== routing.interactionId))
958
+ )
959
+ return current;
960
+
961
+ record = current;
962
+ receipt = existing;
963
+ return current;
964
+ }
965
+ if (
966
+ current.seenEventIds.includes(routing.eventId) ||
967
+ (routing.interactionId !== undefined && current.seenInteractionIds.includes(routing.interactionId)) ||
968
+ (routing.kind === "action" &&
969
+ (current.pendingActionId !== routing.actionId || current.pendingActionNonce !== routing.actionNonce))
970
+ )
971
+ return current;
972
+ if (
973
+ routing.kind === "action" &&
974
+ (current.inboundDispatches ?? []).some(
975
+ candidate =>
976
+ candidate.kind === "action" &&
977
+ candidate.actionId === routing.actionId &&
978
+ candidate.actionNonce === routing.actionNonce,
979
+ )
980
+ )
981
+ return current;
982
+
983
+ receipt = this.#receiptFromRouting(effectId, endpointGeneration, payload.idempotencyKey, routing);
984
+ record = normalizeDiscordConversation({
985
+ ...current,
986
+ generation: current.generation + 1,
987
+ updatedAt: this.#now(),
988
+ inboundDispatches: [...(current.inboundDispatches ?? []), receipt],
989
+ });
990
+ return record;
991
+ });
992
+ if (record && receipt) return { record, receipt };
993
+ await this.#terminalizeRejectedInbound(effectId);
994
+ return undefined;
995
+ }
996
+ #sameInboundReceipt(left: DiscordInboundDispatchReceipt, right: DiscordInboundDispatchReceipt): boolean {
997
+ return (
998
+ left.key === right.key &&
999
+ left.eventId === right.eventId &&
1000
+ left.interactionId === right.interactionId &&
1001
+ left.kind === right.kind &&
1002
+ left.actionId === right.actionId &&
1003
+ left.actionNonce === right.actionNonce &&
1004
+ left.endpointGeneration === right.endpointGeneration &&
1005
+ left.effectId === right.effectId &&
1006
+ left.idempotencyKey === right.idempotencyKey
1007
+ );
1008
+ }
1009
+ #completeInbound(record: DiscordConversation, receipt: DiscordInboundDispatchReceipt): DiscordConversation {
1010
+ const clearsAction =
1011
+ receipt.kind === "action" &&
1012
+ record.pendingActionId === receipt.actionId &&
1013
+ record.pendingActionNonce === receipt.actionNonce;
1014
+ return normalizeDiscordConversation({
1015
+ ...record,
1016
+ generation: record.generation + 1,
1017
+ updatedAt: this.#now(),
1018
+ pendingActionId: clearsAction ? undefined : record.pendingActionId,
1019
+ pendingActionNonce: clearsAction ? undefined : record.pendingActionNonce,
1020
+ pendingActionEffectId: clearsAction ? undefined : record.pendingActionEffectId,
1021
+ seenEventIds: [...record.seenEventIds, receipt.eventId],
1022
+ seenInteractionIds:
1023
+ receipt.interactionId === undefined
1024
+ ? record.seenInteractionIds
1025
+ : [...record.seenInteractionIds, receipt.interactionId],
1026
+ inboundDispatches: (record.inboundDispatches ?? []).filter(
1027
+ candidate => !this.#sameInboundReceipt(candidate, receipt),
1028
+ ),
1029
+ });
1030
+ }
1031
+ #hasLiveEffectLease(effect: ChatEffect | undefined): boolean {
1032
+ return (
1033
+ effect?.state === "leased" && typeof effect.owner === "string" && (effect.leaseExpiresAt ?? 0) > this.#now()
1034
+ );
1035
+ }
1036
+ async #terminalizeEffect(id: string, status: string, lease?: ChatEffectLease): Promise<boolean> {
1037
+ if (lease) {
1038
+ const terminalized = await this.#effects.record(id, lease, "terminal", { status });
1039
+ return terminalized?.state === "terminal";
1040
+ }
1041
+ const effect = await this.#effects.read(id);
1042
+ if (!effect || effect.state === "terminal") return true;
1043
+ if (effect.state === "uncertain" || this.#hasLiveEffectLease(effect)) return false;
1044
+ if (effect.state === "leased") {
1045
+ const claimed = await this.#effects.claim(id, this.#providerOwner, this.#providerLeaseMs);
1046
+ if (!claimed) return false;
1047
+ await this.#effects.record(id, { owner: this.#providerOwner, epoch: claimed.epoch }, "uncertain", {
1048
+ status: "stale_lease_expired",
1049
+ });
1050
+ return false;
1051
+ }
1052
+ return (await this.#effects.terminalize(id, { status }))?.state === "terminal";
1053
+ }
1054
+ async #terminalizeRejectedInbound(effectId: string, lease?: ChatEffectLease): Promise<void> {
1055
+ await this.#terminalizeEffect(effectId, "rejected", lease);
1056
+ }
1057
+ async #terminalizeInbound(
1058
+ record: DiscordConversation,
1059
+ receipt: DiscordInboundDispatchReceipt,
1060
+ status: string,
1061
+ lease?: ChatEffectLease,
1062
+ ): Promise<void> {
1063
+ if (!(await this.#terminalizeEffect(receipt.effectId, status, lease))) return;
1064
+ const key = discordConversationKey({
1065
+ appId: record.appId,
1066
+ guildId: record.guildId,
1067
+ parentChannelId: record.parentChannelId,
1068
+ threadId: record.threadId!,
1069
+ });
1070
+ await this.#store.transact(key, current => {
1071
+ const matching = current?.inboundDispatches?.find(candidate => this.#sameInboundReceipt(candidate, receipt));
1072
+ return !current || !matching ? current : this.#completeInbound(current, matching);
1073
+ });
1074
+ }
1075
+ async #currentInboundRecord(
1076
+ record: DiscordConversation,
1077
+ receipt: DiscordInboundDispatchReceipt,
1078
+ ): Promise<DiscordConversation | undefined> {
1079
+ const current = await this.#byThread(record.guildId, record.parentChannelId, record.threadId!);
1080
+ const claimed = current?.inboundDispatches?.find(candidate => this.#sameInboundReceipt(candidate, receipt));
1081
+ if (
1082
+ current?.state !== "active" ||
1083
+ current.sessionId !== record.sessionId ||
1084
+ current.endpointGeneration !== receipt.endpointGeneration ||
1085
+ !claimed
1086
+ )
1087
+ return undefined;
1088
+ if (
1089
+ receipt.kind === "action" &&
1090
+ (current.pendingActionId !== receipt.actionId || current.pendingActionNonce !== receipt.actionNonce)
1091
+ )
1092
+ return undefined;
1093
+
1094
+ return current;
1095
+ }
1096
+ #receiptFromRouting(
1097
+ effectId: string,
1098
+ endpointGeneration: number,
1099
+ idempotencyKey: string,
1100
+ routing: DiscordInboundRouting,
1101
+ ): DiscordInboundDispatchReceipt {
1102
+ return routing.kind === "command"
1103
+ ? {
1104
+ key: routing.eventId,
1105
+ eventId: routing.eventId,
1106
+ kind: "command",
1107
+ endpointGeneration,
1108
+ effectId,
1109
+ idempotencyKey,
1110
+ }
1111
+ : {
1112
+ key: routing.eventId,
1113
+ eventId: routing.eventId,
1114
+ interactionId: routing.interactionId!,
1115
+ kind: "action",
1116
+ actionId: routing.actionId!,
1117
+ actionNonce: routing.actionNonce!,
1118
+ endpointGeneration,
1119
+ effectId,
1120
+ idempotencyKey,
1121
+ };
1122
+ }
1123
+
1124
+ async #finishInbound(record: DiscordConversation, receipt: DiscordInboundDispatchReceipt): Promise<void> {
1125
+ const key = discordConversationKey({
1126
+ appId: record.appId,
1127
+ guildId: record.guildId,
1128
+ parentChannelId: record.parentChannelId,
1129
+ threadId: record.threadId!,
1130
+ });
1131
+ await this.#store.transact(key, current => {
1132
+ const matching = current?.inboundDispatches?.find(candidate => this.#sameInboundReceipt(candidate, receipt));
1133
+ return !current || !matching ? current : this.#completeInbound(current, matching);
1134
+ });
1135
+ }
1136
+ async #reconcileTerminalInboundReceipts(): Promise<void> {
1137
+ for (const effect of await this.#effects.list()) {
1138
+ if (
1139
+ effect.transport !== "discord" ||
1140
+ effect.state !== "terminal" ||
1141
+ (effect.kind !== "discord.inbound.command" && effect.kind !== "discord.inbound.action")
1142
+ )
1143
+ continue;
1144
+ const payload = effect.payload as DiscordInboundEffectPayload;
1145
+ const routing = payload?.routing;
1146
+ if (
1147
+ !routing ||
1148
+ !effect.sessionId ||
1149
+ !routing.guildId ||
1150
+ !routing.parentId ||
1151
+ !routing.threadId ||
1152
+ !routing.eventId ||
1153
+ payload.idempotencyKey !== effect.id ||
1154
+ effect.id !==
1155
+ `discord:${this.options.provider.applicationId}:${routing.guildId}:${routing.parentId}:${routing.threadId}:${routing.eventId}` ||
1156
+ (payload.type === "command"
1157
+ ? routing.kind !== "command" || effect.kind !== "discord.inbound.command"
1158
+ : routing.kind !== "action" ||
1159
+ effect.kind !== "discord.inbound.action" ||
1160
+ !routing.interactionId ||
1161
+ !routing.actionId ||
1162
+ !routing.actionNonce ||
1163
+ payload.id !== routing.actionId)
1164
+ )
1165
+ continue;
1166
+ const receipt = this.#receiptFromRouting(
1167
+ effect.id,
1168
+ effect.endpointGeneration,
1169
+ payload.idempotencyKey,
1170
+ routing,
1171
+ );
1172
+ const record = await this.#byThread(routing.guildId, routing.parentId, routing.threadId);
1173
+ if (record?.inboundDispatches?.some(candidate => this.#sameInboundReceipt(candidate, receipt)))
1174
+ await this.#finishInbound(record, receipt);
1175
+ }
1176
+ }
1177
+
1178
+ #matches(record: DiscordConversation, endpoint: DiscordEndpointBinding | null): endpoint is DiscordEndpointBinding {
1179
+ if (!endpoint?.isCurrent()) return false;
1180
+ return record.state === "active" && record.endpointGeneration === endpoint.generation;
1181
+ }
1182
+ async #bindingCurrent(sessionId: string, endpointGeneration: number): Promise<boolean> {
1183
+ try {
1184
+ const endpoint = await this.#resolveEndpoint(sessionId, endpointGeneration);
1185
+ return !!endpoint && endpoint.isCurrent() && endpoint.generation === endpointGeneration;
1186
+ } catch {
1187
+ return false;
1188
+ }
1189
+ }
1190
+ async #requireLiveBinding(sessionId: string, endpointGeneration: number): Promise<void> {
1191
+ if (!(await this.#bindingCurrent(sessionId, endpointGeneration))) throw new DiscordEndpointBindingError();
1192
+ }
1193
+ #isDefiniteSdkPreSendFailure(error: unknown): boolean {
1194
+ if (error instanceof DiscordEndpointBindingError) return true;
1195
+ if (error instanceof SdkClientError) return error.code === "connection_closed";
1196
+ return (
1197
+ error instanceof Error &&
1198
+ error.name === "ChatDeliveryError" &&
1199
+ (error as ChatDeliveryError).phase === "pre_send"
1200
+ );
1201
+ }
1202
+ #isDefiniteCallbackPreSendFailure(error: unknown): boolean {
1203
+ // This is intentionally limited to Discord's received-and-rejected callback
1204
+ // response. Transport errors, connection loss, and arbitrary provider errors
1205
+ // leave the persisted defer intent authoritative because the callback may have
1206
+ // been accepted before its HTTP response was lost.
1207
+ return error instanceof Error && /^Discord API request failed \(4\d\d\)$/.test(error.message);
1208
+ }
1209
+
1210
+ async #ensureConversation(input: DiscordNotificationInput): Promise<DiscordConversation> {
1211
+ const existing = await this.#bySession(input.sessionId);
1212
+ if (existing && closingIntent(existing)) {
1213
+ await this.#driveClose(existing);
1214
+ throw new Error("Discord thread is closing");
1215
+ }
1216
+ if (existing?.state === "active" && existing.threadId) {
1217
+ if (existing.endpointGeneration === input.endpointGeneration) return existing;
1218
+ await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
1219
+ return await this.#replace(existing, { ...existing, endpointGeneration: input.endpointGeneration });
1220
+ }
1221
+ const inFlight = this.#creates.get(input.sessionId);
1222
+ if (inFlight) {
1223
+ let created: DiscordConversation;
1224
+ try {
1225
+ created = await inFlight;
1226
+ } catch {
1227
+ await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
1228
+ return await this.#ensureConversation(input);
1229
+ }
1230
+ if (created.endpointGeneration === input.endpointGeneration) return created;
1231
+ await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
1232
+ return await this.#replace(created, { ...created, endpointGeneration: input.endpointGeneration });
1233
+ }
1234
+ const pending = this.#create(input.sessionId, input.endpointGeneration, randomUUID(), input.threadName);
1235
+ this.#creates.set(input.sessionId, pending);
1236
+ try {
1237
+ return await pending;
1238
+ } finally {
1239
+ this.#creates.delete(input.sessionId);
1240
+ }
1241
+ }
1242
+
1243
+ async #create(
1244
+ sessionId: string,
1245
+ endpointGeneration: number,
1246
+ nonce: string,
1247
+ name = "GJC session",
1248
+ ): Promise<DiscordConversation> {
1249
+ const intentKey = this.#intentKey(sessionId);
1250
+ const owner = randomUUID();
1251
+ let intent: DiscordConversation | undefined;
1252
+ for (;;) {
1253
+ const active = await this.#bySession(sessionId);
1254
+ if (active && closingIntent(active)) {
1255
+ await this.#driveClose(active);
1256
+ throw new Error("Discord thread is closing");
1257
+ }
1258
+ if (active?.state === "active" && active.threadId) {
1259
+ if (active.endpointGeneration === endpointGeneration) return active;
1260
+ await this.#requireLiveBinding(sessionId, endpointGeneration);
1261
+ return await this.#replace(active, { ...active, endpointGeneration });
1262
+ }
1263
+ const now = this.#now();
1264
+ await this.#requireLiveBinding(sessionId, endpointGeneration);
1265
+ intent = await this.#store.transact(intentKey, old => {
1266
+ if (old?.state === "creating" && old.createOwner && (old.createLeaseExpiresAt ?? 0) > now) return old;
1267
+ return {
1268
+ generation: (old?.generation ?? 0) + 1,
1269
+ state: "creating",
1270
+ appId: this.options.provider.applicationId,
1271
+ guildId: this.options.guildId,
1272
+ parentChannelId: this.options.parentChannelId,
1273
+ sessionId,
1274
+ endpointGeneration,
1275
+ createNonce: old?.createNonce ?? nonce,
1276
+ createOwner: owner,
1277
+ createLeaseExpiresAt: now + 60_000,
1278
+ updatedAt: now,
1279
+ seenEventIds: [],
1280
+ seenInteractionIds: [],
1281
+ };
1282
+ });
1283
+ if (!intent) throw new Error("Unable to persist Discord create intent");
1284
+ if (intent.createOwner === owner) break;
1285
+ await Bun.sleep(Math.min(25, Math.max(1, (intent.createLeaseExpiresAt ?? now) - now)));
1286
+ }
1287
+ const active = await this.#bySession(sessionId);
1288
+ if (active?.state === "active" && active.threadId) {
1289
+ if (active.endpointGeneration === endpointGeneration) return active;
1290
+ await this.#requireLiveBinding(sessionId, endpointGeneration);
1291
+ return await this.#replace(active, { ...active, endpointGeneration });
1292
+ }
1293
+ let thread: DiscordThread | null;
1294
+ try {
1295
+ thread = await this.#withCreateIntentLease(intent, () => this.#createThreadEffect(intent, name));
1296
+ await this.#requireLiveBinding(sessionId, endpointGeneration);
1297
+ } catch (error) {
1298
+ await this.#abandonCreator(intentKey, intent);
1299
+ throw error;
1300
+ }
1301
+ const currentIntent = await this.#store.read(intentKey);
1302
+ if (
1303
+ currentIntent?.state !== "creating" ||
1304
+ currentIntent.createOwner !== intent.createOwner ||
1305
+ currentIntent.generation !== intent.generation ||
1306
+ (currentIntent.createLeaseExpiresAt ?? 0) <= this.#now()
1307
+ ) {
1308
+ throw new Error("Discord create intent lost its fence before mapping commit");
1309
+ }
1310
+ await this.#requireLiveBinding(sessionId, endpointGeneration);
1311
+ const key = discordConversationKey({
1312
+ appId: intent.appId,
1313
+ guildId: intent.guildId,
1314
+ parentChannelId: intent.parentChannelId,
1315
+ threadId: thread.id,
1316
+ });
1317
+ const record = await this.#store.transact(key, old =>
1318
+ normalizeDiscordConversation({
1319
+ generation: (old?.generation ?? 0) + 1,
1320
+ state: "active",
1321
+ appId: intent.appId,
1322
+ guildId: intent.guildId,
1323
+ parentChannelId: intent.parentChannelId,
1324
+ threadId: thread.id,
1325
+ sessionId,
1326
+ endpointGeneration,
1327
+ createNonce: intent.createNonce,
1328
+ effectIncarnationId: old?.effectIncarnationId ?? intent.effectIncarnationId ?? randomUUID(),
1329
+ updatedAt: this.#now(),
1330
+ seenEventIds: old?.seenEventIds ?? [],
1331
+ seenInteractionIds: old?.seenInteractionIds ?? [],
1332
+ inboundDispatches: old?.inboundDispatches,
1333
+ }),
1334
+ );
1335
+ if (!record) throw new Error("Unable to persist Discord thread mapping");
1336
+ await this.#store.delete(intentKey, intent.generation);
1337
+ return record;
1338
+ }
1339
+
1340
+ async #sessionMappings(sessionId: string): Promise<DiscordConversation[]> {
1341
+ return Object.values((await this.#store.load()).conversations).filter(
1342
+ record => record.sessionId === sessionId && record.state !== "creating",
1343
+ );
1344
+ }
1345
+ async #bySession(sessionId: string): Promise<DiscordConversation | undefined> {
1346
+ const document = await this.#store.load();
1347
+ return Object.values(document.conversations)
1348
+ .filter(record => record.sessionId === sessionId && record.state !== "creating")
1349
+ .sort(
1350
+ (left, right) =>
1351
+ stateRank(left.state) - stateRank(right.state) ||
1352
+ right.generation - left.generation ||
1353
+ right.updatedAt - left.updatedAt,
1354
+ )[0];
1355
+ }
1356
+ async #byThread(
1357
+ guildId: string,
1358
+ parentChannelId: string,
1359
+ threadId: string,
1360
+ ): Promise<DiscordConversation | undefined> {
1361
+ return await this.#store.read(
1362
+ discordConversationKey({ appId: this.options.provider.applicationId, guildId, parentChannelId, threadId }),
1363
+ );
1364
+ }
1365
+ #closeMarkerEffectId(record: DiscordConversation): string {
1366
+ const intent = closingIntent(record);
1367
+ if (!record.threadId || !intent) throw new Error("Discord close intent is unavailable.");
1368
+ return `close-marker:${record.threadId}:${intent.nonce}`;
1369
+ }
1370
+ #closeArchiveEffectId(record: DiscordConversation): string {
1371
+ const intent = closingIntent(record);
1372
+ if (!record.threadId || !intent) throw new Error("Discord close intent is unavailable.");
1373
+ return `close-archive:${record.threadId}:${intent.nonce}`;
1374
+ }
1375
+ async #markClosing(sessionId: string): Promise<DiscordConversation | undefined> {
1376
+ const record = await this.#bySession(sessionId);
1377
+ if (!record?.threadId || record.state === "closed") return undefined;
1378
+ const key = discordConversationKey({
1379
+ appId: record.appId,
1380
+ guildId: record.guildId,
1381
+ parentChannelId: record.parentChannelId,
1382
+ threadId: record.threadId,
1383
+ });
1384
+ let closing: DiscordConversation | undefined;
1385
+ await this.#store.transact(key, current => {
1386
+ if (!current || current.sessionId !== sessionId || !current.threadId || current.state === "closed")
1387
+ return current;
1388
+ if (closingIntent(current)) {
1389
+ closing = current;
1390
+ return current;
1391
+ }
1392
+ closing = normalizeDiscordConversation(withClosingIntent(current, randomUUID(), this.#now()));
1393
+ return closing;
1394
+ });
1395
+ return closing;
1396
+ }
1397
+ async #driveClose(record: DiscordConversation): Promise<void> {
1398
+ if (!record.threadId) return;
1399
+ const key = discordConversationKey({
1400
+ appId: record.appId,
1401
+ guildId: record.guildId,
1402
+ parentChannelId: record.parentChannelId,
1403
+ threadId: record.threadId,
1404
+ });
1405
+ let current = await this.#store.read(key);
1406
+ if (!current || !closingIntent(current)) return;
1407
+ for (const receipt of current.inboundDispatches ?? [])
1408
+ await this.#terminalizeInbound(current, receipt, "closing");
1409
+ current = await this.#store.read(key);
1410
+ if (!current || !closingIntent(current)) return;
1411
+ const closingRecord = current;
1412
+ const intent = closingIntent(closingRecord)!;
1413
+ await this.#postEffect(
1414
+ this.#closeMarkerEffectId(closingRecord),
1415
+ closingRecord,
1416
+ "This conversation is closed.",
1417
+ undefined,
1418
+ false,
1419
+ true,
1420
+ );
1421
+ await this.#threadEffect(this.#closeArchiveEffectId(closingRecord), closingRecord, "archive", true, true);
1422
+ await this.#store.transact(key, candidate => {
1423
+ const candidateIntent = closingIntent(candidate);
1424
+ return candidate && candidate.sessionId === closingRecord.sessionId && candidateIntent?.nonce === intent.nonce
1425
+ ? normalizeDiscordConversation(withoutClosingIntent(candidate, this.#now()))
1426
+ : candidate;
1427
+ });
1428
+ }
1429
+ async #recoverClosingConversations(): Promise<boolean> {
1430
+ let failed = false;
1431
+ for (const record of Object.values((await this.#store.load()).conversations)) {
1432
+ if (!closingIntent(record)) continue;
1433
+ try {
1434
+ await this.#driveClose(record);
1435
+ } catch {
1436
+ failed = true;
1437
+ }
1438
+ }
1439
+ return failed;
1440
+ }
1441
+ async #replace(
1442
+ current: DiscordConversation,
1443
+ next: Omit<DiscordConversation, "generation"> & { generation?: number },
1444
+ ): Promise<DiscordConversation> {
1445
+ const key = current.threadId
1446
+ ? discordConversationKey({
1447
+ appId: current.appId,
1448
+ guildId: current.guildId,
1449
+ parentChannelId: current.parentChannelId,
1450
+ threadId: current.threadId,
1451
+ })
1452
+ : this.#intentKey(current.sessionId!);
1453
+ const result = await this.#store.write(key, current.generation, {
1454
+ ...next,
1455
+ generation: current.generation + 1,
1456
+ updatedAt: this.#now(),
1457
+ });
1458
+ if (!result) {
1459
+ const stored = await this.#store.read(key);
1460
+ throw new Error(
1461
+ `Discord conversation changed concurrently (key=${key}, expected=${current.generation}, actual=${stored?.generation ?? "missing"})`,
1462
+ );
1463
+ }
1464
+ return (await this.#store.read(key))!;
1465
+ }
1466
+ async #beginArchive(record: DiscordConversation): Promise<DiscordConversation> {
1467
+ const key = discordConversationKey({
1468
+ appId: record.appId,
1469
+ guildId: record.guildId,
1470
+ parentChannelId: record.parentChannelId,
1471
+ threadId: record.threadId!,
1472
+ });
1473
+ let archiving: DiscordConversation | undefined;
1474
+ await this.#store.transact(key, current => {
1475
+ if (
1476
+ current?.state !== "active" ||
1477
+ current.sessionId !== record.sessionId ||
1478
+ current.endpointGeneration !== record.endpointGeneration
1479
+ )
1480
+ return current;
1481
+ if (current.archiveOccurrenceId) {
1482
+ archiving = current;
1483
+ return current;
1484
+ }
1485
+ const archiveOccurrenceId = randomUUID();
1486
+ const effectIncarnationId = backfilledEffectIncarnationId(current);
1487
+ archiving = normalizeDiscordConversation({
1488
+ ...current,
1489
+ generation: current.generation + 1,
1490
+ updatedAt: this.#now(),
1491
+ effectIncarnationId,
1492
+ archiveOccurrenceId,
1493
+ archiveEffectId: `archive:${current.threadId}:${effectIncarnationId}:${archiveOccurrenceId}`,
1494
+ });
1495
+
1496
+ return archiving;
1497
+ });
1498
+ if (!archiving) throw new Error("Discord archive intent lost its authority");
1499
+ return archiving;
1500
+ }
1501
+ async #completeArchive(record: DiscordConversation, occurrenceId: string): Promise<void> {
1502
+ const key = discordConversationKey({
1503
+ appId: record.appId,
1504
+ guildId: record.guildId,
1505
+ parentChannelId: record.parentChannelId,
1506
+ threadId: record.threadId!,
1507
+ });
1508
+ await this.#store.transact(key, current => {
1509
+ if (
1510
+ current?.state !== "active" ||
1511
+ current.sessionId !== record.sessionId ||
1512
+ current.archiveOccurrenceId !== occurrenceId
1513
+ )
1514
+ return current;
1515
+ return normalizeDiscordConversation({
1516
+ ...current,
1517
+ generation: current.generation + 1,
1518
+ updatedAt: this.#now(),
1519
+ state: "archived",
1520
+ archivedAt: this.#now(),
1521
+ archiveOccurrenceId: undefined,
1522
+ archiveEffectId: undefined,
1523
+ });
1524
+ });
1525
+ }
1526
+ async #completeResume(record: DiscordConversation, occurrenceId: string): Promise<DiscordConversation> {
1527
+ const key = discordConversationKey({
1528
+ appId: record.appId,
1529
+ guildId: record.guildId,
1530
+ parentChannelId: record.parentChannelId,
1531
+ threadId: record.threadId!,
1532
+ });
1533
+ const completed = await this.#store.transact(key, current => {
1534
+ if (
1535
+ !current ||
1536
+ current.sessionId !== record.sessionId ||
1537
+ current.endpointGeneration !== record.endpointGeneration
1538
+ )
1539
+ return current;
1540
+ if (current.state === "active" && current.resumeOccurrenceId === undefined) return current;
1541
+ if (current.state !== "resuming" || current.resumeOccurrenceId !== occurrenceId) return current;
1542
+ return normalizeDiscordConversation({
1543
+ ...current,
1544
+ generation: current.generation + 1,
1545
+ updatedAt: this.#now(),
1546
+ state: "active",
1547
+ archivedAt: undefined,
1548
+ resumeOccurrenceId: undefined,
1549
+ resumeEffectId: undefined,
1550
+ });
1551
+ });
1552
+ if (completed?.state !== "active" || completed.sessionId !== record.sessionId)
1553
+ throw new Error("Discord resume occurrence lost its authority");
1554
+ return completed;
1555
+ }
1556
+ async #abandonCreator(intentKey: string, intent: DiscordConversation): Promise<void> {
1557
+ await this.#store.transact(intentKey, current => {
1558
+ if (!current || current.generation !== intent.generation || current.createOwner !== intent.createOwner)
1559
+ return current;
1560
+ return normalizeDiscordConversation({
1561
+ ...current,
1562
+ generation: current.generation + 1,
1563
+ updatedAt: this.#now(),
1564
+ createOwner: undefined,
1565
+ createLeaseExpiresAt: undefined,
1566
+ });
1567
+ });
1568
+ }
1569
+ #intentKey(sessionId: string): string {
1570
+ return `${this.options.provider.applicationId}:${this.options.guildId}:${this.options.parentChannelId}:creating:${sessionId}`;
1571
+ }
1572
+ async #withCreateIntentLease<T>(intent: DiscordConversation, work: () => Promise<T>): Promise<T> {
1573
+ let lost = false;
1574
+ let renewal: Promise<boolean> | undefined;
1575
+ let expectedGeneration = intent.generation;
1576
+ const renew = async (): Promise<boolean> => {
1577
+ if (lost) return false;
1578
+ if (renewal) return await renewal;
1579
+ const currentRenewal = (async (): Promise<boolean> => {
1580
+ const now = this.#now();
1581
+ const current = await this.#store.transact(this.#intentKey(intent.sessionId!), candidate => {
1582
+ if (
1583
+ candidate?.state !== "creating" ||
1584
+ candidate.createOwner !== intent.createOwner ||
1585
+ candidate.generation !== expectedGeneration ||
1586
+ (candidate.createLeaseExpiresAt ?? 0) <= now
1587
+ )
1588
+ return candidate;
1589
+ return {
1590
+ ...candidate,
1591
+ generation: candidate.generation + 1,
1592
+ createLeaseExpiresAt: now + this.#providerLeaseMs,
1593
+ updatedAt: now,
1594
+ };
1595
+ });
1596
+ if (
1597
+ current?.state !== "creating" ||
1598
+ current.createOwner !== intent.createOwner ||
1599
+ current.generation !== expectedGeneration + 1 ||
1600
+ (current.createLeaseExpiresAt ?? 0) <= now
1601
+ ) {
1602
+ lost = true;
1603
+ } else {
1604
+ expectedGeneration = current.generation;
1605
+ intent.generation = current.generation;
1606
+ intent.createLeaseExpiresAt = current.createLeaseExpiresAt;
1607
+ }
1608
+ return !lost;
1609
+ })();
1610
+ renewal = currentRenewal;
1611
+ try {
1612
+ return await currentRenewal;
1613
+ } finally {
1614
+ if (renewal === currentRenewal) renewal = undefined;
1615
+ }
1616
+ };
1617
+ if (!(await renew())) throw new Error("Discord create intent lost its fence");
1618
+ const timer = setInterval(
1619
+ () => {
1620
+ void renew().catch(() => {});
1621
+ },
1622
+ Math.max(1, Math.floor(this.#providerLeaseMs / 3)),
1623
+ );
1624
+ try {
1625
+ const result = await work();
1626
+ if (!(await renew())) throw new Error("Discord create intent lost its fence");
1627
+ return result;
1628
+ } finally {
1629
+ clearInterval(timer);
1630
+ }
1631
+ }
1632
+ async #clearPending(record: DiscordConversation, actionId: string, actionNonce: string): Promise<void> {
1633
+ const key = record.threadId
1634
+ ? discordConversationKey({
1635
+ appId: record.appId,
1636
+ guildId: record.guildId,
1637
+ parentChannelId: record.parentChannelId,
1638
+ threadId: record.threadId,
1639
+ })
1640
+ : this.#intentKey(record.sessionId!);
1641
+ await this.#store.transact(key, current => {
1642
+ if (current?.pendingActionId !== actionId || current.pendingActionNonce !== actionNonce) return current;
1643
+ return normalizeDiscordConversation({
1644
+ ...current,
1645
+ generation: current.generation + 1,
1646
+ updatedAt: this.#now(),
1647
+ pendingActionId: undefined,
1648
+ pendingActionNonce: undefined,
1649
+ pendingActionEffectId: undefined,
1650
+ });
1651
+ });
1652
+ }
1653
+ async #ensureActionPublication(record: DiscordConversation, actionId: string): Promise<DiscordConversation> {
1654
+ const key = discordConversationKey({
1655
+ appId: record.appId,
1656
+ guildId: record.guildId,
1657
+ parentChannelId: record.parentChannelId,
1658
+ threadId: record.threadId!,
1659
+ });
1660
+ await this.#requireLiveBinding(record.sessionId!, record.endpointGeneration!);
1661
+ const result = await this.#store.transact(key, current => {
1662
+ if (
1663
+ current?.state !== "active" ||
1664
+ current.sessionId !== record.sessionId ||
1665
+ current.endpointGeneration !== record.endpointGeneration
1666
+ )
1667
+ return current;
1668
+ if (current.pendingActionId === actionId && current.pendingActionNonce && current.pendingActionEffectId)
1669
+ return current;
1670
+ const actionNonce =
1671
+ current.pendingActionId === actionId && current.pendingActionNonce
1672
+ ? current.pendingActionNonce
1673
+ : randomUUID();
1674
+ return normalizeDiscordConversation({
1675
+ ...current,
1676
+ generation: current.generation + 1,
1677
+ updatedAt: this.#now(),
1678
+ pendingActionId: actionId,
1679
+ pendingActionNonce: actionNonce,
1680
+ pendingActionEffectId: `action-publication:${current.threadId}:${actionId}:${actionNonce}`,
1681
+ });
1682
+ });
1683
+ if (
1684
+ result?.state !== "active" ||
1685
+ result.sessionId !== record.sessionId ||
1686
+ result.endpointGeneration !== record.endpointGeneration ||
1687
+ result.pendingActionId !== actionId ||
1688
+ !result.pendingActionNonce ||
1689
+ !result.pendingActionEffectId
1690
+ ) {
1691
+ throw new Error("Discord action publication intent lost its authority");
1692
+ }
1693
+ return result;
1694
+ }
1695
+
1696
+ async #runEffect<TPayload>(
1697
+ id: string,
1698
+ kind: string,
1699
+ sessionId: string | undefined,
1700
+ endpointGeneration: number,
1701
+ payload: TPayload,
1702
+ operation: (ensure: () => Promise<void>, beforeProvider: () => void) => Promise<ChatEffectReceipt>,
1703
+ revalidate: () => boolean | Promise<boolean>,
1704
+ terminalizeStaleBeforeProvider = false,
1705
+ ): Promise<ChatEffectReceipt> {
1706
+ const claimed = await this.#rescheduleAfterEffectTransition(
1707
+ this.#effects.enqueueAndClaim<TPayload>(
1708
+ { id, kind, transport: "discord", sessionId, endpointGeneration, payload },
1709
+ this.#providerOwner,
1710
+ this.#providerLeaseMs,
1711
+ ),
1712
+ );
1713
+ let effect: ChatEffect<TPayload>;
1714
+ if (claimed) {
1715
+ // Fresh effect atomically inserted into a live lease. This closes the
1716
+ // enqueue→claim window in which the same-process lease-recovery timer could
1717
+ // claim the still-"pending" effect first and make this foreground claim fail
1718
+ // with "owned by another worker" (mirrors the inbound enqueueAndClaim path).
1719
+ effect = claimed;
1720
+ } else {
1721
+ const initial = await this.#effects.read<TPayload>(id);
1722
+ if (initial?.state === "terminal") {
1723
+ if (!initial.receipt) throw new Error(`Discord effect ${id} has no receipt`);
1724
+ if (terminalizeStaleBeforeProvider && initial.receipt.status === "stale_noop")
1725
+ throw new DiscordEndpointBindingError("Discord thread effect is no longer current.");
1726
+ return initial.receipt;
1727
+ }
1728
+ const reclaimed = await this.#rescheduleAfterEffectTransition(
1729
+ this.#effects.claim<TPayload>(id, this.#providerOwner, this.#providerLeaseMs),
1730
+ );
1731
+ if (!reclaimed) throw new Error(`Discord effect ${id} is owned by another worker`);
1732
+ effect = reclaimed;
1733
+ }
1734
+ const lease: ChatEffectLease = { owner: this.#providerOwner, epoch: effect.epoch };
1735
+ let renewalLost = false;
1736
+ let revalidationFailed = false;
1737
+ let providerEffectStarted = false;
1738
+ let renewal: Promise<boolean> | undefined;
1739
+ const renew = async (): Promise<boolean> => {
1740
+ if (renewalLost) return false;
1741
+ if (renewal) return await renewal;
1742
+ const currentRenewal = (async (): Promise<boolean> => {
1743
+ const renewed = await this.#rescheduleAfterEffectTransition(
1744
+ this.#effects.renew(id, lease, this.#providerLeaseMs),
1745
+ );
1746
+ if (!renewed) renewalLost = true;
1747
+ else if (!(await revalidate())) {
1748
+ revalidationFailed = true;
1749
+ renewalLost = true;
1750
+ }
1751
+ return !renewalLost;
1752
+ })();
1753
+ renewal = currentRenewal;
1754
+ try {
1755
+ return await currentRenewal;
1756
+ } finally {
1757
+ if (renewal === currentRenewal) renewal = undefined;
1758
+ }
1759
+ };
1760
+ const timer = setInterval(
1761
+ () => {
1762
+ void renew().catch(() => {});
1763
+ },
1764
+ Math.max(1, Math.floor(this.#providerLeaseMs / 3)),
1765
+ );
1766
+ const ensure = async (): Promise<void> => {
1767
+ if (!(await renew())) throw new Error(`Discord effect ${id} lost its fence`);
1768
+ };
1769
+ try {
1770
+ await ensure();
1771
+ const receipt = await operation(ensure, () => {
1772
+ providerEffectStarted = true;
1773
+ });
1774
+ await ensure();
1775
+ const committed = await this.#effects.record(id, lease, "terminal", receipt);
1776
+ if (!committed) throw new Error(`Discord effect ${id} lost its fence before commit`);
1777
+ return receipt;
1778
+ } catch (error) {
1779
+ if (terminalizeStaleBeforeProvider && revalidationFailed && !providerEffectStarted)
1780
+ await this.#rescheduleAfterEffectTransition(
1781
+ this.#effects.record(id, lease, "terminal", { status: "stale_noop" }),
1782
+ );
1783
+ else if (!renewalLost)
1784
+ await this.#rescheduleAfterEffectTransition(
1785
+ this.#effects.record(id, lease, "uncertain", { status: "uncertain" }),
1786
+ );
1787
+ throw error;
1788
+ } finally {
1789
+ clearInterval(timer);
1790
+ }
1791
+ }
1792
+
1793
+ async #postEffect(
1794
+ id: string,
1795
+ record: DiscordConversation,
1796
+ content: string,
1797
+ components?: DiscordMessageComponent[],
1798
+ actionPublication = false,
1799
+ closing = false,
1800
+ allowInactive = false,
1801
+ ): Promise<void> {
1802
+ const nonce = discordEffectNonce(id);
1803
+ const existingEffect = await this.#effects.read<{
1804
+ threadId: string;
1805
+ content: string;
1806
+ nonce: string;
1807
+ components?: DiscordMessageComponent[];
1808
+ }>(id);
1809
+ const payload = existingEffect?.payload ?? {
1810
+ threadId: record.threadId!,
1811
+ content,
1812
+ nonce,
1813
+ ...(components ? { components } : {}),
1814
+ };
1815
+ await this.#runEffect(
1816
+ id,
1817
+ "post-message",
1818
+ record.sessionId,
1819
+ record.endpointGeneration!,
1820
+ payload,
1821
+ async ensure => {
1822
+ await ensure();
1823
+ const reconciled = await this.options.provider.findMessageByNonce({
1824
+ threadId: payload.threadId,
1825
+ nonce: payload.nonce,
1826
+ });
1827
+ if (reconciled)
1828
+ return {
1829
+ provider: "discord",
1830
+ messageId: reconciled.id,
1831
+ threadId: record.threadId,
1832
+ status: "reconciled",
1833
+ };
1834
+ await ensure();
1835
+ const posted = await this.options.provider.postMessage({
1836
+ threadId: payload.threadId,
1837
+ content: payload.content,
1838
+ nonce: payload.nonce,
1839
+ ...(payload.components ? { components: payload.components } : {}),
1840
+ });
1841
+ return { provider: "discord", messageId: posted.id, threadId: record.threadId, status: "posted" };
1842
+ },
1843
+ async () => {
1844
+ const current = await this.#byThread(record.guildId, record.parentChannelId, record.threadId!);
1845
+ const intent = closingIntent(record);
1846
+ const mappingCurrent = closing
1847
+ ? !!current &&
1848
+ intent?.nonce === closingIntent(current)?.nonce &&
1849
+ current.sessionId === record.sessionId &&
1850
+ current.endpointGeneration === record.endpointGeneration
1851
+ : !!current &&
1852
+ (allowInactive || current.state === "active") &&
1853
+ current.generation === record.generation &&
1854
+ current.endpointGeneration === record.endpointGeneration &&
1855
+ (!actionPublication || current.pendingActionEffectId === id);
1856
+ return (
1857
+ mappingCurrent &&
1858
+ (closing || allowInactive || (await this.#bindingCurrent(record.sessionId!, record.endpointGeneration!)))
1859
+ );
1860
+ },
1861
+ );
1862
+ }
1863
+ async #threadEffect(
1864
+ id: string,
1865
+ record: DiscordConversation,
1866
+ operation: "archive" | "unarchive",
1867
+ locked = false,
1868
+ closing = false,
1869
+ occurrenceId?: string,
1870
+ ): Promise<void> {
1871
+ await this.#runEffect(
1872
+ id,
1873
+ operation,
1874
+ record.sessionId,
1875
+ record.endpointGeneration!,
1876
+ { threadId: record.threadId!, locked, ...(occurrenceId === undefined ? {} : { occurrenceId }) },
1877
+
1878
+ async (ensure, beforeProvider) => {
1879
+ await ensure();
1880
+ beforeProvider();
1881
+ if (operation === "archive")
1882
+ await this.options.provider.archiveThread({
1883
+ threadId: record.threadId!,
1884
+ ...(locked ? { locked: true } : {}),
1885
+ });
1886
+ else await this.options.provider.unarchiveThread({ threadId: record.threadId! });
1887
+ return { provider: "discord", threadId: record.threadId, status: operation };
1888
+ },
1889
+ async () => {
1890
+ const current = await this.#byThread(record.guildId, record.parentChannelId, record.threadId!);
1891
+ const intent = closingIntent(record);
1892
+ const mappingCurrent = closing
1893
+ ? !!current &&
1894
+ intent?.nonce === closingIntent(current)?.nonce &&
1895
+ current.sessionId === record.sessionId &&
1896
+ current.endpointGeneration === record.endpointGeneration
1897
+ : !!current &&
1898
+ current.state === (operation === "archive" ? "active" : "resuming") &&
1899
+ current.generation === record.generation &&
1900
+ current.endpointGeneration === record.endpointGeneration &&
1901
+ (occurrenceId === undefined ||
1902
+ (operation === "archive"
1903
+ ? current.archiveEffectId === id && current.archiveOccurrenceId === occurrenceId
1904
+ : current.resumeEffectId === id && current.resumeOccurrenceId === occurrenceId));
1905
+ return (
1906
+ mappingCurrent &&
1907
+ (closing ||
1908
+ (!!record.sessionId && (await this.#bindingCurrent(record.sessionId, record.endpointGeneration!))))
1909
+ );
1910
+ },
1911
+ !closing,
1912
+ );
1913
+ }
1914
+ async #createThreadEffect(intent: DiscordConversation, name: string): Promise<DiscordThread> {
1915
+ const effectId = `create:${intent.sessionId}:${intent.createNonce}`;
1916
+ const nonce = discordEffectNonce(effectId);
1917
+ const receipt = await this.#runEffect(
1918
+ effectId,
1919
+ "create-thread",
1920
+ intent.sessionId,
1921
+ intent.endpointGeneration!,
1922
+ { guildId: intent.guildId, parentId: intent.parentChannelId, name, nonce },
1923
+ async ensure => {
1924
+ await ensure();
1925
+ const existing = await this.options.provider.findThreadByNonce({
1926
+ guildId: intent.guildId,
1927
+ parentId: intent.parentChannelId,
1928
+ nonce,
1929
+ });
1930
+ await ensure();
1931
+ const thread =
1932
+ existing ??
1933
+ (await this.options.provider.createThread({
1934
+ guildId: intent.guildId,
1935
+ parentId: intent.parentChannelId,
1936
+ name,
1937
+ nonce,
1938
+ }));
1939
+ return {
1940
+ provider: "discord",
1941
+ threadId: thread.id,
1942
+ channelId: thread.parentId,
1943
+ status: existing ? "reconciled" : "created",
1944
+ };
1945
+ },
1946
+ async () => {
1947
+ const current = await this.#store.read(this.#intentKey(intent.sessionId!));
1948
+ return (
1949
+ current?.state === "creating" &&
1950
+ current.createOwner === intent.createOwner &&
1951
+ current.generation === intent.generation &&
1952
+ (current.createLeaseExpiresAt ?? 0) > this.#now() &&
1953
+ (await this.#bindingCurrent(intent.sessionId!, intent.endpointGeneration!))
1954
+ );
1955
+ },
1956
+ );
1957
+ if (!receipt.threadId) throw new Error("Discord create effect has no thread receipt");
1958
+ return { id: receipt.threadId, guildId: intent.guildId, parentId: intent.parentChannelId, archived: false };
1959
+ }
1960
+ async #recoverCreateThread(
1961
+ effect: ChatEffect,
1962
+ payload: { guildId?: string; parentId?: string; name?: string; nonce?: string },
1963
+ ): Promise<void> {
1964
+ if (!effect.sessionId || !payload.nonce) return;
1965
+ const intentKey = this.#intentKey(effect.sessionId);
1966
+ const intent = await this.#store.read(intentKey);
1967
+ const matchesIntent =
1968
+ intent?.state === "creating" &&
1969
+ intent.sessionId === effect.sessionId &&
1970
+ intent.guildId === payload.guildId &&
1971
+ intent.parentChannelId === payload.parentId &&
1972
+ discordEffectNonce(`create:${intent.sessionId}:${intent.createNonce}`) === payload.nonce;
1973
+ if (!matchesIntent || !intent) {
1974
+ if (effect.state !== "terminal") await this.#terminalizeEffect(effect.id, "rejected");
1975
+
1976
+ return;
1977
+ }
1978
+ if (!(await this.#bindingCurrent(effect.sessionId, effect.endpointGeneration))) {
1979
+ await this.#store.delete(intentKey, intent.generation);
1980
+ return;
1981
+ }
1982
+ if (effect.state !== "terminal") {
1983
+ await this.#create(effect.sessionId, effect.endpointGeneration, intent.createNonce!, payload.name);
1984
+ return;
1985
+ }
1986
+ const threadId = effect.receipt?.threadId;
1987
+ if (!threadId) return;
1988
+ // Any durable mapping for this session is already session-level authority.
1989
+ // Do not let a terminal receipt reactivate an older remote thread merely
1990
+ // because its exact thread key is absent. Delete only this generation so a
1991
+ // later notification must mint a fresh nonce and provider effect.
1992
+ if ((await this.#sessionMappings(effect.sessionId)).length > 0) {
1993
+ await this.#store.delete(intentKey, intent.generation);
1994
+ return;
1995
+ }
1996
+ await this.#requireLiveBinding(effect.sessionId, effect.endpointGeneration);
1997
+ const key = discordConversationKey({
1998
+ appId: intent.appId,
1999
+ guildId: intent.guildId,
2000
+ parentChannelId: intent.parentChannelId,
2001
+ threadId,
2002
+ });
2003
+ const committed = await this.#store.transact(
2004
+ key,
2005
+ old =>
2006
+ old ??
2007
+ normalizeDiscordConversation({
2008
+ generation: 1,
2009
+ state: "active",
2010
+ appId: intent.appId,
2011
+ guildId: intent.guildId,
2012
+ parentChannelId: intent.parentChannelId,
2013
+ threadId,
2014
+ sessionId: intent.sessionId,
2015
+ endpointGeneration: intent.endpointGeneration,
2016
+ createNonce: intent.createNonce,
2017
+ effectIncarnationId: intent.createNonce,
2018
+ updatedAt: this.#now(),
2019
+ seenEventIds: [],
2020
+ seenInteractionIds: [],
2021
+ }),
2022
+ );
2023
+ if (committed) await this.#store.delete(intentKey, intent.generation);
2024
+ }
2025
+ async #drainProviderEffects(): Promise<boolean> {
2026
+ let failed = false;
2027
+ for (const effect of await this.#effects.list()) {
2028
+ if (
2029
+ effect.transport !== "discord" ||
2030
+ (effect.state === "terminal" &&
2031
+ effect.kind !== "create-thread" &&
2032
+ effect.kind !== "archive" &&
2033
+ effect.kind !== "unarchive")
2034
+ )
2035
+ continue;
2036
+ if (this.#hasLiveEffectLease(effect)) continue;
2037
+
2038
+ const payload = effect.payload as {
2039
+ guildId?: string;
2040
+ parentId?: string;
2041
+ name?: string;
2042
+ nonce?: string;
2043
+ threadId?: string;
2044
+ content?: string;
2045
+ components?: DiscordMessageComponent[];
2046
+ locked?: boolean;
2047
+ occurrenceId?: string;
2048
+ };
2049
+ const providerEffect =
2050
+ effect.kind === "post-message" || effect.kind === "archive" || effect.kind === "unarchive";
2051
+ if (
2052
+ providerEffect &&
2053
+ (!effect.sessionId ||
2054
+ !payload.threadId ||
2055
+ (effect.kind === "post-message" && payload.content === undefined))
2056
+ ) {
2057
+ await this.#terminalizeEffect(effect.id, "stale_noop");
2058
+
2059
+ continue;
2060
+ }
2061
+ try {
2062
+ if (effect.kind === "create-thread" && effect.sessionId && payload.nonce) {
2063
+ await this.#recoverCreateThread(effect, payload);
2064
+ }
2065
+ if (effect.kind === "post-message" && payload.threadId && payload.content !== undefined) {
2066
+ const record = await this.#byThread(
2067
+ this.options.guildId,
2068
+ this.options.parentChannelId,
2069
+ payload.threadId,
2070
+ );
2071
+ const closing = closingIntent(record);
2072
+ const closeMarker = !!closing && effect.id === this.#closeMarkerEffectId(record!);
2073
+ const inactiveFailure = effect.id.startsWith("failure:");
2074
+ if (
2075
+ !record ||
2076
+ (record.state !== "active" && !closeMarker && !inactiveFailure) ||
2077
+ record.endpointGeneration !== effect.endpointGeneration ||
2078
+ (effect.id.startsWith("action-publication:") && record.pendingActionEffectId !== effect.id)
2079
+ ) {
2080
+ await this.#terminalizeEffect(effect.id, "stale_noop");
2081
+ } else {
2082
+ await this.#postEffect(
2083
+ effect.id,
2084
+ record,
2085
+ payload.content,
2086
+ payload.components,
2087
+ effect.id.startsWith("action-publication:"),
2088
+ closeMarker,
2089
+ inactiveFailure,
2090
+ );
2091
+ }
2092
+ }
2093
+ if ((effect.kind === "archive" || effect.kind === "unarchive") && payload.threadId) {
2094
+ const record = await this.#byThread(
2095
+ this.options.guildId,
2096
+ this.options.parentChannelId,
2097
+ payload.threadId,
2098
+ );
2099
+ const closing = closingIntent(record);
2100
+ const closeArchive =
2101
+ !!closing &&
2102
+ effect.kind === "archive" &&
2103
+ payload.locked === true &&
2104
+ effect.id === this.#closeArchiveEffectId(record!);
2105
+ const occurrenceId = payload.occurrenceId;
2106
+ const occurrenceMatches =
2107
+ closeArchive ||
2108
+ (!!record &&
2109
+ typeof occurrenceId === "string" &&
2110
+ effect.id === (effect.kind === "archive" ? record.archiveEffectId : record.resumeEffectId) &&
2111
+ (effect.kind === "archive"
2112
+ ? record.archiveOccurrenceId === occurrenceId
2113
+ : record.resumeOccurrenceId === occurrenceId));
2114
+ if (
2115
+ !record ||
2116
+ (!closeArchive &&
2117
+ (effect.kind === "archive" ? record.state !== "active" : record.state !== "resuming")) ||
2118
+ record.endpointGeneration !== effect.endpointGeneration ||
2119
+ !occurrenceMatches
2120
+ ) {
2121
+ await this.#terminalizeEffect(effect.id, "stale_noop");
2122
+ } else {
2123
+ await this.#threadEffect(effect.id, record, effect.kind, payload.locked, closeArchive, occurrenceId);
2124
+ if (!closeArchive && occurrenceId) {
2125
+ if (effect.kind === "archive") await this.#completeArchive(record, occurrenceId);
2126
+ else await this.#completeResume(record, occurrenceId);
2127
+ }
2128
+ }
2129
+ }
2130
+ } catch {
2131
+ failed = true; /* retained for a later journal-authoritative replay */
2132
+ }
2133
+ }
2134
+ return failed;
2135
+ }
2136
+ async #rescheduleAfterEffectTransition<T extends ChatEffect | undefined>(transition: Promise<T>): Promise<T> {
2137
+ const effect = await transition;
2138
+ if (effect?.state !== "terminal") await this.#scheduleLeaseRecovery();
2139
+ return effect;
2140
+ }
2141
+ async #scheduleLeaseRecovery(recoveryFailed = false): Promise<void> {
2142
+ if (!this.#started) return;
2143
+ const lifecycleGeneration = this.#lifecycleGeneration;
2144
+ const effects = await this.#effects.list();
2145
+ if (!this.#started || lifecycleGeneration !== this.#lifecycleGeneration) return;
2146
+ const now = this.#now();
2147
+ const recoveryAt = effects
2148
+ .filter(effect => effect.transport === "discord")
2149
+ .reduce<number | undefined>(
2150
+ (earliest, effect) => {
2151
+ const claimAt =
2152
+ effect.state === "leased" && Number.isFinite(effect.leaseExpiresAt)
2153
+ ? effect.leaseExpiresAt
2154
+ : effect.state === "pending" ||
2155
+ effect.state === "accepted" ||
2156
+ (effect.state === "uncertain" && !effect.kind.includes(".inbound."))
2157
+ ? now
2158
+ : undefined;
2159
+ return claimAt === undefined || (earliest !== undefined && earliest <= claimAt) ? earliest : claimAt;
2160
+ },
2161
+ recoveryFailed ? now : undefined,
2162
+ );
2163
+ if (recoveryAt === undefined) {
2164
+ if (this.#leaseRecoveryTimer !== undefined)
2165
+ this.#leaseRecoveryScheduler.clearTimeout(this.#leaseRecoveryTimer);
2166
+ this.#leaseRecoveryTimer = undefined;
2167
+ this.#leaseRecoveryAt = undefined;
2168
+ this.#leaseRecoveryFailures = 0;
2169
+ return;
2170
+ }
2171
+ if (this.#leaseRecoveryAt !== undefined && this.#leaseRecoveryAt <= recoveryAt) return;
2172
+ if (this.#leaseRecoveryTimer !== undefined) this.#leaseRecoveryScheduler.clearTimeout(this.#leaseRecoveryTimer);
2173
+ this.#leaseRecoveryAt = recoveryAt;
2174
+ const delay =
2175
+ recoveryAt <= now
2176
+ ? Math.min(1_000, 25 * 2 ** Math.min(this.#leaseRecoveryFailures, 5))
2177
+ : Math.min(recoveryAt - now, 2_147_483_647);
2178
+ this.#leaseRecoveryTimer = this.#leaseRecoveryScheduler.setTimeout(() => {
2179
+ if (lifecycleGeneration !== this.#lifecycleGeneration) return;
2180
+ this.#leaseRecoveryTimer = undefined;
2181
+ this.#leaseRecoveryAt = undefined;
2182
+ return this.#track(this.#recoverLeasedEffects()).catch(() => {});
2183
+ }, delay);
2184
+ }
2185
+ async #recoverLeasedEffects(): Promise<void> {
2186
+ if (!this.#started) return;
2187
+ let failed = false;
2188
+ try {
2189
+ try {
2190
+ await this.#reconcileTerminalInboundReceipts();
2191
+ failed ||= await this.#recoverClosingConversations();
2192
+ failed ||= await this.#drainProviderEffects();
2193
+ await this.#reconcileTerminalInboundReceipts();
2194
+ failed ||= await this.#recoverClosingConversations();
2195
+ } catch {
2196
+ failed = true;
2197
+ }
2198
+ try {
2199
+ failed = (await this.#drainPendingDispatches()) || failed;
2200
+ } catch {
2201
+ failed = true;
2202
+ }
2203
+ } finally {
2204
+ if (failed) this.#leaseRecoveryFailures = Math.min(this.#leaseRecoveryFailures + 1, 5);
2205
+ else this.#leaseRecoveryFailures = 0;
2206
+ try {
2207
+ await this.#scheduleLeaseRecovery(failed);
2208
+ } catch {
2209
+ /* retained effects are retried by the next trigger */
2210
+ }
2211
+ }
2212
+ }
2213
+
2214
+ async #fail(threadId: string): Promise<void> {
2215
+ try {
2216
+ const record = await this.#byThread(this.options.guildId, this.options.parentChannelId, threadId);
2217
+ if (record)
2218
+ await this.#postEffect(
2219
+ `failure:${threadId}:${randomUUID()}`,
2220
+ record,
2221
+ FAILURE,
2222
+ undefined,
2223
+ false,
2224
+ false,
2225
+ true,
2226
+ );
2227
+ } catch {}
2228
+ }
2229
+ }
2230
+
2231
+ function decodeCustomId(value: string): { generation: number; actionId: string; actionNonce: string } | undefined {
2232
+ const match = /^gjc:(\d+):([^:]+):([0-9a-f-]{36})$/.exec(value);
2233
+ if (!match) return undefined;
2234
+ const generation = Number(match[1]);
2235
+ return Number.isSafeInteger(generation) && generation >= 0
2236
+ ? { generation, actionId: match[2]!, actionNonce: match[3]! }
2237
+ : undefined;
2238
+ }
2239
+
2240
+ function stateRank(state: string): number {
2241
+ return state === "closing"
2242
+ ? -1
2243
+ : state === "active"
2244
+ ? 0
2245
+ : state === "resuming"
2246
+ ? 1
2247
+ : state === "archived"
2248
+ ? 2
2249
+ : state === "closed"
2250
+ ? 3
2251
+ : 4;
2252
+ }
2253
+
2254
+ function actionComponents(
2255
+ generation: number,
2256
+ actionId: string,
2257
+ actionNonce: string,
2258
+ options: string[],
2259
+ ): DiscordMessageComponent[] {
2260
+ return [
2261
+ {
2262
+ type: 1,
2263
+ components: [
2264
+ {
2265
+ type: 3,
2266
+ customId: `gjc:${generation}:${actionId}:${actionNonce}`,
2267
+ placeholder: "Choose an option",
2268
+ minValues: 1,
2269
+ maxValues: 1,
2270
+ options: options.slice(0, 25).map((option, index) => ({
2271
+ label: option.slice(0, 100) || `Option ${index + 1}`,
2272
+ value: String(index),
2273
+ })),
2274
+ },
2275
+ ],
2276
+ },
2277
+ ];
2278
+ }
2279
+
2280
+ function componentAnswer(value: string | number): string | number {
2281
+ if (typeof value === "string" && /^\d+$/.test(value)) {
2282
+ const index = Number(value);
2283
+ if (Number.isSafeInteger(index)) return index;
2284
+ }
2285
+ return value;
2286
+ }
2287
+
2288
+ function discordEffectNonce(effectId: string): string {
2289
+ // Discord nonces are bounded; hash the durable effect identifier rather than
2290
+ // truncating its potentially shared prefix.
2291
+ return `gjc-${createHash("sha256").update(effectId).digest("hex").slice(0, 21)}`;
2292
+ }