@gajae-code/coding-agent 0.10.1 → 0.11.0

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