@gajae-code/coding-agent 0.10.2 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (476) hide show
  1. package/CHANGELOG.md +58 -2
  2. package/dist/types/append-only-mode.d.ts +1 -1
  3. package/dist/types/async/job-manager.d.ts +46 -7
  4. package/dist/types/cli/args.d.ts +4 -5
  5. package/dist/types/cli/daemon-cli.d.ts +12 -1
  6. package/dist/types/cli/notify-cli.d.ts +21 -1
  7. package/dist/types/cli.d.ts +3 -0
  8. package/dist/types/commands/daemon.d.ts +39 -0
  9. package/dist/types/commands/launch.d.ts +3 -0
  10. package/dist/types/commands/notify.d.ts +28 -1
  11. package/dist/types/commands/sdk.d.ts +33 -0
  12. package/dist/types/config/atomic-yaml-patch.d.ts +85 -0
  13. package/dist/types/config/model-profile-activation.d.ts +35 -15
  14. package/dist/types/config/model-profiles.d.ts +11 -5
  15. package/dist/types/config/model-registry.d.ts +3 -3
  16. package/dist/types/config/model-resolver.d.ts +39 -6
  17. package/dist/types/config/model-selector-value.d.ts +17 -0
  18. package/dist/types/config/models-config-schema.d.ts +10 -6
  19. package/dist/types/config/settings-schema.d.ts +133 -6
  20. package/dist/types/config/settings.d.ts +63 -35
  21. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -6
  22. package/dist/types/coordinator-mcp/server.d.ts +6 -49
  23. package/dist/types/daemon/builtin.d.ts +6 -5
  24. package/dist/types/daemon/control-types.d.ts +1 -1
  25. package/dist/types/discovery/mcp-json.d.ts +12 -1
  26. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  27. package/dist/types/extensibility/extensions/compact-handler.d.ts +4 -2
  28. package/dist/types/extensibility/extensions/get-commands-handler.d.ts +2 -2
  29. package/dist/types/extensibility/extensions/loader.d.ts +10 -1
  30. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  31. package/dist/types/extensibility/extensions/types.d.ts +141 -16
  32. package/dist/types/extensibility/hooks/types.d.ts +1 -1
  33. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -2
  34. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  35. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  36. package/dist/types/gjc-runtime/team-runtime.d.ts +3 -4
  37. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +13 -0
  38. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -1
  39. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
  40. package/dist/types/gjc-runtime/windows-powershell-command.d.ts +9 -0
  41. package/dist/types/harness-control-plane/operate.d.ts +5 -5
  42. package/dist/types/harness-control-plane/owner.d.ts +18 -4
  43. package/dist/types/harness-control-plane/receipts.d.ts +0 -1
  44. package/dist/types/harness-control-plane/sdk-transport.d.ts +43 -0
  45. package/dist/types/harness-control-plane/session-transport.d.ts +48 -0
  46. package/dist/types/harness-control-plane/types.d.ts +3 -4
  47. package/dist/types/lsp/index.d.ts +1 -1
  48. package/dist/types/main.d.ts +32 -38
  49. package/dist/types/modes/acp/acp-agent.d.ts +224 -53
  50. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -0
  51. package/dist/types/modes/acp/acp-mode.d.ts +6 -15
  52. package/dist/types/modes/acp/permission-mode.d.ts +4 -0
  53. package/dist/types/modes/acp/startup-options.d.ts +6 -0
  54. package/dist/types/modes/components/agent-dashboard.d.ts +3 -0
  55. package/dist/types/modes/components/model-selector.d.ts +1 -0
  56. package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
  57. package/dist/types/modes/components/settings-selector.d.ts +6 -4
  58. package/dist/types/modes/components/welcome.d.ts +1 -1
  59. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  60. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
  61. package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
  62. package/dist/types/modes/index.d.ts +0 -4
  63. package/dist/types/modes/interactive-mode.d.ts +5 -2
  64. package/dist/types/modes/runtime-init.d.ts +2 -2
  65. package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
  66. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
  67. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
  68. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
  69. package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
  70. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +170 -30
  71. package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
  72. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
  73. package/dist/types/modes/types.d.ts +7 -2
  74. package/dist/types/runtime-mcp/client.d.ts +2 -0
  75. package/dist/types/runtime-mcp/config.d.ts +5 -1
  76. package/dist/types/runtime-mcp/manager.d.ts +8 -1
  77. package/dist/types/runtime-mcp/types.d.ts +4 -0
  78. package/dist/types/sdk/acp/adapter.d.ts +67 -0
  79. package/dist/types/sdk/acp/index.d.ts +2 -0
  80. package/dist/types/sdk/broker/broker.d.ts +53 -0
  81. package/dist/types/sdk/broker/discovery.d.ts +30 -0
  82. package/dist/types/sdk/broker/ensure.d.ts +42 -0
  83. package/dist/types/sdk/broker/identity.d.ts +3 -0
  84. package/dist/types/sdk/broker/index.d.ts +8 -0
  85. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
  86. package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
  87. package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
  88. package/dist/types/sdk/broker/runtime.d.ts +33 -0
  89. package/dist/types/sdk/broker/session-index.d.ts +57 -0
  90. package/dist/types/sdk/broker/state-version.d.ts +8 -0
  91. package/dist/types/sdk/broker/transport.d.ts +9 -0
  92. package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
  93. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
  94. package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
  95. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
  96. package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
  97. package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
  98. package/dist/types/sdk/bus/config.d.ts +274 -0
  99. package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
  100. package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
  101. package/dist/types/sdk/bus/discord-daemon.d.ts +50 -0
  102. package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
  103. package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
  104. package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
  105. package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
  106. package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
  107. package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
  108. package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
  109. package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
  110. package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
  111. package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
  112. package/dist/types/sdk/bus/session-control.d.ts +83 -0
  113. package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
  114. package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
  115. package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
  116. package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
  117. package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
  118. package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
  119. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
  120. package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
  121. package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +78 -17
  122. package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
  123. package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
  124. package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
  125. package/dist/types/sdk/cli/index.d.ts +1 -0
  126. package/dist/types/sdk/cli/session-cli.d.ts +19 -0
  127. package/dist/types/sdk/client/client.d.ts +1 -0
  128. package/dist/types/sdk/client/discovery.d.ts +27 -0
  129. package/dist/types/sdk/client/index.d.ts +2 -0
  130. package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
  131. package/dist/types/sdk/host/control/index.d.ts +2 -0
  132. package/dist/types/sdk/host/control/operations.d.ts +65 -0
  133. package/dist/types/sdk/host/events.d.ts +35 -0
  134. package/dist/types/sdk/host/host.d.ts +35 -0
  135. package/dist/types/sdk/host/index.d.ts +4 -0
  136. package/dist/types/sdk/host/query/cursor.d.ts +62 -0
  137. package/dist/types/sdk/host/query/handlers.d.ts +70 -0
  138. package/dist/types/sdk/host/query/index.d.ts +3 -0
  139. package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
  140. package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
  141. package/dist/types/sdk/host/types.d.ts +32 -0
  142. package/dist/types/sdk/index.d.ts +6 -0
  143. package/dist/types/sdk/lifecycle-session.d.ts +14 -0
  144. package/dist/types/sdk/mcp/index.d.ts +1 -0
  145. package/dist/types/sdk/mcp/server.d.ts +37 -0
  146. package/dist/types/sdk/models.d.ts +44 -0
  147. package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
  148. package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
  149. package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
  150. package/dist/types/sdk/startup-capability.d.ts +50 -0
  151. package/dist/types/session/agent-session.d.ts +114 -21
  152. package/dist/types/session/default-model-selection.d.ts +25 -0
  153. package/dist/types/session/fallback-chain-controller.d.ts +52 -0
  154. package/dist/types/session/session-manager.d.ts +58 -1
  155. package/dist/types/setup/credential-auto-import.d.ts +36 -6
  156. package/dist/types/tools/ask-answer-registry.d.ts +1 -1
  157. package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
  158. package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
  159. package/dist/types/tools/browser/launch.d.ts +22 -0
  160. package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
  161. package/dist/types/tools/browser/profile-posture.d.ts +37 -0
  162. package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
  163. package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
  164. package/dist/types/tools/browser/registry.d.ts +19 -0
  165. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  166. package/dist/types/tools/index.d.ts +8 -8
  167. package/dist/types/tools/subagent-render.d.ts +1 -0
  168. package/dist/types/tools/subagent.d.ts +6 -1
  169. package/package.json +34 -13
  170. package/scripts/build-sdk-package-smoke.ts +99 -0
  171. package/scripts/capture-notifications-settings-showcase.ts +435 -0
  172. package/scripts/compile-args.ts +4 -2
  173. package/scripts/g005-daemon-smoke.ts +4 -5
  174. package/scripts/g011-daemon-path-smoke.ts +5 -5
  175. package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
  176. package/scripts/generate-sdk-operation-inventory.ts +867 -0
  177. package/scripts/generate-telegram-baseline-manifest.ts +128 -0
  178. package/scripts/manifest-schema.ts +124 -0
  179. package/scripts/run-test-manifest.ts +153 -0
  180. package/scripts/run-threat-model-check.ts +157 -0
  181. package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
  182. package/scripts/verify-gjc-sdk-rename.ts +90 -0
  183. package/src/append-only-mode.ts +1 -1
  184. package/src/async/job-manager.ts +406 -63
  185. package/src/cli/args.ts +43 -12
  186. package/src/cli/auth-gateway-cli.ts +2 -2
  187. package/src/cli/config-cli.ts +2 -1
  188. package/src/cli/daemon-cli.ts +63 -14
  189. package/src/cli/list-models.ts +1 -1
  190. package/src/cli/notify-cli.ts +288 -286
  191. package/src/cli.ts +21 -2
  192. package/src/commands/daemon.ts +50 -6
  193. package/src/commands/harness.ts +93 -36
  194. package/src/commands/launch.ts +5 -2
  195. package/src/commands/mcp-serve.ts +19 -12
  196. package/src/commands/notify.ts +36 -3
  197. package/src/commands/sdk.ts +426 -0
  198. package/src/commit/agentic/agent.ts +1 -0
  199. package/src/config/atomic-yaml-patch.ts +423 -0
  200. package/src/config/model-profile-activation.ts +240 -89
  201. package/src/config/model-profiles.ts +18 -15
  202. package/src/config/model-registry.ts +51 -26
  203. package/src/config/model-resolver.ts +138 -51
  204. package/src/config/model-selector-value.ts +50 -0
  205. package/src/config/models-config-schema.ts +14 -21
  206. package/src/config/settings-schema.ts +128 -10
  207. package/src/config/settings.ts +1120 -158
  208. package/src/coordinator-mcp/model-preset.ts +74 -20
  209. package/src/coordinator-mcp/server.ts +1597 -3052
  210. package/src/daemon/builtin.ts +8 -5
  211. package/src/daemon/control-types.ts +1 -1
  212. package/src/defaults/gjc/skills/team/SKILL.md +4 -4
  213. package/src/discovery/mcp-json.ts +327 -20
  214. package/src/extensibility/custom-tools/types.ts +1 -1
  215. package/src/extensibility/extensions/compact-handler.ts +3 -3
  216. package/src/extensibility/extensions/get-commands-handler.ts +2 -2
  217. package/src/extensibility/extensions/loader.ts +69 -5
  218. package/src/extensibility/extensions/runner.ts +112 -4
  219. package/src/extensibility/extensions/types.ts +176 -17
  220. package/src/extensibility/hooks/types.ts +1 -1
  221. package/src/gjc-runtime/launch-tmux.ts +170 -86
  222. package/src/gjc-runtime/psmux-detect.ts +82 -4
  223. package/src/gjc-runtime/session-state-sidecar.ts +41 -31
  224. package/src/gjc-runtime/team-runtime.ts +54 -35
  225. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
  226. package/src/gjc-runtime/tmux-owner-isolation.ts +1 -1
  227. package/src/gjc-runtime/tmux-sessions.ts +19 -13
  228. package/src/gjc-runtime/ultragoal-guard.ts +108 -14
  229. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
  230. package/src/gjc-runtime/ultragoal-runtime.ts +227 -11
  231. package/src/gjc-runtime/windows-powershell-command.ts +58 -0
  232. package/src/harness-control-plane/operate.ts +9 -9
  233. package/src/harness-control-plane/owner.ts +283 -39
  234. package/src/harness-control-plane/receipts.ts +0 -1
  235. package/src/harness-control-plane/sdk-transport.ts +395 -0
  236. package/src/harness-control-plane/session-transport.ts +94 -0
  237. package/src/harness-control-plane/types.ts +1 -1
  238. package/src/internal-urls/docs-index.generated.ts +34 -35
  239. package/src/lsp/client.ts +42 -6
  240. package/src/lsp/index.ts +2 -2
  241. package/src/main.ts +299 -204
  242. package/src/modes/DESIGN.md +258 -0
  243. package/src/modes/acp/acp-agent.ts +1228 -2612
  244. package/src/modes/acp/acp-client-bridge.ts +2 -20
  245. package/src/modes/acp/acp-event-mapper.ts +18 -2
  246. package/src/modes/acp/acp-mode.ts +8 -36
  247. package/src/modes/acp/permission-mode.ts +25 -0
  248. package/src/modes/acp/startup-options.ts +6 -0
  249. package/src/modes/components/agent-dashboard.ts +37 -17
  250. package/src/modes/components/model-selector.ts +93 -35
  251. package/src/modes/components/notifications-settings-editor.ts +1379 -0
  252. package/src/modes/components/settings-selector.ts +60 -11
  253. package/src/modes/components/welcome.ts +4 -4
  254. package/src/modes/controllers/command-controller.ts +10 -17
  255. package/src/modes/controllers/event-controller.ts +11 -16
  256. package/src/modes/controllers/extension-ui-controller.ts +396 -28
  257. package/src/modes/controllers/selector-controller.ts +674 -36
  258. package/src/modes/index.ts +0 -22
  259. package/src/modes/interactive-mode.ts +87 -36
  260. package/src/modes/runtime-init.ts +298 -6
  261. package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
  262. package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
  263. package/src/modes/shared/agent-wire/event-contract.ts +7 -8
  264. package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
  265. package/src/modes/shared/agent-wire/event-observation.ts +16 -7
  266. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1258 -87
  267. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
  268. package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
  269. package/src/modes/types.ts +7 -2
  270. package/src/runtime-mcp/client.ts +94 -19
  271. package/src/runtime-mcp/config.ts +27 -15
  272. package/src/runtime-mcp/manager.ts +343 -105
  273. package/src/runtime-mcp/oauth-flow.ts +42 -12
  274. package/src/runtime-mcp/transports/http.ts +49 -19
  275. package/src/runtime-mcp/transports/stdio.ts +47 -28
  276. package/src/runtime-mcp/types.ts +10 -0
  277. package/src/sdk/acp/adapter.ts +418 -0
  278. package/src/sdk/acp/index.ts +2 -0
  279. package/src/sdk/broker/broker.ts +642 -0
  280. package/src/sdk/broker/discovery.ts +113 -0
  281. package/src/sdk/broker/ensure.ts +307 -0
  282. package/src/sdk/broker/identity.ts +42 -0
  283. package/src/sdk/broker/index.ts +8 -0
  284. package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
  285. package/src/sdk/broker/internal-source.bunfig.toml +1 -0
  286. package/src/sdk/broker/lifecycle-ledger.ts +286 -0
  287. package/src/sdk/broker/lifecycle.ts +2157 -0
  288. package/src/sdk/broker/process-incarnation.ts +159 -0
  289. package/src/sdk/broker/runtime.ts +164 -0
  290. package/src/sdk/broker/session-index.ts +318 -0
  291. package/src/sdk/broker/state-version.ts +23 -0
  292. package/src/sdk/broker/transport.ts +125 -0
  293. package/src/sdk/bus/chat-command-policy.ts +107 -0
  294. package/src/sdk/bus/chat-daemon-cli.ts +214 -0
  295. package/src/sdk/bus/chat-daemon-control.ts +713 -0
  296. package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
  297. package/src/sdk/bus/chat-effect-journal.ts +355 -0
  298. package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
  299. package/src/sdk/bus/config.ts +433 -0
  300. package/src/sdk/bus/conversation-store.ts +391 -0
  301. package/src/sdk/bus/discord-conversation.ts +85 -0
  302. package/src/sdk/bus/discord-daemon.ts +2292 -0
  303. package/src/sdk/bus/discord-live-provider.ts +521 -0
  304. package/src/sdk/bus/discord-provider.ts +56 -0
  305. package/src/{notifications → sdk/bus}/html-format.ts +2 -2
  306. package/src/sdk/bus/index.ts +3979 -0
  307. package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
  308. package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
  309. package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
  310. package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
  311. package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
  312. package/src/sdk/bus/natives-augment.d.ts +38 -0
  313. package/src/sdk/bus/notification-orchestration.ts +535 -0
  314. package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
  315. package/src/sdk/bus/session-control.ts +333 -0
  316. package/src/sdk/bus/slack-conversation.ts +74 -0
  317. package/src/sdk/bus/slack-daemon.ts +2144 -0
  318. package/src/sdk/bus/slack-live-provider.ts +380 -0
  319. package/src/sdk/bus/slack-provider.ts +76 -0
  320. package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
  321. package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
  322. package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
  323. package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
  324. package/src/{notifications → sdk/bus}/telegram-daemon.ts +603 -63
  325. package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
  326. package/src/sdk/bus/telegram-setup.ts +663 -0
  327. package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
  328. package/src/sdk/cli/index.ts +1 -0
  329. package/src/sdk/cli/session-cli.ts +275 -0
  330. package/src/sdk/client/client.ts +1 -0
  331. package/src/sdk/client/discovery.ts +108 -0
  332. package/src/sdk/client/index.ts +2 -0
  333. package/src/sdk/host/control/dispatch.ts +367 -0
  334. package/src/sdk/host/control/index.ts +10 -0
  335. package/src/sdk/host/control/operations.ts +63 -0
  336. package/src/sdk/host/events.ts +91 -0
  337. package/src/sdk/host/host.ts +296 -0
  338. package/src/sdk/host/index.ts +4 -0
  339. package/src/sdk/host/query/cursor.ts +189 -0
  340. package/src/sdk/host/query/handlers.ts +533 -0
  341. package/src/sdk/host/query/index.ts +3 -0
  342. package/src/sdk/host/query/revision-store.ts +1126 -0
  343. package/src/sdk/host/reverse-leases.ts +325 -0
  344. package/src/sdk/host/types.ts +29 -0
  345. package/src/sdk/index.ts +13 -0
  346. package/src/sdk/lifecycle-session.ts +39 -0
  347. package/src/sdk/mcp/index.ts +1 -0
  348. package/src/sdk/mcp/server.ts +332 -0
  349. package/src/sdk/models.ts +168 -0
  350. package/src/sdk/protocol/adapter-validation.ts +48 -0
  351. package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
  352. package/src/sdk/protocol/operation-registry.ts +312 -0
  353. package/src/{sdk.ts → sdk/session.ts} +339 -212
  354. package/src/sdk/startup-capability.ts +189 -0
  355. package/src/session/agent-session.ts +2650 -944
  356. package/src/session/default-model-selection.ts +93 -0
  357. package/src/session/fallback-chain-controller.ts +136 -0
  358. package/src/session/session-manager.ts +438 -11
  359. package/src/session/session-storage.ts +8 -1
  360. package/src/setup/credential-auto-import.ts +290 -35
  361. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
  362. package/src/slash-commands/builtin-registry.ts +2 -1
  363. package/src/task/executor.ts +62 -13
  364. package/src/task/index.ts +26 -16
  365. package/src/tools/ask-answer-registry.ts +1 -1
  366. package/src/tools/ask.ts +13 -8
  367. package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
  368. package/src/tools/browser/benchmark/detector-report.ts +157 -0
  369. package/src/tools/browser/benchmark/run-suite.ts +39 -0
  370. package/src/tools/browser/launch.ts +124 -19
  371. package/src/tools/browser/profile-discovery.ts +63 -0
  372. package/src/tools/browser/profile-posture.ts +59 -0
  373. package/src/tools/browser/profile-reuse.ts +102 -0
  374. package/src/tools/browser/profile-warmup.ts +109 -0
  375. package/src/tools/browser/registry.ts +87 -11
  376. package/src/tools/browser/tab-protocol.ts +1 -0
  377. package/src/tools/browser/tab-supervisor.ts +1 -0
  378. package/src/tools/browser/tab-worker.ts +1 -1
  379. package/src/tools/browser.ts +6 -0
  380. package/src/tools/index.ts +8 -8
  381. package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
  382. package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
  383. package/src/tools/subagent-render.ts +19 -8
  384. package/src/tools/subagent.ts +84 -32
  385. package/src/tools/telegram-send.ts +2 -2
  386. package/vendor/insane-search/MANIFEST.json +2 -1
  387. package/vendor/insane-search/engine/bias_check.py +4 -1
  388. package/vendor/insane-search/engine/phase0.py +26 -1
  389. package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
  390. package/dist/types/modes/bridge/auth.d.ts +0 -12
  391. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
  392. package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
  393. package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
  394. package/dist/types/modes/bridge/event-stream.d.ts +0 -8
  395. package/dist/types/modes/rpc/host-tools.d.ts +0 -1
  396. package/dist/types/modes/rpc/host-uris.d.ts +0 -1
  397. package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
  398. package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
  399. package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
  400. package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
  401. package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
  402. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
  403. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
  404. package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
  405. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
  406. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
  407. package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
  408. package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
  409. package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
  410. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
  411. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
  412. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
  413. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
  414. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
  415. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
  416. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
  417. package/dist/types/notifications/config.d.ts +0 -132
  418. package/src/harness-control-plane/rpc-adapter.ts +0 -290
  419. package/src/modes/bridge/auth.ts +0 -41
  420. package/src/modes/bridge/bridge-client-bridge.ts +0 -47
  421. package/src/modes/bridge/bridge-mode.ts +0 -675
  422. package/src/modes/bridge/bridge-ui-context.ts +0 -200
  423. package/src/modes/bridge/event-stream.ts +0 -70
  424. package/src/modes/rpc/host-tools.ts +0 -1
  425. package/src/modes/rpc/host-uris.ts +0 -1
  426. package/src/modes/rpc/rpc-client.ts +0 -1166
  427. package/src/modes/rpc/rpc-mode.ts +0 -1029
  428. package/src/modes/rpc/rpc-socket-security.ts +0 -115
  429. package/src/modes/rpc/rpc-types.ts +0 -637
  430. package/src/modes/shared/agent-wire/command-contract.ts +0 -18
  431. package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
  432. package/src/modes/shared/agent-wire/command-validation.ts +0 -181
  433. package/src/modes/shared/agent-wire/handshake.ts +0 -157
  434. package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
  435. package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
  436. package/src/modes/shared/agent-wire/protocol.ts +0 -46
  437. package/src/modes/shared/agent-wire/responses.ts +0 -17
  438. package/src/modes/shared/agent-wire/scopes.ts +0 -94
  439. package/src/modes/shared/agent-wire/session-registry.ts +0 -109
  440. package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
  441. package/src/modes/shared/agent-wire/ui-result.ts +0 -48
  442. package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
  443. package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
  444. package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
  445. package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
  446. package/src/notifications/config.ts +0 -219
  447. package/src/notifications/index.ts +0 -1835
  448. package/src/notifications/telegram-daemon-cli.ts +0 -243
  449. package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
  450. /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
  451. /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
  452. /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
  453. /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
  454. /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
  455. /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
  456. /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
  457. /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
  458. /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
  459. /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
  460. /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
  461. /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
  462. /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
  463. /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
  464. /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
  465. /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
  466. /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
  467. /package/src/{notifications → sdk/bus}/engine.ts +0 -0
  468. /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
  469. /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
  470. /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
  471. /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
  472. /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
  473. /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
  474. /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
  475. /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
  476. /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
@@ -0,0 +1,2144 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SdkClientError } from "../client/client";
3
+ import { readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
4
+ import type { ChatDeliveryError } from "./chat-daemon-runtime";
5
+ import { ConversationStore } from "./conversation-store";
6
+ import {
7
+ acceptsSlackInbound,
8
+ normalizeSlackConversation,
9
+ type SlackConversation,
10
+ type SlackInboundDispatchReceipt,
11
+ slackConversationKey,
12
+ } from "./slack-conversation";
13
+
14
+ export class SlackEndpointBindingError extends Error {
15
+ constructor(message = "Slack session endpoint changed before dispatch.") {
16
+ super(message);
17
+ this.name = "SlackEndpointBindingError";
18
+ }
19
+ }
20
+
21
+ class SlackStaleEffectError extends Error {
22
+ constructor() {
23
+ super("Slack effect is no longer current");
24
+ }
25
+ }
26
+
27
+ class SlackReconciledAbsentEffectError extends Error {
28
+ constructor() {
29
+ super("Slack effect was not found during reconciliation");
30
+ }
31
+ }
32
+
33
+ import { type ChatEffect, ChatEffectJournal, type ChatEffectLease } from "./chat-effect-journal";
34
+ import { SlackProviderError } from "./slack-live-provider";
35
+ import type { SlackPostedMessage, SlackProvider, SlackSocketEnvelope } from "./slack-provider";
36
+
37
+ // Durable filesystem publication leases must outlast one event-loop and persistence turn.
38
+ const MIN_PUBLICATION_LEASE_MS = 100;
39
+
40
+ export interface SlackEndpoint extends SdkSessionEndpoint {
41
+ generation: number;
42
+ }
43
+
44
+ export interface SlackSdkClient {
45
+ send(frame: Record<string, unknown>): void;
46
+ }
47
+
48
+ export interface SlackNotificationDaemonOptions {
49
+ agentDir: string;
50
+ repo: string;
51
+ teamId: string;
52
+ channelId: string;
53
+ provider: SlackProvider;
54
+ botUserId?: string;
55
+ /** Fail-closed authorization for the paired Slack principal. */
56
+ authorizeActor?: (actorId: string) => boolean | Promise<boolean>;
57
+ store?: ConversationStore<SlackConversation>;
58
+ now?: () => number;
59
+ randomId?: () => string;
60
+ /** Stable identity for the process attempting durable provider publication. */
61
+ publicationOwnerId?: string;
62
+ publicationLeaseMs?: number;
63
+
64
+ resolveEndpoint?: (sessionId: string) => Promise<SlackEndpoint | null>;
65
+ createClient: (endpoint: SlackEndpoint) => SlackSdkClient;
66
+ onCommand?: (
67
+ sessionId: string,
68
+ content: string,
69
+ endpoint: SlackEndpoint,
70
+ idempotencyKey: string,
71
+ ) => Promise<boolean>;
72
+ }
73
+
74
+ type SlackEvent = {
75
+ type?: unknown;
76
+ channel?: unknown;
77
+ ts?: unknown;
78
+ thread_ts?: unknown;
79
+ user?: unknown;
80
+ bot_id?: unknown;
81
+ subtype?: unknown;
82
+ text?: unknown;
83
+ client_msg_id?: unknown;
84
+ };
85
+
86
+ type EventsPayload = {
87
+ type?: unknown;
88
+ event_id?: unknown;
89
+ team_id?: unknown;
90
+ event_context?: unknown;
91
+ event?: SlackEvent;
92
+ };
93
+ type SlackInboundRouting = {
94
+ teamId: string;
95
+ channelId: string;
96
+ rootTs: string;
97
+ actorId: string;
98
+ eventId: string;
99
+ interactionId: string;
100
+ retryKey: string;
101
+ eventContext?: string;
102
+ kind: "action" | "command";
103
+ actionId?: string;
104
+ };
105
+
106
+ type SlackInboundEffectPayload =
107
+ | { type: "reply"; id: string; answer: string; idempotencyKey: string; routing: SlackInboundRouting }
108
+ | { type: "command"; content: string; idempotencyKey: string; routing: SlackInboundRouting };
109
+
110
+ type SlackRootPublication = {
111
+ conversation: SlackConversation;
112
+ created: boolean;
113
+ };
114
+
115
+ function text(value: unknown): string | undefined {
116
+ return typeof value === "string" && value.length > 0 ? value : undefined;
117
+ }
118
+
119
+ function messageFromEnvelope(
120
+ payload: unknown,
121
+ ):
122
+ | { eventId: string; eventContext?: string; teamId: string; channelId: string; rootTs: string; event: SlackEvent }
123
+ | undefined {
124
+ if (!payload || typeof payload !== "object") return undefined;
125
+ const eventPayload = payload as EventsPayload;
126
+ if (eventPayload.type !== "events_api" || !eventPayload.event || eventPayload.event.type !== "message")
127
+ return undefined;
128
+ const eventId = text(eventPayload.event_id);
129
+ const teamId = text(eventPayload.team_id);
130
+ const channelId = text(eventPayload.event.channel);
131
+ const ts = text(eventPayload.event.ts);
132
+ const rootTs = text(eventPayload.event.thread_ts) ?? ts;
133
+ if (!eventId || !teamId || !channelId || !rootTs) return undefined;
134
+ return {
135
+ eventId,
136
+ eventContext: text(eventPayload.event_context),
137
+ teamId,
138
+ channelId,
139
+ rootTs,
140
+ event: eventPayload.event,
141
+ };
142
+ }
143
+
144
+ function nextRecord(record: SlackConversation, update: Partial<SlackConversation>): SlackConversation {
145
+ return normalizeSlackConversation({ ...record, ...update, generation: record.generation + 1 });
146
+ }
147
+
148
+ /**
149
+ * Slack Socket Mode notification daemon. Accepted inbound SDK effects are
150
+ * persisted before acknowledgement, including their replay payload and captured
151
+ * endpoint generation; endpoint credentials and Socket Mode cursors are never
152
+ * written to disk.
153
+ */
154
+
155
+ export class SlackNotificationDaemon {
156
+ readonly store: ConversationStore<SlackConversation>;
157
+ readonly #now: () => number;
158
+ readonly #randomId: () => string;
159
+ readonly #resolveEndpoint: (sessionId: string) => Promise<SlackEndpoint | null>;
160
+ readonly #publicationOwnerId: string;
161
+ readonly #publicationLeaseMs: number;
162
+ readonly #journal: ChatEffectJournal;
163
+
164
+ readonly #inflightInbound = new Set<string>();
165
+ readonly #activeWork = new Set<Promise<unknown>>();
166
+ readonly #rollovers = new Map<string, Promise<SlackRootPublication>>();
167
+ #lifecycleTail: Promise<void> = Promise.resolve();
168
+ #lifecycleGeneration = 0;
169
+ #startedGeneration: number | undefined;
170
+ #startOperation: Promise<void> | undefined;
171
+ #startOperationGeneration: number | undefined;
172
+ #stopOperation: Promise<void> | undefined;
173
+ #started = false;
174
+ #providerStarting = false;
175
+ #providerRunning = false;
176
+ #providerStopRequestedGeneration: number | undefined;
177
+ #leaseRecoveryTimer: ReturnType<typeof setTimeout> | undefined;
178
+ #leaseRecoveryAt: number | undefined;
179
+ #leaseRecoveryFailures = 0;
180
+ #leaseRecoveryTimerGeneration = 0;
181
+ #leaseRecoveryScheduling: Promise<void> = Promise.resolve();
182
+ #recoveringLeasedEffects = false;
183
+
184
+ constructor(private readonly options: SlackNotificationDaemonOptions) {
185
+ this.store =
186
+ options.store ?? new ConversationStore<SlackConversation>({ agentDir: options.agentDir, kind: "slack" });
187
+ this.#now = options.now ?? Date.now;
188
+ this.#randomId = options.randomId ?? randomUUID;
189
+ this.#publicationOwnerId = options.publicationOwnerId ?? randomUUID();
190
+ this.#publicationLeaseMs = Math.max(options.publicationLeaseMs ?? 30_000, MIN_PUBLICATION_LEASE_MS);
191
+ this.#journal = new ChatEffectJournal({ agentDir: options.agentDir, transport: "slack", now: this.#now });
192
+ this.#resolveEndpoint =
193
+ options.resolveEndpoint ??
194
+ (async sessionId => {
195
+ const endpoint = await readSdkSessionEndpoint(options.repo, sessionId);
196
+ return endpoint ? { ...endpoint, generation: 1 } : null;
197
+ });
198
+ }
199
+
200
+ async start(): Promise<void> {
201
+ const lifecycleGeneration = this.#lifecycleGeneration;
202
+ if (this.#started && this.#startedGeneration === lifecycleGeneration) return;
203
+ if (this.#startOperation && this.#startOperationGeneration === lifecycleGeneration)
204
+ return await this.#startOperation;
205
+ const operation = this.#enqueueLifecycle(async () => {
206
+ if (lifecycleGeneration !== this.#lifecycleGeneration || this.#started) return;
207
+ this.#started = true;
208
+ this.#startedGeneration = lifecycleGeneration;
209
+ try {
210
+ // Recovery is a barrier: Socket Mode must not ACK an envelope before the
211
+ // mapping authority represented by durable effects has been restored.
212
+ await this.#reconcileTerminalProviderReceipts();
213
+ await this.#reconcileTerminalInboundReceipts();
214
+ const providerRecoveryFailed = await this.#drainProviderEffects();
215
+ await this.#drainPendingDispatches();
216
+ await this.#scheduleLeaseRecovery(providerRecoveryFailed);
217
+ // stop() invalidates this generation synchronously before it joins the
218
+ // lifecycle queue, so no stale recovery can open Socket Mode.
219
+ if (
220
+ lifecycleGeneration !== this.#lifecycleGeneration ||
221
+ !this.#started ||
222
+ this.#startedGeneration !== lifecycleGeneration
223
+ ) {
224
+ this.#started = false;
225
+ this.#startedGeneration = undefined;
226
+ this.#clearLeaseRecoveryTimer();
227
+ return;
228
+ }
229
+ this.#providerStarting = true;
230
+ try {
231
+ await this.options.provider.start(async envelope => {
232
+ await this.#track(this.handleEnvelope(envelope));
233
+ });
234
+ this.#providerRunning = true;
235
+ } finally {
236
+ this.#providerStarting = false;
237
+ }
238
+ if (lifecycleGeneration !== this.#lifecycleGeneration || this.#startedGeneration !== lifecycleGeneration) {
239
+ this.#started = false;
240
+ this.#startedGeneration = undefined;
241
+ this.#providerRunning = false;
242
+ this.#clearLeaseRecoveryTimer();
243
+ if (this.#providerStopRequestedGeneration !== lifecycleGeneration) await this.options.provider.stop();
244
+ }
245
+ } catch (error) {
246
+ if (this.#startedGeneration === lifecycleGeneration) {
247
+ this.#started = false;
248
+ this.#startedGeneration = undefined;
249
+ this.#clearLeaseRecoveryTimer();
250
+ }
251
+ throw error;
252
+ }
253
+ });
254
+ this.#startOperation = operation;
255
+ this.#startOperationGeneration = lifecycleGeneration;
256
+ try {
257
+ await operation;
258
+ } finally {
259
+ if (this.#startOperation === operation) {
260
+ this.#startOperation = undefined;
261
+ this.#startOperationGeneration = undefined;
262
+ }
263
+ }
264
+ }
265
+
266
+ async stop(): Promise<void> {
267
+ if (this.#stopOperation) return await this.#stopOperation;
268
+ const lifecycleGeneration = this.#lifecycleGeneration++;
269
+ const stopProvider = this.#providerStarting || this.#providerRunning;
270
+ this.#started = false;
271
+ this.#startedGeneration = undefined;
272
+ this.#providerRunning = false;
273
+ this.#clearLeaseRecoveryTimer();
274
+ if (stopProvider) this.#providerStopRequestedGeneration = lifecycleGeneration;
275
+ const providerStop = stopProvider ? this.options.provider.stop() : undefined;
276
+ const operation = this.#enqueueLifecycle(async () => {
277
+ // Calling provider.stop() before joining the lifecycle queue lets a provider
278
+ // cancel an open that resolves only after its socket is stopped.
279
+ if (providerStop) await providerStop;
280
+ // Drain until quiescent: a tracked task can schedule further tracked work
281
+ // (recovery/reconciliation) while we await, and any that outlives stop() would
282
+ // bleed timing pressure into the next daemon/test. #started is already false,
283
+ // so no new lease-recovery timers can be armed and the loop terminates.
284
+ while (this.#activeWork.size > 0) await Promise.all([...this.#activeWork]);
285
+ });
286
+ this.#stopOperation = operation;
287
+ try {
288
+ await operation;
289
+ } finally {
290
+ if (this.#stopOperation === operation) this.#stopOperation = undefined;
291
+ }
292
+ }
293
+
294
+ /** Accepted inbound effects are durably claimed before their Socket Mode ACK. */
295
+ async handleEnvelope(envelope: SlackSocketEnvelope): Promise<boolean> {
296
+ if (!text(envelope.envelope_id)) return false;
297
+ const inbound = messageFromEnvelope(envelope.payload);
298
+ if (!inbound || inbound.teamId !== this.options.teamId || inbound.channelId !== this.options.channelId) {
299
+ await this.options.provider.ack(envelope.envelope_id);
300
+ return false;
301
+ }
302
+ const actorId = text(inbound.event.user);
303
+ if (
304
+ inbound.event.bot_id ||
305
+ inbound.event.subtype === "bot_message" ||
306
+ actorId === this.options.botUserId ||
307
+ !actorId ||
308
+ !(await this.#actorAuthorized(actorId))
309
+ ) {
310
+ await this.options.provider.ack(envelope.envelope_id);
311
+ return false;
312
+ }
313
+ const claim = await this.#claimInbound(inbound, actorId);
314
+ if (!claim) {
315
+ await this.options.provider.ack(envelope.envelope_id);
316
+ return false;
317
+ }
318
+ const inflightKey = `${claim.key}\u0000${claim.receipt.key}`;
319
+ if (this.#inflightInbound.has(inflightKey)) {
320
+ await this.options.provider.ack(envelope.envelope_id);
321
+ return false;
322
+ }
323
+ this.#inflightInbound.add(inflightKey);
324
+ try {
325
+ await this.options.provider.ack(envelope.envelope_id);
326
+ return await this.#dispatchInbound(claim);
327
+ } finally {
328
+ this.#inflightInbound.delete(inflightKey);
329
+ await this.#scheduleLeaseRecovery();
330
+ }
331
+ }
332
+
333
+ #track<T>(work: Promise<T>): Promise<T> {
334
+ this.#activeWork.add(work);
335
+ return work.finally(() => this.#activeWork.delete(work));
336
+ }
337
+
338
+ #enqueueLifecycle<T>(operation: () => Promise<T>): Promise<T> {
339
+ const scheduled = this.#lifecycleTail.then(operation, operation);
340
+ this.#lifecycleTail = scheduled.then(
341
+ () => undefined,
342
+ () => undefined,
343
+ );
344
+ return scheduled;
345
+ }
346
+
347
+ async postRoot(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackConversation> {
348
+ return (await this.#postRoot(sessionId, body, endpointGeneration)).conversation;
349
+ }
350
+
351
+ async #postRoot(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackRootPublication> {
352
+ const endpoint = await this.#resolveEndpoint(sessionId);
353
+ if (!endpoint || (endpointGeneration !== undefined && endpoint.generation !== endpointGeneration))
354
+ throw new SlackEndpointBindingError("Slack root publication requires the current session endpoint.");
355
+ const generation = endpoint.generation;
356
+ const pendingKey = this.#intentKey(sessionId);
357
+ let claimed = false;
358
+ const pending = await this.store.transact(pendingKey, current => {
359
+ if (current?.state === "active") return current;
360
+ const now = this.#now();
361
+ if (
362
+ current?.rootPublicationOwner !== undefined &&
363
+ current.rootPublicationOwner !== this.#publicationOwnerId &&
364
+ !this.#leaseExpired(current.rootPublicationLeaseExpiresAt, now)
365
+ )
366
+ return current;
367
+ if (current?.state === "posting_root") {
368
+ if (current.rootPublicationOwner === this.#publicationOwnerId) return current;
369
+ claimed = true;
370
+ return nextRecord(current, {
371
+ rootPublicationOwner: this.#publicationOwnerId,
372
+ rootPublicationLeaseExpiresAt: now + this.#publicationLeaseMs,
373
+ rootPublicationFence: (current.rootPublicationFence ?? 0) + 1,
374
+ updatedAt: now,
375
+ });
376
+ }
377
+ claimed = true;
378
+ return {
379
+ generation: (current?.generation ?? 0) + 1,
380
+ state: "posting_root",
381
+ teamId: this.options.teamId,
382
+ channelId: this.options.channelId,
383
+ sessionId,
384
+ clientMsgId: this.#randomId(),
385
+ rootPublicationOwner: this.#publicationOwnerId,
386
+ rootPublicationLeaseExpiresAt: now + this.#publicationLeaseMs,
387
+ rootPublicationFence: (current?.rootPublicationFence ?? 0) + 1,
388
+ endpointGeneration: generation,
389
+ updatedAt: now,
390
+ seenEventIds: current?.seenEventIds ?? [],
391
+ seenContextIds: current?.seenContextIds ?? [],
392
+ seenRetryKeys: current?.seenRetryKeys ?? [],
393
+ seenInteractionIds: current?.seenInteractionIds ?? [],
394
+ inboundDispatches: current?.inboundDispatches ?? [],
395
+ };
396
+ });
397
+ if (!pending?.clientMsgId) throw new Error("Unable to persist Slack root post intent");
398
+ if (pending.state === "active") {
399
+ if (pending.endpointGeneration !== generation) throw new SlackEndpointBindingError();
400
+ return { conversation: pending, created: false };
401
+ }
402
+ if (!claimed)
403
+ return { conversation: await this.#waitForRoot(pendingKey, sessionId, body, generation), created: false };
404
+ if (pending.endpointGeneration !== generation) throw new SlackEndpointBindingError();
405
+ const clientMsgId = pending.clientMsgId;
406
+ const fence = pending.rootPublicationFence;
407
+ if (fence === undefined) throw new Error("Unable to fence Slack root post intent");
408
+ let posted: SlackPostedMessage | null = null;
409
+ let createdRootEffect = false;
410
+ try {
411
+ await this.#withRootLease(pendingKey, clientMsgId, fence, async () => {
412
+ const effectId = `root:${sessionId}:${clientMsgId}`;
413
+ const durable = await this.#providerEffectPayload(effectId, sessionId, generation, {
414
+ channel: this.options.channelId,
415
+ text: body,
416
+ clientMsgId,
417
+ });
418
+ createdRootEffect = !durable.existed;
419
+ posted = await this.#postDurable(effectId, sessionId, generation, durable.payload);
420
+ });
421
+ } catch (error) {
422
+ if (this.#isUncertainPostFailure(error)) {
423
+ // The journal retains the stable root effect for fenced reconciliation.
424
+ }
425
+ if (!posted) {
426
+ if (!this.#isUncertainPostFailure(error)) {
427
+ await this.store.transact(pendingKey, current =>
428
+ current?.clientMsgId === clientMsgId &&
429
+ current.rootPublicationOwner === this.#publicationOwnerId &&
430
+ current.rootPublicationFence === fence
431
+ ? nextRecord(current, {
432
+ state: "error",
433
+ rootPublicationOwner: undefined,
434
+ rootPublicationLeaseExpiresAt: undefined,
435
+ updatedAt: this.#now(),
436
+ lastError: "provider_failure",
437
+ })
438
+ : current,
439
+ );
440
+ }
441
+ throw error;
442
+ }
443
+ }
444
+ if (!posted) throw new Error("Slack root post was not confirmed");
445
+ const confirmedPosted = posted as SlackPostedMessage;
446
+ // A confirmed receipt remains authoritative even if the endpoint rolled after
447
+ // dispatch; resume performs any required replacement after reconciliation.
448
+ const active = await this.store.transact(pendingKey, current => {
449
+ if (
450
+ !current ||
451
+ current.clientMsgId !== clientMsgId ||
452
+ current.rootPublicationOwner !== this.#publicationOwnerId ||
453
+ current.rootPublicationFence !== fence
454
+ )
455
+ return current;
456
+ return nextRecord(current, {
457
+ state: "active",
458
+ rootTs: confirmedPosted.ts,
459
+ endpointGeneration: generation,
460
+ updatedAt: this.#now(),
461
+ lastError: undefined,
462
+ rootPublicationOwner: undefined,
463
+ rootPublicationLeaseExpiresAt: undefined,
464
+ });
465
+ });
466
+ if (!active) throw new Error("Slack root mapping disappeared");
467
+ if (active.state !== "active")
468
+ return { conversation: await this.#waitForRoot(pendingKey, sessionId, body, generation), created: false };
469
+ return { conversation: active, created: createdRootEffect };
470
+ }
471
+
472
+ /** Deliver a notification into the mapped root thread, creating that root once. */
473
+ async notify(
474
+ sessionId: string,
475
+ body: string,
476
+ actionId?: string,
477
+ endpointGeneration?: number,
478
+ ): Promise<SlackConversation> {
479
+ const endpoint = await this.#resolveEndpoint(sessionId);
480
+ if (!endpoint || (endpointGeneration !== undefined && endpoint.generation !== endpointGeneration))
481
+ throw new SlackEndpointBindingError("Slack notification requires the current session endpoint.");
482
+ const generation = endpoint.generation;
483
+ const existing = await this.findSession(sessionId, false);
484
+ const usedExistingRoot =
485
+ existing?.record.state === "active" &&
486
+ !!existing.record.rootTs &&
487
+ existing.record.endpointGeneration === generation;
488
+ let conversation: SlackConversation;
489
+ let bodyWasUsedAsRoot = false;
490
+ if (usedExistingRoot) {
491
+ conversation = existing.record;
492
+ } else {
493
+ const publication =
494
+ existing?.record.state === "active"
495
+ ? await this.#resumeWithRootPublication(sessionId, body, generation)
496
+ : await this.#postRoot(sessionId, body, generation);
497
+ conversation = publication.conversation;
498
+ bodyWasUsedAsRoot = publication.created;
499
+ }
500
+ if (!conversation.rootTs) return conversation;
501
+ const key = this.#intentKey(sessionId);
502
+ const conversationGeneration = this.#requireEndpointGeneration(conversation);
503
+ if (conversationGeneration !== generation) throw new SlackEndpointBindingError();
504
+ if (bodyWasUsedAsRoot) {
505
+ if (!actionId) return conversation;
506
+ const active = await this.store.transact(key, current =>
507
+ current && acceptsSlackInbound(current, conversation.rootTs!, conversationGeneration)
508
+ ? nextRecord(current, { pendingActionId: actionId, updatedAt: this.#now() })
509
+ : current,
510
+ );
511
+ if (!active) throw new Error("Slack root disappeared while activating action authority");
512
+ return active;
513
+ }
514
+ if (!actionId) {
515
+ await this.#postDurable(`notification:${sessionId}:${this.#randomId()}`, sessionId, conversationGeneration, {
516
+ channel: conversation.channelId,
517
+ threadTs: conversation.rootTs,
518
+ text: body,
519
+ clientMsgId: this.#randomId(),
520
+ });
521
+ return conversation;
522
+ }
523
+ let actionClaimed = false;
524
+ const intent = await this.store.transact(key, current => {
525
+ if (!current || !acceptsSlackInbound(current, conversation.rootTs!, conversationGeneration)) return current;
526
+ const now = this.#now();
527
+ if (current.outboundActionId && current.outboundActionId !== actionId) return current;
528
+ if (current.outboundActionId === actionId) {
529
+ if (
530
+ current.outboundActionOwner === this.#publicationOwnerId ||
531
+ !this.#leaseExpired(current.outboundActionLeaseExpiresAt, now)
532
+ )
533
+ return current;
534
+ actionClaimed = true;
535
+ return nextRecord(current, {
536
+ outboundActionOwner: this.#publicationOwnerId,
537
+ outboundActionLeaseExpiresAt: now + this.#publicationLeaseMs,
538
+ outboundActionFence: (current.outboundActionFence ?? 0) + 1,
539
+ updatedAt: now,
540
+ });
541
+ }
542
+ actionClaimed = true;
543
+ return nextRecord(current, {
544
+ outboundActionId: actionId,
545
+ outboundActionClientMsgId: this.#randomId(),
546
+ outboundActionOwner: this.#publicationOwnerId,
547
+ outboundActionLeaseExpiresAt: now + this.#publicationLeaseMs,
548
+ outboundActionFence: (current.outboundActionFence ?? 0) + 1,
549
+ updatedAt: now,
550
+ });
551
+ });
552
+ if (!intent || intent.outboundActionId !== actionId || !intent.outboundActionClientMsgId) {
553
+ throw new Error("Another Slack action publication is pending");
554
+ }
555
+ if (!actionClaimed) return await this.#waitForAction(key, sessionId, body, actionId);
556
+ const clientMsgId = intent.outboundActionClientMsgId;
557
+ const fence = intent.outboundActionFence;
558
+ if (fence === undefined) throw new Error("Unable to fence Slack action post intent");
559
+ let published: SlackPostedMessage | null = null;
560
+ try {
561
+ await this.#withActionLease(key, clientMsgId, fence, async () => {
562
+ const effectId = `action:${sessionId}:${actionId}:${clientMsgId}`;
563
+ const durable = await this.#providerEffectPayload(effectId, sessionId, conversationGeneration, {
564
+ channel: conversation.channelId,
565
+ threadTs: conversation.rootTs,
566
+ text: body,
567
+ clientMsgId,
568
+ });
569
+ published = await this.#postDurable(effectId, sessionId, conversationGeneration, durable.payload);
570
+ });
571
+ } catch (error) {
572
+ if (this.#isUncertainPostFailure(error)) {
573
+ // The journal retains the stable action effect for fenced reconciliation.
574
+ }
575
+ if (!published) {
576
+ if (!this.#isUncertainPostFailure(error)) {
577
+ await this.store.transact(key, current =>
578
+ current?.outboundActionClientMsgId === clientMsgId &&
579
+ current.outboundActionOwner === this.#publicationOwnerId &&
580
+ current.outboundActionFence === fence
581
+ ? nextRecord(current, {
582
+ outboundActionId: undefined,
583
+ outboundActionClientMsgId: undefined,
584
+ outboundActionOwner: undefined,
585
+ outboundActionLeaseExpiresAt: undefined,
586
+ updatedAt: this.#now(),
587
+ })
588
+ : current,
589
+ );
590
+ }
591
+ throw error;
592
+ }
593
+ }
594
+ const active = await this.store.transact(key, current =>
595
+ current?.outboundActionClientMsgId === clientMsgId &&
596
+ current.outboundActionOwner === this.#publicationOwnerId &&
597
+ current.outboundActionFence === fence
598
+ ? nextRecord(current, {
599
+ pendingActionId: actionId,
600
+ outboundActionId: undefined,
601
+ outboundActionClientMsgId: undefined,
602
+ outboundActionOwner: undefined,
603
+ outboundActionLeaseExpiresAt: undefined,
604
+ updatedAt: this.#now(),
605
+ })
606
+ : current,
607
+ );
608
+ if (!active) throw new Error("Slack conversation disappeared while activating action authority");
609
+ return active;
610
+ }
611
+
612
+ /** Posts a safe command outcome to the active mapped root thread. */
613
+ async postCommandResult(sessionId: string, content: string): Promise<boolean> {
614
+ const found = await this.findSession(sessionId, false);
615
+ if (found?.record.state !== "active" || !found.record.rootTs) return false;
616
+ const generation = this.#requireEndpointGeneration(found.record);
617
+ await this.#postDurable(`command-result:${sessionId}:${this.#randomId()}`, sessionId, generation, {
618
+ channel: found.record.channelId,
619
+ threadTs: found.record.rootTs,
620
+ text: content,
621
+ clientMsgId: this.#randomId(),
622
+ });
623
+ return true;
624
+ }
625
+
626
+ async resolveAction(sessionId: string, actionId: string): Promise<void> {
627
+ const found = await this.findSession(sessionId, true);
628
+ if (!found) return;
629
+ await this.store.transact(found.key, current =>
630
+ current?.pendingActionId === actionId
631
+ ? nextRecord(current, { pendingActionId: undefined, updatedAt: this.#now() })
632
+ : current,
633
+ );
634
+ }
635
+
636
+ async close(sessionId: string, marker = "Session closed."): Promise<boolean> {
637
+ const found = await this.findSession(sessionId, true);
638
+ if (!found?.record.rootTs || found.record.state !== "active") return false;
639
+ const effectId = `close-marker:${sessionId}:${found.record.clientMsgId ?? found.record.rootTs}`;
640
+ const existing = await this.#journal.read<{
641
+ channel: string;
642
+ text: string;
643
+ threadTs?: string;
644
+ clientMsgId: string;
645
+ }>(effectId);
646
+ const durable = await this.#providerEffectPayload(
647
+ effectId,
648
+ sessionId,
649
+ this.#requireEndpointGeneration(found.record),
650
+ {
651
+ channel: found.record.channelId,
652
+ threadTs: found.record.rootTs,
653
+ text: marker,
654
+ clientMsgId: existing?.payload.clientMsgId ?? this.#randomId(),
655
+ },
656
+ );
657
+ await this.#postDurable(effectId, sessionId, this.#requireEndpointGeneration(found.record), durable.payload);
658
+ await this.store.transact(found.key, current =>
659
+ current
660
+ ? nextRecord(current, { state: "closed_marker", pendingActionId: undefined, updatedAt: this.#now() })
661
+ : current,
662
+ );
663
+
664
+ return true;
665
+ }
666
+
667
+ async resume(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackConversation> {
668
+ return (await this.#resumeWithRootPublication(sessionId, body, endpointGeneration)).conversation;
669
+ }
670
+
671
+ async #resumeWithRootPublication(
672
+ sessionId: string,
673
+ body: string,
674
+ endpointGeneration?: number,
675
+ ): Promise<SlackRootPublication> {
676
+ const inFlight = this.#rollovers.get(sessionId);
677
+ if (inFlight) {
678
+ const publication = await inFlight;
679
+ const activeGeneration = this.#requireEndpointGeneration(publication.conversation);
680
+ if (endpointGeneration === undefined || endpointGeneration === activeGeneration)
681
+ return { conversation: publication.conversation, created: false };
682
+ if (endpointGeneration < activeGeneration)
683
+ throw new SlackEndpointBindingError("Slack root belongs to a newer endpoint generation.");
684
+ return await this.#resumeWithRootPublication(sessionId, body, endpointGeneration);
685
+ }
686
+ const rollover = this.#resumeRoot(sessionId, body, endpointGeneration);
687
+ this.#rollovers.set(sessionId, rollover);
688
+ try {
689
+ return await rollover;
690
+ } finally {
691
+ if (this.#rollovers.get(sessionId) === rollover) this.#rollovers.delete(sessionId);
692
+ }
693
+ }
694
+
695
+ async #resumeRoot(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackRootPublication> {
696
+ const endpoint = await this.#resolveEndpoint(sessionId);
697
+ if (!endpoint || (endpointGeneration !== undefined && endpoint.generation !== endpointGeneration))
698
+ throw new SlackEndpointBindingError("Slack root rollover requires the current session endpoint.");
699
+ const generation = endpoint.generation;
700
+ const forced = endpointGeneration === undefined;
701
+ let waitedForRollover = false;
702
+ for (let attempt = 0; attempt < 100; attempt++) {
703
+ let previous = await this.findSession(sessionId, true);
704
+ while (previous?.record.state === "posting_root") {
705
+ const reconciled = await this.#waitForRootReconciliation(previous.key, previous.record);
706
+ if (!reconciled) throw new Error("Slack root mapping disappeared during reconciliation.");
707
+ previous = { key: previous.key, record: reconciled };
708
+ waitedForRollover = true;
709
+ }
710
+ if (!previous) return await this.#postRoot(sessionId, body, generation);
711
+ if (previous.record.state !== "active") {
712
+ if (
713
+ previous.record.rootPublicationOwner !== this.#publicationOwnerId &&
714
+ !this.#leaseExpired(previous.record.rootPublicationLeaseExpiresAt, this.#now())
715
+ ) {
716
+ waitedForRollover = true;
717
+ await Bun.sleep(10);
718
+ continue;
719
+ }
720
+ return await this.#postRoot(sessionId, body, generation);
721
+ }
722
+ const previousGeneration = this.#requireEndpointGeneration(previous.record);
723
+ if (previousGeneration > generation)
724
+ throw new SlackEndpointBindingError("Slack root belongs to a newer endpoint generation.");
725
+ if (previousGeneration === generation && (!forced || waitedForRollover))
726
+ return { conversation: previous.record, created: false };
727
+
728
+ let claimed = false;
729
+ let fence: number | undefined;
730
+ const locked = await this.store.transact(previous.key, current => {
731
+ if (
732
+ current?.state !== "active" ||
733
+ current.rootTs !== previous!.record.rootTs ||
734
+ current.endpointGeneration !== previousGeneration
735
+ )
736
+ return current;
737
+ const now = this.#now();
738
+ if (
739
+ current.rootPublicationOwner === this.#publicationOwnerId ||
740
+ !this.#leaseExpired(current.rootPublicationLeaseExpiresAt, now)
741
+ )
742
+ return current;
743
+ claimed = true;
744
+ fence = (current.rootPublicationFence ?? 0) + 1;
745
+ return nextRecord(current, {
746
+ rootPublicationOwner: this.#publicationOwnerId,
747
+ rootPublicationLeaseExpiresAt: now + this.#publicationLeaseMs,
748
+ rootPublicationFence: fence,
749
+ updatedAt: now,
750
+ });
751
+ });
752
+ if (!locked) throw new Error("Slack root mapping disappeared during rollover.");
753
+ if (!claimed || fence === undefined) {
754
+ waitedForRollover = true;
755
+ await Bun.sleep(10);
756
+ continue;
757
+ }
758
+ let closed = false;
759
+ try {
760
+ closed = await this.close(sessionId);
761
+ } catch (error) {
762
+ if (!(error instanceof SlackStaleEffectError)) throw error;
763
+ // The old-generation close marker was deliberately suppressed after
764
+ // reconciliation; this fenced rollover still owns the state transition.
765
+ closed = true;
766
+ }
767
+ const resumed = await this.store.transact(previous.key, current =>
768
+ closed &&
769
+ current &&
770
+ (current.state === "closed_marker" || current.state === "active") &&
771
+ current.rootPublicationOwner === this.#publicationOwnerId &&
772
+ current.rootPublicationFence === fence
773
+ ? nextRecord(current, { state: "resumed_root", pendingActionId: undefined, updatedAt: this.#now() })
774
+ : current,
775
+ );
776
+ if (
777
+ resumed?.state === "resumed_root" &&
778
+ resumed.rootPublicationOwner === this.#publicationOwnerId &&
779
+ resumed.rootPublicationFence === fence
780
+ )
781
+ return await this.#postRoot(sessionId, body, generation);
782
+ waitedForRollover = true;
783
+ await Bun.sleep(10);
784
+ }
785
+ throw new Error("Slack root rollover is still pending");
786
+ }
787
+
788
+ async #claimInbound(
789
+ inbound: {
790
+ eventId: string;
791
+ eventContext?: string;
792
+ teamId: string;
793
+ channelId: string;
794
+ rootTs: string;
795
+ event: SlackEvent;
796
+ },
797
+ actorId: string,
798
+ ): Promise<
799
+ { key: string; endpoint: SlackEndpoint; sessionId: string; receipt: SlackInboundDispatchReceipt } | undefined
800
+ > {
801
+ const document = await this.store.load();
802
+ const matched = Object.entries(document.conversations)
803
+ .map(([mappingKey, candidate]) => ({ mappingKey, candidate }))
804
+ .filter(
805
+ ({ candidate }) =>
806
+ candidate.teamId === inbound.teamId &&
807
+ candidate.channelId === inbound.channelId &&
808
+ candidate.rootTs === inbound.rootTs,
809
+ )
810
+ .sort(
811
+ (left, right) =>
812
+ (right.candidate.endpointGeneration ?? -1) - (left.candidate.endpointGeneration ?? -1) ||
813
+ right.candidate.generation - left.candidate.generation ||
814
+ right.candidate.updatedAt - left.candidate.updatedAt,
815
+ )[0];
816
+ if (!matched) return undefined;
817
+ const { mappingKey: key, candidate: record } = matched;
818
+ if (
819
+ !record.sessionId ||
820
+ !record.endpointGeneration ||
821
+ !acceptsSlackInbound(record, inbound.rootTs, record.endpointGeneration)
822
+ )
823
+ return undefined;
824
+ const endpoint = await this.#resolveEndpoint(record.sessionId);
825
+ if (!endpoint || endpoint.generation !== record.endpointGeneration) return undefined;
826
+ const interactionId = text(inbound.event.client_msg_id) ?? inbound.eventId;
827
+ const retryKey = `${inbound.eventId}:${interactionId}`;
828
+ const inboundText = text(inbound.event.text);
829
+ const command = inboundText?.startsWith("/sdk ") ?? false;
830
+
831
+ const idempotencyKey = `slack:${inbound.teamId}:${inbound.channelId}:${inbound.rootTs}:${actorId}:${inbound.eventId}:${interactionId}`;
832
+ const effectId = `inbound:${inbound.teamId}:${inbound.channelId}:${inbound.rootTs}:${actorId}:${inbound.eventId}:${interactionId}`;
833
+ const routing: SlackInboundRouting = {
834
+ teamId: inbound.teamId,
835
+ channelId: inbound.channelId,
836
+ rootTs: inbound.rootTs,
837
+ eventId: inbound.eventId,
838
+ interactionId,
839
+ actorId,
840
+ retryKey,
841
+ eventContext: inbound.eventContext,
842
+ kind: command ? "command" : "action",
843
+ ...(command ? {} : record.pendingActionId ? { actionId: record.pendingActionId } : {}),
844
+ };
845
+ const initialPayload: SlackInboundEffectPayload | undefined = command
846
+ ? { type: "command", content: inboundText!, idempotencyKey, routing }
847
+ : record.pendingActionId
848
+ ? { type: "reply", id: record.pendingActionId, answer: inboundText ?? "", idempotencyKey, routing }
849
+ : undefined;
850
+ const existingEffect = await this.#journal.read<SlackInboundEffectPayload>(effectId);
851
+ const payload = existingEffect?.payload ?? initialPayload;
852
+ if (!payload) return undefined;
853
+ await this.#rescheduleAfterEffectTransition(
854
+ this.#journal.enqueue({
855
+ id: effectId,
856
+ kind: command ? "sdk.inbound.command" : "sdk.inbound.reply",
857
+ transport: "slack",
858
+ sessionId: record.sessionId,
859
+ endpointGeneration: endpoint.generation,
860
+ payload,
861
+ }),
862
+ );
863
+ let sessionId: string | undefined;
864
+ let receipt: SlackInboundDispatchReceipt | undefined;
865
+ await this.store.transact(key, current => {
866
+ if (!current?.sessionId || !acceptsSlackInbound(current, inbound.rootTs, endpoint.generation)) return current;
867
+ const existing = (current.inboundDispatches ?? []).find(
868
+ candidate =>
869
+ candidate.eventId === inbound.eventId ||
870
+ candidate.interactionId === interactionId ||
871
+ candidate.retryKey === retryKey ||
872
+ (inbound.eventContext !== undefined && candidate.eventContext === inbound.eventContext),
873
+ );
874
+ if (existing) {
875
+ sessionId = current.sessionId;
876
+ receipt = existing;
877
+ return current;
878
+ }
879
+ if (
880
+ current.seenEventIds.includes(inbound.eventId) ||
881
+ current.seenInteractionIds.includes(interactionId) ||
882
+ current.seenRetryKeys.includes(retryKey) ||
883
+ (inbound.eventContext !== undefined && current.seenContextIds.includes(inbound.eventContext)) ||
884
+ (!command && !current.pendingActionId)
885
+ )
886
+ return current;
887
+ sessionId = current.sessionId;
888
+ receipt = {
889
+ key: `${inbound.eventId}:${interactionId}`,
890
+ eventId: inbound.eventId,
891
+ interactionId,
892
+ retryKey,
893
+ eventContext: inbound.eventContext,
894
+ kind: command ? "command" : "action",
895
+ ...(command ? {} : { actionId: current.pendingActionId }),
896
+ endpointGeneration: endpoint.generation,
897
+ effectId,
898
+ idempotencyKey,
899
+ };
900
+ return nextRecord(current, {
901
+ inboundDispatches: [...(current.inboundDispatches ?? []), receipt],
902
+ updatedAt: this.#now(),
903
+ });
904
+ });
905
+ return sessionId && receipt ? { key, endpoint, sessionId, receipt } : undefined;
906
+ }
907
+
908
+ async #dispatchInbound(claim: {
909
+ key: string;
910
+ endpoint: SlackEndpoint;
911
+ sessionId: string;
912
+ receipt: SlackInboundDispatchReceipt;
913
+ }): Promise<boolean> {
914
+ const current = await this.store.read(claim.key);
915
+ const effect = await this.#journal.read<SlackInboundEffectPayload>(claim.receipt.effectId);
916
+ if (effect?.state === "terminal") {
917
+ await this.#finalizeTerminalInboundDispatch(claim.key, claim.receipt);
918
+ return false;
919
+ }
920
+ if (
921
+ !current ||
922
+ !this.#mappedInboundDispatchable(current, claim.receipt, effect) ||
923
+ claim.receipt.endpointGeneration !== claim.endpoint.generation
924
+ ) {
925
+ await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_mapping");
926
+ return false;
927
+ }
928
+ return await this.#dispatchEffect(claim, claim.receipt.effectId);
929
+ }
930
+
931
+ async #dispatchEffect(
932
+ claim: { key: string; endpoint: SlackEndpoint; sessionId: string; receipt: SlackInboundDispatchReceipt },
933
+ effectId: string,
934
+ ): Promise<boolean> {
935
+ const effect = await this.#rescheduleAfterEffectTransition(
936
+ this.#journal.claim<SlackInboundEffectPayload>(
937
+ effectId,
938
+ this.#publicationOwnerId,
939
+ Math.max(this.#publicationLeaseMs, 100),
940
+ ),
941
+ );
942
+ if (!effect) return false;
943
+ if (!this.#matchesInboundEffect(effect, claim.receipt)) {
944
+ await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_mapping");
945
+ return false;
946
+ }
947
+ const lease: ChatEffectLease = { owner: this.#publicationOwnerId, epoch: effect.epoch };
948
+ try {
949
+ if (effect.payload.type === "command") {
950
+ const payload = effect.payload;
951
+ const accepted = await this.#withEffectLease(effect.id, lease, async () => {
952
+ if (!(await this.#inboundEffectCurrent(claim, effect.id))) throw new SlackStaleEffectError();
953
+ return await (this.options.onCommand?.(
954
+ claim.sessionId,
955
+ payload.content,
956
+ claim.endpoint,
957
+ payload.idempotencyKey,
958
+ ) ?? Promise.resolve(false));
959
+ });
960
+ if (!(await this.#inboundEffectCurrent(claim, effect.id))) {
961
+ await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
962
+ return false;
963
+ }
964
+ const recorded = await this.#journal.record(effect.id, lease, "terminal", {
965
+ status: accepted ? "accepted" : "rejected",
966
+ });
967
+ if (recorded) await this.#finishDispatch(claim, "terminal");
968
+ return accepted && !!recorded;
969
+ }
970
+ await this.#withEffectLease(effect.id, lease, async () => {
971
+ if (!(await this.#inboundEffectCurrent(claim, effect.id))) throw new SlackStaleEffectError();
972
+ this.options.createClient(claim.endpoint).send(effect.payload);
973
+ });
974
+ if (!(await this.#inboundEffectCurrent(claim, effect.id))) {
975
+ await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
976
+ return false;
977
+ }
978
+ const recorded = await this.#journal.record(effect.id, lease, "terminal", { status: "sent" });
979
+ if (recorded) await this.#finishDispatch(claim, "terminal");
980
+ return !!recorded;
981
+ } catch (error) {
982
+ if (error instanceof SlackStaleEffectError) {
983
+ await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
984
+ return false;
985
+ }
986
+ const state = this.#isDefiniteSdkPreSendFailure(error) ? "accepted" : "uncertain";
987
+
988
+ const recorded = await this.#rescheduleAfterEffectTransition(
989
+ this.#journal.record(effect.id, lease, state, { status: state }),
990
+ );
991
+ if (recorded && state === "accepted") await this.#releaseDispatch(claim);
992
+ else if (recorded) await this.#finishDispatch(claim, "uncertain");
993
+ return false;
994
+ }
995
+ }
996
+
997
+ async #inboundEffectCurrent(
998
+ claim: { key: string; endpoint: SlackEndpoint; sessionId: string; receipt: SlackInboundDispatchReceipt },
999
+ effectId: string,
1000
+ ): Promise<boolean> {
1001
+ const endpoint = await this.#resolveEndpoint(claim.sessionId);
1002
+ if (!endpoint || endpoint.generation !== claim.endpoint.generation) return false;
1003
+ const [current, effect] = await Promise.all([
1004
+ this.store.read(claim.key),
1005
+ this.#journal.read<SlackInboundEffectPayload>(effectId),
1006
+ ]);
1007
+ return (
1008
+ !!current &&
1009
+ !!effect &&
1010
+ current.sessionId === claim.sessionId &&
1011
+ acceptsSlackInbound(current, current.rootTs ?? "", claim.endpoint.generation) &&
1012
+ claim.receipt.endpointGeneration === claim.endpoint.generation &&
1013
+ this.#matchesInboundEffect(effect, claim.receipt) &&
1014
+ (await this.#actorAuthorized(effect.payload.routing.actorId)) &&
1015
+ (current.inboundDispatches ?? []).some(receipt => this.#sameInboundReceipt(receipt, claim.receipt))
1016
+ );
1017
+ }
1018
+
1019
+ async #withEffectLease<T>(id: string, lease: ChatEffectLease, operation: () => Promise<T>): Promise<T> {
1020
+ // The effect was just claimed, so its lease is current. The periodic renewal
1021
+ // still protects the external operation without another durable write first.
1022
+ return await this.#withRenewal(
1023
+ operation,
1024
+ async () => {
1025
+ if (
1026
+ !(await this.#rescheduleAfterEffectTransition(
1027
+ this.#journal.renew(id, lease, Math.max(this.#publicationLeaseMs, 100)),
1028
+ ))
1029
+ )
1030
+ throw new Error("Slack effect lease renewal failed");
1031
+ },
1032
+ false,
1033
+ );
1034
+ }
1035
+
1036
+ async #drainPendingDispatches(): Promise<void> {
1037
+ await this.#reconcileTerminalInboundReceipts();
1038
+ const document = await this.store.load();
1039
+ const dispatchableEffectIds = new Set<string>();
1040
+ for (const [key, record] of Object.entries(document.conversations)) {
1041
+ for (const receipt of record.inboundDispatches ?? []) {
1042
+ const effect = await this.#journal.read<SlackInboundEffectPayload>(receipt.effectId);
1043
+ if (effect?.state === "terminal") {
1044
+ await this.#finalizeTerminalInboundDispatch(key, receipt);
1045
+ continue;
1046
+ }
1047
+ if (!this.#mappedInboundDispatchable(record, receipt, effect)) {
1048
+ await this.#terminalizeStaleInboundDispatch(key, receipt, "stale_mapping");
1049
+ continue;
1050
+ }
1051
+ const endpoint = await this.#resolveEndpoint(record.sessionId!);
1052
+ if (!endpoint || endpoint.generation !== receipt.endpointGeneration) {
1053
+ await this.#terminalizeStaleInboundDispatch(key, receipt, "stale_binding");
1054
+ continue;
1055
+ }
1056
+ if (
1057
+ effect.state === "uncertain" ||
1058
+ (effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now())
1059
+ )
1060
+ continue;
1061
+ dispatchableEffectIds.add(receipt.effectId);
1062
+ const inflightKey = `${key}\u0000${receipt.key}`;
1063
+ if (this.#inflightInbound.has(inflightKey)) continue;
1064
+ this.#inflightInbound.add(inflightKey);
1065
+ try {
1066
+ await this.#dispatchInbound({ key, endpoint, sessionId: record.sessionId!, receipt });
1067
+ } finally {
1068
+ this.#inflightInbound.delete(inflightKey);
1069
+ }
1070
+ }
1071
+ }
1072
+ for (const effect of await this.#journal.list()) {
1073
+ if (
1074
+ dispatchableEffectIds.has(effect.id) ||
1075
+ effect.transport !== "slack" ||
1076
+ effect.state === "terminal" ||
1077
+ !effect.sessionId ||
1078
+ (effect.kind !== "sdk.inbound.command" && effect.kind !== "sdk.inbound.reply")
1079
+ )
1080
+ continue;
1081
+ const adopted = await this.#adoptOrphanInbound(effect as ChatEffect<SlackInboundEffectPayload>);
1082
+ if (!adopted) continue;
1083
+ const endpoint = await this.#resolveEndpoint(adopted.sessionId);
1084
+ if (
1085
+ !endpoint ||
1086
+ endpoint.generation !== adopted.receipt.endpointGeneration ||
1087
+ !(await this.#inboundEffectCurrent(
1088
+ { key: adopted.key, endpoint, sessionId: adopted.sessionId, receipt: adopted.receipt },
1089
+ effect.id,
1090
+ ))
1091
+ ) {
1092
+ await this.#terminalizeStaleInboundDispatch(adopted.key, adopted.receipt, "stale_binding");
1093
+ continue;
1094
+ }
1095
+ if (effect.state === "uncertain" || (effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now()))
1096
+ continue;
1097
+ await this.#dispatchInbound({
1098
+ key: adopted.key,
1099
+ endpoint,
1100
+ sessionId: adopted.sessionId,
1101
+ receipt: adopted.receipt,
1102
+ });
1103
+ }
1104
+ }
1105
+
1106
+ async #actorAuthorized(actorId: string): Promise<boolean> {
1107
+ const authorizeActor = this.options.authorizeActor;
1108
+ if (!authorizeActor || !text(actorId)) return false;
1109
+ try {
1110
+ return await authorizeActor(actorId);
1111
+ } catch {
1112
+ return false;
1113
+ }
1114
+ }
1115
+ #validInboundRouting(routing: SlackInboundRouting): boolean {
1116
+ return (
1117
+ routing.teamId === this.options.teamId &&
1118
+ routing.channelId === this.options.channelId &&
1119
+ text(routing.rootTs) !== undefined &&
1120
+ text(routing.actorId) !== undefined &&
1121
+ text(routing.eventId) !== undefined &&
1122
+ text(routing.interactionId) !== undefined &&
1123
+ routing.retryKey === `${routing.eventId}:${routing.interactionId}` &&
1124
+ (routing.eventContext === undefined || text(routing.eventContext) !== undefined) &&
1125
+ (routing.kind === "command" || (routing.kind === "action" && text(routing.actionId) !== undefined))
1126
+ );
1127
+ }
1128
+ #matchesInboundEffect(effect: ChatEffect<SlackInboundEffectPayload>, receipt: SlackInboundDispatchReceipt): boolean {
1129
+ const payload = effect.payload;
1130
+ const routing = payload?.routing;
1131
+ if (
1132
+ !routing ||
1133
+ !this.#validInboundRouting(routing) ||
1134
+ !effect.sessionId ||
1135
+ !Number.isSafeInteger(effect.endpointGeneration) ||
1136
+ effect.endpointGeneration <= 0
1137
+ )
1138
+ return false;
1139
+ const effectId = `inbound:${routing.teamId}:${routing.channelId}:${routing.rootTs}:${routing.actorId}:${routing.eventId}:${routing.interactionId}`;
1140
+ const idempotencyKey = `slack:${routing.teamId}:${routing.channelId}:${routing.rootTs}:${routing.actorId}:${routing.eventId}:${routing.interactionId}`;
1141
+ return (
1142
+ effect.id === effectId &&
1143
+ payload.idempotencyKey === idempotencyKey &&
1144
+ receipt.key === `${routing.eventId}:${routing.interactionId}` &&
1145
+ receipt.eventId === routing.eventId &&
1146
+ receipt.interactionId === routing.interactionId &&
1147
+ receipt.retryKey === routing.retryKey &&
1148
+ receipt.eventContext === routing.eventContext &&
1149
+ receipt.kind === routing.kind &&
1150
+ receipt.actionId === routing.actionId &&
1151
+ receipt.endpointGeneration === effect.endpointGeneration &&
1152
+ receipt.effectId === effect.id &&
1153
+ receipt.idempotencyKey === payload.idempotencyKey &&
1154
+ ((payload.type === "command" && routing.kind === "command" && effect.kind === "sdk.inbound.command") ||
1155
+ (payload.type === "reply" &&
1156
+ routing.kind === "action" &&
1157
+ effect.kind === "sdk.inbound.reply" &&
1158
+ payload.id === routing.actionId))
1159
+ );
1160
+ }
1161
+ #sameInboundReceipt(left: SlackInboundDispatchReceipt, right: SlackInboundDispatchReceipt): boolean {
1162
+ return (
1163
+ left.key === right.key &&
1164
+ left.eventId === right.eventId &&
1165
+ left.interactionId === right.interactionId &&
1166
+ left.retryKey === right.retryKey &&
1167
+ left.eventContext === right.eventContext &&
1168
+ left.kind === right.kind &&
1169
+ left.actionId === right.actionId &&
1170
+ left.endpointGeneration === right.endpointGeneration &&
1171
+ left.effectId === right.effectId &&
1172
+ left.idempotencyKey === right.idempotencyKey
1173
+ );
1174
+ }
1175
+ #mappedInboundDispatchable(
1176
+ record: SlackConversation,
1177
+ receipt: SlackInboundDispatchReceipt,
1178
+ effect: ChatEffect<SlackInboundEffectPayload> | undefined,
1179
+ ): effect is ChatEffect<SlackInboundEffectPayload> {
1180
+ return (
1181
+ !!effect &&
1182
+ record.state === "active" &&
1183
+ !!record.sessionId &&
1184
+ !!record.rootTs &&
1185
+ acceptsSlackInbound(record, record.rootTs, receipt.endpointGeneration) &&
1186
+ record.sessionId === effect.sessionId &&
1187
+ this.#matchesInboundEffect(effect, receipt)
1188
+ );
1189
+ }
1190
+ async #reconcileTerminalInboundReceipts(): Promise<void> {
1191
+ for (const effect of await this.#journal.list()) {
1192
+ if (
1193
+ effect.transport !== "slack" ||
1194
+ effect.state !== "terminal" ||
1195
+ (effect.kind !== "sdk.inbound.command" && effect.kind !== "sdk.inbound.reply")
1196
+ )
1197
+ continue;
1198
+ const payload = effect.payload as SlackInboundEffectPayload;
1199
+ if (!payload?.routing || !this.#validInboundRouting(payload.routing)) continue;
1200
+ const key = slackConversationKey({
1201
+ teamId: payload.routing.teamId,
1202
+ channelId: payload.routing.channelId,
1203
+ rootTs: payload.routing.rootTs,
1204
+ });
1205
+ const current = await this.store.read(key);
1206
+ let receipt: SlackInboundDispatchReceipt | undefined;
1207
+ if (current && current.sessionId === effect.sessionId) {
1208
+ receipt = current.inboundDispatches?.find(candidate =>
1209
+ this.#matchesInboundEffect(effect as ChatEffect<SlackInboundEffectPayload>, candidate),
1210
+ );
1211
+ }
1212
+ if (receipt) await this.#finalizeTerminalInboundDispatch(key, receipt);
1213
+ }
1214
+ }
1215
+ async #terminalizeStaleInboundDispatch(
1216
+ key: string,
1217
+ receipt: SlackInboundDispatchReceipt,
1218
+ status: "stale_binding" | "stale_mapping",
1219
+ ): Promise<void> {
1220
+ await this.#journal.terminalize(receipt.effectId, { status });
1221
+ await this.#finalizeTerminalInboundDispatch(key, receipt);
1222
+ }
1223
+ async #finalizeTerminalInboundDispatch(key: string, receipt: SlackInboundDispatchReceipt): Promise<void> {
1224
+ await this.store.transact(key, current => {
1225
+ const found = current?.inboundDispatches?.find(candidate => this.#sameInboundReceipt(candidate, receipt));
1226
+ return !current || !found ? current : this.#completeInboundDispatch(current, found, true);
1227
+ });
1228
+ }
1229
+ #completeInboundDispatch(
1230
+ current: SlackConversation,
1231
+ receipt: SlackInboundDispatchReceipt,
1232
+ terminal: boolean,
1233
+ ): SlackConversation {
1234
+ return nextRecord(current, {
1235
+ pendingActionId:
1236
+ receipt.kind === "action" && current.pendingActionId === receipt.actionId
1237
+ ? undefined
1238
+ : current.pendingActionId,
1239
+ seenEventIds: [...current.seenEventIds, receipt.eventId],
1240
+ seenInteractionIds: [...current.seenInteractionIds, receipt.interactionId],
1241
+ seenRetryKeys: [...current.seenRetryKeys, receipt.retryKey],
1242
+ seenContextIds:
1243
+ receipt.eventContext === undefined
1244
+ ? current.seenContextIds
1245
+ : [...current.seenContextIds, receipt.eventContext],
1246
+ inboundDispatches: terminal
1247
+ ? (current.inboundDispatches ?? []).filter(candidate => !this.#sameInboundReceipt(candidate, receipt))
1248
+ : current.inboundDispatches,
1249
+ updatedAt: this.#now(),
1250
+ });
1251
+ }
1252
+ async #adoptOrphanInbound(
1253
+ effect: ChatEffect<SlackInboundEffectPayload>,
1254
+ ): Promise<{ key: string; sessionId: string; receipt: SlackInboundDispatchReceipt } | undefined> {
1255
+ const payload = effect.payload;
1256
+ const routing = payload?.routing;
1257
+ if (!routing || !this.#validInboundRouting(routing) || !(await this.#actorAuthorized(routing.actorId))) {
1258
+ await this.#terminalizeRejectedInbound(effect.id);
1259
+ return undefined;
1260
+ }
1261
+ const matches = Object.entries((await this.store.load()).conversations).filter(
1262
+ ([, record]) =>
1263
+ record.teamId === routing.teamId &&
1264
+ record.channelId === routing.channelId &&
1265
+ record.rootTs === routing.rootTs,
1266
+ );
1267
+ if (matches.length !== 1) {
1268
+ await this.#terminalizeRejectedInbound(effect.id);
1269
+ return undefined;
1270
+ }
1271
+ const key = matches[0]![0];
1272
+ const expectedEffectId = `inbound:${routing.teamId}:${routing.channelId}:${routing.rootTs}:${routing.actorId}:${routing.eventId}:${routing.interactionId}`;
1273
+ const expectedIdempotencyKey = `slack:${routing.teamId}:${routing.channelId}:${routing.rootTs}:${routing.actorId}:${routing.eventId}:${routing.interactionId}`;
1274
+ const validPayload =
1275
+ effect.transport === "slack" &&
1276
+ !!effect.sessionId &&
1277
+ Number.isSafeInteger(effect.endpointGeneration) &&
1278
+ effect.endpointGeneration > 0 &&
1279
+ effect.id === expectedEffectId &&
1280
+ payload.idempotencyKey === expectedIdempotencyKey &&
1281
+ ((payload.type === "command" && routing.kind === "command" && effect.kind === "sdk.inbound.command") ||
1282
+ (payload.type === "reply" &&
1283
+ routing.kind === "action" &&
1284
+ effect.kind === "sdk.inbound.reply" &&
1285
+ payload.id === routing.actionId));
1286
+ let receipt: SlackInboundDispatchReceipt | undefined;
1287
+ await this.store.transact(key, current => {
1288
+ if (
1289
+ !validPayload ||
1290
+ !current ||
1291
+ !effect.sessionId ||
1292
+ current.sessionId !== effect.sessionId ||
1293
+ !acceptsSlackInbound(current, routing.rootTs, effect.endpointGeneration)
1294
+ )
1295
+ return current;
1296
+ const existing = (current.inboundDispatches ?? []).find(
1297
+ candidate =>
1298
+ candidate.eventId === routing.eventId ||
1299
+ candidate.interactionId === routing.interactionId ||
1300
+ candidate.retryKey === routing.retryKey ||
1301
+ (routing.eventContext !== undefined && candidate.eventContext === routing.eventContext),
1302
+ );
1303
+ if (existing) {
1304
+ if (this.#matchesInboundEffect(effect, existing)) receipt = existing;
1305
+ return current;
1306
+ }
1307
+ if (
1308
+ current.seenEventIds.includes(routing.eventId) ||
1309
+ current.seenInteractionIds.includes(routing.interactionId) ||
1310
+ current.seenRetryKeys.includes(routing.retryKey) ||
1311
+ (routing.eventContext !== undefined && current.seenContextIds.includes(routing.eventContext)) ||
1312
+ (routing.kind === "action" && current.pendingActionId !== routing.actionId)
1313
+ )
1314
+ return current;
1315
+ receipt = {
1316
+ key: `${routing.eventId}:${routing.interactionId}`,
1317
+ eventId: routing.eventId,
1318
+ interactionId: routing.interactionId,
1319
+ retryKey: routing.retryKey,
1320
+ eventContext: routing.eventContext,
1321
+ kind: routing.kind,
1322
+ ...(routing.kind === "action" ? { actionId: routing.actionId } : {}),
1323
+ endpointGeneration: effect.endpointGeneration,
1324
+ effectId: effect.id,
1325
+ idempotencyKey: payload.idempotencyKey,
1326
+ };
1327
+ return nextRecord(current, {
1328
+ inboundDispatches: [...(current.inboundDispatches ?? []), receipt],
1329
+ updatedAt: this.#now(),
1330
+ });
1331
+ });
1332
+ if (receipt && effect.sessionId) return { key, sessionId: effect.sessionId, receipt };
1333
+ await this.#terminalizeRejectedInbound(effect.id);
1334
+ return undefined;
1335
+ }
1336
+
1337
+ async #terminalizeRejectedInbound(effectId: string): Promise<void> {
1338
+ await this.#journal.terminalize(effectId, { status: "rejected" });
1339
+ }
1340
+
1341
+ async #finishDispatch(
1342
+ claim: { key: string; endpoint: SlackEndpoint; receipt: SlackInboundDispatchReceipt },
1343
+ state: "terminal" | "uncertain",
1344
+ ): Promise<void> {
1345
+ await this.store.transact(claim.key, current => {
1346
+ if (!current || !acceptsSlackInbound(current, current.rootTs ?? "", claim.endpoint.generation)) return current;
1347
+ const found = (current.inboundDispatches ?? []).find(receipt =>
1348
+ this.#sameInboundReceipt(receipt, claim.receipt),
1349
+ );
1350
+ return found ? this.#completeInboundDispatch(current, found, state === "terminal") : current;
1351
+ });
1352
+ }
1353
+
1354
+ async #releaseDispatch(claim: {
1355
+ key: string;
1356
+ endpoint: SlackEndpoint;
1357
+ receipt: SlackInboundDispatchReceipt;
1358
+ }): Promise<void> {
1359
+ await this.store.transact(claim.key, current => {
1360
+ if (!current || !acceptsSlackInbound(current, current.rootTs ?? "", claim.endpoint.generation)) return current;
1361
+ const found = (current.inboundDispatches ?? []).find(receipt =>
1362
+ this.#sameInboundReceipt(receipt, claim.receipt),
1363
+ );
1364
+ return found
1365
+ ? nextRecord(current, {
1366
+ inboundDispatches: (current.inboundDispatches ?? []).filter(
1367
+ candidate => !this.#sameInboundReceipt(candidate, found),
1368
+ ),
1369
+ updatedAt: this.#now(),
1370
+ })
1371
+ : current;
1372
+ });
1373
+ }
1374
+
1375
+ async #reconcileTerminalProviderReceipts(): Promise<void> {
1376
+ for (const effect of await this.#journal.list()) {
1377
+ try {
1378
+ if (effect.kind !== "provider-post" || effect.transport !== "slack" || effect.state !== "terminal")
1379
+ continue;
1380
+ const payload = effect.payload as { channel?: unknown; threadTs?: unknown; clientMsgId?: unknown };
1381
+ const receipt = effect.receipt;
1382
+ if (
1383
+ receipt?.provider !== "slack" ||
1384
+ typeof payload.clientMsgId !== "string" ||
1385
+ (receipt.status !== "posted" && receipt.status !== "not_found") ||
1386
+ (receipt.status === "posted" && typeof receipt.timestamp !== "string")
1387
+ )
1388
+ continue;
1389
+ const normalized = {
1390
+ channel: payload.channel,
1391
+ threadTs: payload.threadTs,
1392
+ clientMsgId: payload.clientMsgId,
1393
+ };
1394
+ if (typeof normalized.threadTs === "string") {
1395
+ if (receipt.status === "posted") await this.#activateReconciledAction(effect, normalized);
1396
+ else await this.#releaseUnreconciledAction(effect, normalized);
1397
+ } else if (receipt.status === "posted") {
1398
+ await this.#activateReconciledRoot(effect, normalized, receipt.timestamp!);
1399
+ } else {
1400
+ await this.#releaseUnreconciledRoot(effect, normalized);
1401
+ }
1402
+ } catch {
1403
+ await this.#recordRecoveryFailure(effect);
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ async #activateReconciledRoot(
1409
+ effect: ChatEffect,
1410
+ payload: { channel?: unknown; clientMsgId: string },
1411
+ rootTs: string,
1412
+ ): Promise<void> {
1413
+ if (
1414
+ !effect.sessionId ||
1415
+ typeof payload.channel !== "string" ||
1416
+ payload.channel !== this.options.channelId ||
1417
+ !Number.isSafeInteger(effect.endpointGeneration) ||
1418
+ effect.endpointGeneration <= 0
1419
+ )
1420
+ return;
1421
+ // Receipt recovery must retain the original mapping through an endpoint roll;
1422
+ // inbound routing still fences it against the now-current endpoint generation.
1423
+ await this.store.transact(this.#intentKey(effect.sessionId), current =>
1424
+ current &&
1425
+ current.state === "posting_root" &&
1426
+ current.sessionId === effect.sessionId &&
1427
+ current.teamId === this.options.teamId &&
1428
+ current.channelId === payload.channel &&
1429
+ current.clientMsgId === payload.clientMsgId &&
1430
+ current.endpointGeneration === effect.endpointGeneration
1431
+ ? nextRecord(current, {
1432
+ state: "active",
1433
+ rootTs,
1434
+ endpointGeneration: effect.endpointGeneration,
1435
+ rootPublicationOwner: undefined,
1436
+ rootPublicationLeaseExpiresAt: undefined,
1437
+ lastError: undefined,
1438
+ updatedAt: this.#now(),
1439
+ })
1440
+ : current,
1441
+ );
1442
+ }
1443
+
1444
+ async #activateReconciledAction(
1445
+ effect: ChatEffect,
1446
+ payload: { channel?: unknown; threadTs?: unknown; clientMsgId: string },
1447
+ ): Promise<void> {
1448
+ if (
1449
+ !effect.sessionId ||
1450
+ typeof payload.channel !== "string" ||
1451
+ typeof payload.threadTs !== "string" ||
1452
+ !Number.isSafeInteger(effect.endpointGeneration) ||
1453
+ effect.endpointGeneration <= 0
1454
+ )
1455
+ return;
1456
+ const threadTs = payload.threadTs;
1457
+ const document = await this.store.load();
1458
+ const found = Object.entries(document.conversations)
1459
+ .map(([key, record]) => ({ key, record }))
1460
+ .filter(
1461
+ ({ record }) =>
1462
+ record.state === "active" &&
1463
+ record.sessionId === effect.sessionId &&
1464
+ record.teamId === this.options.teamId &&
1465
+ record.channelId === payload.channel &&
1466
+ record.rootTs === threadTs &&
1467
+ record.endpointGeneration === effect.endpointGeneration &&
1468
+ record.outboundActionClientMsgId === payload.clientMsgId &&
1469
+ typeof record.outboundActionId === "string",
1470
+ )[0];
1471
+ if (!found) return;
1472
+ await this.store.transact(found.key, current =>
1473
+ current &&
1474
+ acceptsSlackInbound(current, threadTs, effect.endpointGeneration) &&
1475
+ current.outboundActionClientMsgId === payload.clientMsgId &&
1476
+ current.outboundActionId === found.record.outboundActionId
1477
+ ? nextRecord(current, {
1478
+ pendingActionId: current.outboundActionId,
1479
+ outboundActionId: undefined,
1480
+ outboundActionClientMsgId: undefined,
1481
+ outboundActionOwner: undefined,
1482
+ outboundActionLeaseExpiresAt: undefined,
1483
+ updatedAt: this.#now(),
1484
+ })
1485
+ : current,
1486
+ );
1487
+ }
1488
+ async #releaseUnreconciledRoot(
1489
+ effect: ChatEffect,
1490
+ payload: { channel?: unknown; clientMsgId: string },
1491
+ ): Promise<void> {
1492
+ if (
1493
+ !effect.sessionId ||
1494
+ typeof payload.channel !== "string" ||
1495
+ payload.channel !== this.options.channelId ||
1496
+ !Number.isSafeInteger(effect.endpointGeneration) ||
1497
+ effect.endpointGeneration <= 0
1498
+ )
1499
+ return;
1500
+ await this.store.transact(this.#intentKey(effect.sessionId), current =>
1501
+ current &&
1502
+ current.state === "posting_root" &&
1503
+ current.sessionId === effect.sessionId &&
1504
+ current.teamId === this.options.teamId &&
1505
+ current.channelId === payload.channel &&
1506
+ current.clientMsgId === payload.clientMsgId &&
1507
+ current.endpointGeneration === effect.endpointGeneration
1508
+ ? nextRecord(current, {
1509
+ state: "error",
1510
+ rootPublicationOwner: undefined,
1511
+ rootPublicationLeaseExpiresAt: undefined,
1512
+ lastError: "provider_not_found",
1513
+ updatedAt: this.#now(),
1514
+ })
1515
+ : current,
1516
+ );
1517
+ }
1518
+ async #releaseUnreconciledAction(
1519
+ effect: ChatEffect,
1520
+ payload: { channel?: unknown; threadTs?: unknown; clientMsgId: string },
1521
+ ): Promise<void> {
1522
+ if (
1523
+ !effect.sessionId ||
1524
+ typeof payload.channel !== "string" ||
1525
+ typeof payload.threadTs !== "string" ||
1526
+ !Number.isSafeInteger(effect.endpointGeneration) ||
1527
+ effect.endpointGeneration <= 0
1528
+ )
1529
+ return;
1530
+ const threadTs = payload.threadTs;
1531
+ const document = await this.store.load();
1532
+ const found = Object.entries(document.conversations).find(
1533
+ ([, record]) =>
1534
+ record.state === "active" &&
1535
+ record.sessionId === effect.sessionId &&
1536
+ record.teamId === this.options.teamId &&
1537
+ record.channelId === payload.channel &&
1538
+ record.rootTs === threadTs &&
1539
+ record.endpointGeneration === effect.endpointGeneration &&
1540
+ record.outboundActionClientMsgId === payload.clientMsgId,
1541
+ );
1542
+ if (!found) return;
1543
+ await this.store.transact(found[0], current =>
1544
+ current &&
1545
+ acceptsSlackInbound(current, threadTs, effect.endpointGeneration) &&
1546
+ current.outboundActionClientMsgId === payload.clientMsgId
1547
+ ? nextRecord(current, {
1548
+ outboundActionId: undefined,
1549
+ outboundActionClientMsgId: undefined,
1550
+ outboundActionOwner: undefined,
1551
+ outboundActionLeaseExpiresAt: undefined,
1552
+ updatedAt: this.#now(),
1553
+ })
1554
+ : current,
1555
+ );
1556
+ }
1557
+ async #recordRecoveryFailure(effect: ChatEffect): Promise<void> {
1558
+ try {
1559
+ if (!effect.sessionId) return;
1560
+ const found = await this.findSession(effect.sessionId, true);
1561
+ if (!found) return;
1562
+ await this.store.transact(found.key, current => {
1563
+ if (!current || current.sessionId !== effect.sessionId) return current;
1564
+ return nextRecord(current, { lastError: "recovery_failure", updatedAt: this.#now() });
1565
+ });
1566
+ } catch {
1567
+ // Diagnostics must never remove the durable recovery trigger.
1568
+ }
1569
+ }
1570
+
1571
+ async #drainProviderEffects(): Promise<boolean> {
1572
+ let failed = false;
1573
+ for (const effect of await this.#journal.list()) {
1574
+ try {
1575
+ if (effect.kind !== "provider-post" || effect.state === "terminal") continue;
1576
+ const current = !!effect.sessionId && (await this.#providerEffectCurrent(effect));
1577
+ if (!current && effect.state !== "uncertain" && effect.state !== "leased") {
1578
+ await this.#journal.terminalize(effect.id, { provider: "slack", status: "stale_noop" });
1579
+ continue;
1580
+ }
1581
+ if (!effect.sessionId) continue;
1582
+ const payload = effect.payload as {
1583
+ channel?: unknown;
1584
+ text?: unknown;
1585
+ threadTs?: unknown;
1586
+ clientMsgId?: unknown;
1587
+ };
1588
+ if (
1589
+ typeof payload.channel !== "string" ||
1590
+ typeof payload.text !== "string" ||
1591
+ typeof payload.clientMsgId !== "string" ||
1592
+ (payload.threadTs !== undefined && typeof payload.threadTs !== "string")
1593
+ )
1594
+ continue;
1595
+ await this.#postDurable(effect.id, effect.sessionId, effect.endpointGeneration, {
1596
+ channel: payload.channel,
1597
+ text: payload.text,
1598
+ ...(typeof payload.threadTs === "string" ? { threadTs: payload.threadTs } : {}),
1599
+ clientMsgId: payload.clientMsgId,
1600
+ });
1601
+ } catch {
1602
+ failed = true;
1603
+ await this.#recordRecoveryFailure(effect);
1604
+ }
1605
+ }
1606
+ await this.#reconcileTerminalProviderReceipts();
1607
+ return failed;
1608
+ }
1609
+ async #rescheduleAfterEffectTransition<T extends ChatEffect | undefined>(transition: Promise<T>): Promise<T> {
1610
+ const effect = await transition;
1611
+ if (effect?.state !== "terminal") await this.#scheduleLeaseRecovery();
1612
+ return effect;
1613
+ }
1614
+ async #scheduleLeaseRecovery(recoveryFailed = false): Promise<void> {
1615
+ const scheduled = this.#leaseRecoveryScheduling.then(async () => {
1616
+ await this.#scheduleLeaseRecoveryNow(recoveryFailed);
1617
+ });
1618
+ this.#leaseRecoveryScheduling = scheduled.catch(() => undefined);
1619
+ return await scheduled;
1620
+ }
1621
+ async #scheduleLeaseRecoveryNow(recoveryFailed: boolean): Promise<void> {
1622
+ if (!this.#started) return;
1623
+ const now = this.#now();
1624
+ const recoveryAt = (await this.#journal.list())
1625
+ .filter(effect => effect.transport === "slack")
1626
+ .reduce<number | undefined>(
1627
+ (earliest, effect) => {
1628
+ const claimAt =
1629
+ effect.state === "leased" && Number.isFinite(effect.leaseExpiresAt)
1630
+ ? effect.leaseExpiresAt
1631
+ : effect.state === "pending" ||
1632
+ effect.state === "accepted" ||
1633
+ (effect.state === "uncertain" && !effect.kind.includes(".inbound."))
1634
+ ? now
1635
+ : undefined;
1636
+ return claimAt === undefined || (earliest !== undefined && earliest <= claimAt) ? earliest : claimAt;
1637
+ },
1638
+ recoveryFailed ? now : undefined,
1639
+ );
1640
+ if (!this.#started) return;
1641
+ if (recoveryAt === undefined) {
1642
+ this.#clearLeaseRecoveryTimer();
1643
+ this.#leaseRecoveryFailures = 0;
1644
+ return;
1645
+ }
1646
+ if (this.#leaseRecoveryAt !== undefined && this.#leaseRecoveryAt <= recoveryAt) return;
1647
+ this.#clearLeaseRecoveryTimer();
1648
+ this.#leaseRecoveryAt = recoveryAt;
1649
+ const delay =
1650
+ recoveryAt <= now
1651
+ ? Math.min(1_000, 25 * 2 ** Math.min(this.#leaseRecoveryFailures, 5))
1652
+ : Math.min(recoveryAt - now, 2_147_483_647);
1653
+ const timerGeneration = this.#leaseRecoveryTimerGeneration;
1654
+ this.#leaseRecoveryTimer = setTimeout(() => {
1655
+ if (!this.#started || timerGeneration !== this.#leaseRecoveryTimerGeneration) return;
1656
+ this.#leaseRecoveryTimer = undefined;
1657
+ this.#leaseRecoveryAt = undefined;
1658
+ void this.#track(this.#recoverLeasedEffects());
1659
+ }, delay);
1660
+ }
1661
+ #clearLeaseRecoveryTimer(): void {
1662
+ this.#leaseRecoveryTimerGeneration++;
1663
+ if (this.#leaseRecoveryTimer) clearTimeout(this.#leaseRecoveryTimer);
1664
+ this.#leaseRecoveryTimer = undefined;
1665
+ this.#leaseRecoveryAt = undefined;
1666
+ }
1667
+ async #recoverLeasedEffects(): Promise<void> {
1668
+ if (!this.#started || this.#recoveringLeasedEffects) return;
1669
+ this.#recoveringLeasedEffects = true;
1670
+ let failed = false;
1671
+ try {
1672
+ try {
1673
+ await this.#reconcileTerminalProviderReceipts();
1674
+ } catch {
1675
+ failed = true;
1676
+ }
1677
+ try {
1678
+ failed ||= await this.#drainProviderEffects();
1679
+ } catch {
1680
+ failed = true;
1681
+ }
1682
+ try {
1683
+ await this.#reconcileTerminalProviderReceipts();
1684
+ } catch {
1685
+ failed = true;
1686
+ }
1687
+ try {
1688
+ await this.#drainPendingDispatches();
1689
+ } catch {
1690
+ failed = true;
1691
+ }
1692
+ } finally {
1693
+ if (failed) this.#leaseRecoveryFailures = Math.min(this.#leaseRecoveryFailures + 1, 5);
1694
+ else this.#leaseRecoveryFailures = 0;
1695
+ try {
1696
+ await this.#scheduleLeaseRecovery(failed);
1697
+ } catch {
1698
+ /* retained effects are retried by the next trigger */
1699
+ } finally {
1700
+ this.#recoveringLeasedEffects = false;
1701
+ }
1702
+ }
1703
+ }
1704
+
1705
+ async #providerEffectCurrent(effect: ChatEffect): Promise<boolean> {
1706
+ if (!effect.sessionId || !Number.isSafeInteger(effect.endpointGeneration) || effect.endpointGeneration <= 0)
1707
+ return false;
1708
+ const endpoint = await this.#resolveEndpoint(effect.sessionId);
1709
+ if (!endpoint || endpoint.generation !== effect.endpointGeneration) return false;
1710
+ const payload = effect.payload as { threadTs?: unknown };
1711
+ const threadTs = payload.threadTs;
1712
+ const records = Object.values((await this.store.load()).conversations);
1713
+ if (typeof threadTs === "string") {
1714
+ return records.some(
1715
+ record =>
1716
+ record.sessionId === effect.sessionId &&
1717
+ acceptsSlackInbound(record, threadTs, effect.endpointGeneration),
1718
+ );
1719
+ }
1720
+ return records.some(
1721
+ record =>
1722
+ record.sessionId === effect.sessionId &&
1723
+ record.endpointGeneration === effect.endpointGeneration &&
1724
+ record.state === "posting_root",
1725
+ );
1726
+ }
1727
+
1728
+ async #providerEffectPayload(
1729
+ id: string,
1730
+ sessionId: string,
1731
+ endpointGeneration: number,
1732
+ expected: { channel: string; text: string; threadTs?: string; clientMsgId: string },
1733
+ ): Promise<{ payload: typeof expected; existed: boolean }> {
1734
+ const existing = await this.#journal.read<typeof expected>(id);
1735
+ if (!existing) return { payload: expected, existed: false };
1736
+ const payload = existing.payload;
1737
+ if (
1738
+ existing.kind !== "provider-post" ||
1739
+ existing.transport !== "slack" ||
1740
+ existing.sessionId !== sessionId ||
1741
+ existing.endpointGeneration !== endpointGeneration ||
1742
+ typeof payload.channel !== "string" ||
1743
+ typeof payload.text !== "string" ||
1744
+ typeof payload.clientMsgId !== "string" ||
1745
+ (payload.threadTs !== undefined && typeof payload.threadTs !== "string") ||
1746
+ payload.channel !== expected.channel ||
1747
+ payload.threadTs !== expected.threadTs ||
1748
+ payload.clientMsgId !== expected.clientMsgId
1749
+ ) {
1750
+ throw new SlackEndpointBindingError("Slack provider effect identity does not match the durable occurrence.");
1751
+ }
1752
+ return { payload, existed: true };
1753
+ }
1754
+
1755
+ async #postDurable(
1756
+ id: string,
1757
+ sessionId: string,
1758
+ endpointGeneration: number,
1759
+ payload: { channel: string; text: string; threadTs?: string; clientMsgId: string },
1760
+ ): Promise<SlackPostedMessage> {
1761
+ if (!Number.isSafeInteger(endpointGeneration) || endpointGeneration <= 0)
1762
+ throw new SlackEndpointBindingError("Slack provider effects require a positive endpoint generation.");
1763
+ const initial = await this.#rescheduleAfterEffectTransition(
1764
+ this.#journal.enqueue({
1765
+ id,
1766
+ kind: "provider-post",
1767
+ transport: "slack",
1768
+ sessionId,
1769
+ endpointGeneration,
1770
+ payload,
1771
+ }),
1772
+ );
1773
+ const fromReceipt = (effect: typeof initial): SlackPostedMessage | undefined => {
1774
+ if (
1775
+ effect.state !== "terminal" ||
1776
+ effect.receipt?.status !== "posted" ||
1777
+ !effect.receipt.channelId ||
1778
+ !effect.receipt.timestamp
1779
+ )
1780
+ return undefined;
1781
+ return {
1782
+ channel: effect.receipt.channelId,
1783
+ ts: effect.receipt.timestamp,
1784
+ client_msg_id: effect.receipt.messageId,
1785
+ };
1786
+ };
1787
+ const completed = fromReceipt(initial);
1788
+ if (completed) return completed;
1789
+ if (initial.state === "terminal") throw new Error("Slack provider effect previously failed");
1790
+ let effect: ChatEffect<typeof payload> | undefined;
1791
+ for (let attempt = 0; attempt < 100 && !effect; attempt++) {
1792
+ effect = await this.#rescheduleAfterEffectTransition(
1793
+ this.#journal.claim<typeof payload>(id, this.#publicationOwnerId, Math.max(this.#publicationLeaseMs, 100)),
1794
+ );
1795
+ if (effect) break;
1796
+ const current = await this.#journal.read<typeof payload>(id);
1797
+ if (current) {
1798
+ const posted = fromReceipt(current);
1799
+ if (posted) return posted;
1800
+ if (current.state === "terminal") throw new Error("Slack provider effect previously failed");
1801
+ }
1802
+ await Bun.sleep(1);
1803
+ }
1804
+ if (!effect) throw new Error("Slack provider effect is owned by another worker");
1805
+ const lease: ChatEffectLease = { owner: this.#publicationOwnerId, epoch: effect.epoch };
1806
+ // A recovered lease may have crossed the provider boundary before its owner
1807
+ // died, and a fresh post may have reached Slack at a now-superseded generation.
1808
+ // Reconcile first in all cases. Only an effect that may have crossed the
1809
+ // provider boundary is terminalized after an absent reconciliation; a stale
1810
+ // pending effect remains nonterminal for recovery.
1811
+ const requiresReconciliation =
1812
+ initial.state === "accepted" || initial.state === "uncertain" || initial.state === "leased";
1813
+ try {
1814
+ const posted = await this.#withEffectLease(id, lease, async () => {
1815
+ const found = await this.options.provider.findMessageByClientMsgId({
1816
+ channel: effect!.payload.channel,
1817
+ threadTs: effect!.payload.threadTs,
1818
+ clientMsgId: effect!.payload.clientMsgId,
1819
+ });
1820
+ if (found) return found;
1821
+ if (!(await this.#providerEffectCurrent(effect!))) {
1822
+ if (requiresReconciliation) throw new SlackReconciledAbsentEffectError();
1823
+ throw new SlackStaleEffectError();
1824
+ }
1825
+ return await this.options.provider.postMessage(effect!.payload);
1826
+ });
1827
+
1828
+ if (
1829
+ !(await this.#journal.record(id, lease, "terminal", {
1830
+ provider: "slack",
1831
+ channelId: posted.channel,
1832
+ timestamp: posted.ts,
1833
+ messageId: posted.client_msg_id ?? posted.ts,
1834
+ status: "posted",
1835
+ }))
1836
+ )
1837
+ throw new Error("Slack provider effect lease expired before commit");
1838
+
1839
+ return posted;
1840
+ } catch (error) {
1841
+ if (error instanceof SlackReconciledAbsentEffectError) {
1842
+ await this.#rescheduleAfterEffectTransition(
1843
+ this.#journal.record(id, lease, "terminal", { provider: "slack", status: "not_found" }),
1844
+ );
1845
+ throw new SlackStaleEffectError();
1846
+ }
1847
+ if (error instanceof SlackStaleEffectError) throw error;
1848
+ if (this.#isUncertainPostFailure(error)) {
1849
+ try {
1850
+ const reconciled = await this.#withEffectLease(
1851
+ id,
1852
+ lease,
1853
+ async () =>
1854
+ await this.options.provider.findMessageByClientMsgId({
1855
+ channel: effect!.payload.channel,
1856
+ threadTs: effect!.payload.threadTs,
1857
+ clientMsgId: effect!.payload.clientMsgId,
1858
+ }),
1859
+ );
1860
+ if (reconciled) {
1861
+ if (
1862
+ !(await this.#journal.record(id, lease, "terminal", {
1863
+ provider: "slack",
1864
+ channelId: reconciled.channel,
1865
+ timestamp: reconciled.ts,
1866
+ messageId: reconciled.client_msg_id ?? reconciled.ts,
1867
+ status: "posted",
1868
+ }))
1869
+ )
1870
+ throw new Error("Slack provider effect lease expired before commit");
1871
+ return reconciled;
1872
+ }
1873
+ } catch {
1874
+ // Preserve the uncertain effect for a later reconciliation attempt.
1875
+ }
1876
+ }
1877
+ const uncertain = this.#isUncertainPostFailure(error);
1878
+ await this.#rescheduleAfterEffectTransition(
1879
+ this.#journal.record(id, lease, uncertain ? "uncertain" : "terminal", {
1880
+ provider: "slack",
1881
+ status: uncertain ? "uncertain" : "failed",
1882
+ }),
1883
+ );
1884
+ throw error;
1885
+ }
1886
+ }
1887
+
1888
+ async #withRootLease<T>(key: string, clientMsgId: string, fence: number, operation: () => Promise<T>): Promise<T> {
1889
+ return await this.#withPublicationLease(operation, async () => {
1890
+ let renewed = false;
1891
+ await this.store.transact(key, current => {
1892
+ if (
1893
+ !current ||
1894
+ current.clientMsgId !== clientMsgId ||
1895
+ current.rootPublicationOwner !== this.#publicationOwnerId ||
1896
+ current.rootPublicationFence !== fence
1897
+ )
1898
+ return current;
1899
+ renewed = true;
1900
+ return nextRecord(current, {
1901
+ rootPublicationLeaseExpiresAt: this.#now() + this.#publicationLeaseMs,
1902
+ updatedAt: this.#now(),
1903
+ });
1904
+ });
1905
+ if (!renewed) throw new Error("Slack root publication lease renewal failed");
1906
+ });
1907
+ }
1908
+
1909
+ async #withActionLease<T>(key: string, clientMsgId: string, fence: number, operation: () => Promise<T>): Promise<T> {
1910
+ return await this.#withPublicationLease(operation, async () => {
1911
+ let renewed = false;
1912
+ await this.store.transact(key, current => {
1913
+ if (
1914
+ !current ||
1915
+ current.outboundActionClientMsgId !== clientMsgId ||
1916
+ current.outboundActionOwner !== this.#publicationOwnerId ||
1917
+ current.outboundActionFence !== fence
1918
+ )
1919
+ return current;
1920
+ renewed = true;
1921
+ return nextRecord(current, {
1922
+ outboundActionLeaseExpiresAt: this.#now() + this.#publicationLeaseMs,
1923
+ updatedAt: this.#now(),
1924
+ });
1925
+ });
1926
+ if (!renewed) throw new Error("Slack action publication lease renewal failed");
1927
+ });
1928
+ }
1929
+
1930
+ async #withPublicationLease<T>(operation: () => Promise<T>, renew: () => Promise<void>): Promise<T> {
1931
+ return await this.#withRenewal(operation, renew);
1932
+ }
1933
+
1934
+ async #withRenewal<T>(
1935
+ operation: () => Promise<T>,
1936
+ renew: () => Promise<void>,
1937
+ renewBeforeOperation = true,
1938
+ ): Promise<T> {
1939
+ if (renewBeforeOperation) await renew();
1940
+ let failure: unknown;
1941
+ let renewing = Promise.resolve();
1942
+ let renewalPending = false;
1943
+ const renewLease = async () => {
1944
+ try {
1945
+ await renew();
1946
+ } catch (error) {
1947
+ failure ??= error;
1948
+ }
1949
+ };
1950
+ const timer = setInterval(
1951
+ () => {
1952
+ if (renewalPending || failure) return;
1953
+ renewalPending = true;
1954
+ renewing = renewing.then(renewLease).finally(() => {
1955
+ renewalPending = false;
1956
+ });
1957
+ },
1958
+ Math.max(1, Math.floor(this.#publicationLeaseMs / 4)),
1959
+ );
1960
+ try {
1961
+ const result = await operation();
1962
+ await renewing;
1963
+ if (failure) throw failure;
1964
+ return result;
1965
+ } finally {
1966
+ clearInterval(timer);
1967
+ await renewing;
1968
+ }
1969
+ }
1970
+
1971
+ async #waitForRootReconciliation(key: string, initial: SlackConversation): Promise<SlackConversation | undefined> {
1972
+ const clientMsgId = initial.clientMsgId;
1973
+ const endpointGeneration = initial.endpointGeneration;
1974
+ if (
1975
+ typeof clientMsgId !== "string" ||
1976
+ typeof endpointGeneration !== "number" ||
1977
+ !Number.isSafeInteger(endpointGeneration) ||
1978
+ endpointGeneration <= 0
1979
+ )
1980
+ throw new SlackEndpointBindingError("Slack root publication has no stable reconciliation identity.");
1981
+ for (let attempt = 0; attempt < 100; attempt++) {
1982
+ const current = await this.store.read(key);
1983
+ if (
1984
+ current?.state !== "posting_root" ||
1985
+ current.clientMsgId !== clientMsgId ||
1986
+ current.endpointGeneration !== endpointGeneration
1987
+ )
1988
+ return current;
1989
+ const effect:
1990
+ | ChatEffect<{
1991
+ channel?: unknown;
1992
+ text?: unknown;
1993
+ threadTs?: unknown;
1994
+ clientMsgId?: unknown;
1995
+ }>
1996
+ | undefined = await this.#journal.read(`root:${current.sessionId}:${clientMsgId}`);
1997
+ if (effect?.state === "terminal") {
1998
+ await this.#reconcileTerminalProviderReceipts();
1999
+ await Bun.sleep(10);
2000
+ continue;
2001
+ }
2002
+ const payload: { channel?: unknown; text?: unknown; threadTs?: unknown; clientMsgId?: unknown } | undefined =
2003
+ effect?.payload;
2004
+ if (
2005
+ effect?.kind === "provider-post" &&
2006
+ effect.transport === "slack" &&
2007
+ effect.sessionId === current.sessionId &&
2008
+ typeof current.sessionId === "string" &&
2009
+ effect.endpointGeneration === endpointGeneration &&
2010
+ (effect.state === "pending" ||
2011
+ effect.state === "accepted" ||
2012
+ effect.state === "uncertain" ||
2013
+ (effect.state === "leased" && this.#leaseExpired(effect.leaseExpiresAt, this.#now()))) &&
2014
+ payload &&
2015
+ typeof payload.channel === "string" &&
2016
+ payload.channel === current.channelId &&
2017
+ typeof payload.text === "string" &&
2018
+ payload.threadTs === undefined &&
2019
+ payload.clientMsgId === clientMsgId
2020
+ ) {
2021
+ try {
2022
+ await this.#postDurable(effect.id, current.sessionId, endpointGeneration, {
2023
+ channel: payload.channel,
2024
+ text: payload.text,
2025
+ clientMsgId,
2026
+ });
2027
+ } catch (error) {
2028
+ if (!(error instanceof SlackStaleEffectError)) throw error;
2029
+ }
2030
+ await this.#reconcileTerminalProviderReceipts();
2031
+ continue;
2032
+ }
2033
+ await Bun.sleep(10);
2034
+ }
2035
+ throw new Error("Slack root post reconciliation is still pending");
2036
+ }
2037
+
2038
+ async #waitForRoot(
2039
+ key: string,
2040
+ sessionId: string,
2041
+ body: string,
2042
+ endpointGeneration: number,
2043
+ ): Promise<SlackConversation> {
2044
+ for (let attempt = 0; attempt < 100; attempt++) {
2045
+ await Bun.sleep(10);
2046
+ const current = await this.store.read(key);
2047
+ if (current?.state === "active") {
2048
+ const currentGeneration = this.#requireEndpointGeneration(current);
2049
+ if (currentGeneration === endpointGeneration) return current;
2050
+ if (currentGeneration > endpointGeneration)
2051
+ throw new SlackEndpointBindingError("Slack root belongs to a newer endpoint generation.");
2052
+ return await this.resume(sessionId, body, endpointGeneration);
2053
+ }
2054
+ if (
2055
+ current?.state === "error" ||
2056
+ (current?.state === "posting_root" &&
2057
+ this.#leaseExpired(current.rootPublicationLeaseExpiresAt, this.#now())) ||
2058
+ (current !== undefined &&
2059
+ current.rootPublicationOwner !== undefined &&
2060
+ this.#leaseExpired(current.rootPublicationLeaseExpiresAt, this.#now()))
2061
+ ) {
2062
+ return await this.postRoot(sessionId, body, endpointGeneration);
2063
+ }
2064
+ }
2065
+ throw new Error("Slack root post is still pending");
2066
+ }
2067
+
2068
+ async #waitForAction(key: string, sessionId: string, body: string, actionId: string): Promise<SlackConversation> {
2069
+ for (let attempt = 0; attempt < 100; attempt++) {
2070
+ await Bun.sleep(10);
2071
+ const current = await this.store.read(key);
2072
+ if (current?.pendingActionId === actionId && !current.outboundActionId) return current;
2073
+ if (
2074
+ current?.outboundActionId === actionId &&
2075
+ this.#leaseExpired(current.outboundActionLeaseExpiresAt, this.#now())
2076
+ ) {
2077
+ return await this.notify(sessionId, body, actionId);
2078
+ }
2079
+ }
2080
+ throw new Error("Slack action publication is still pending");
2081
+ }
2082
+
2083
+ async findSession(
2084
+ sessionId: string,
2085
+ includeInactive: boolean,
2086
+ ): Promise<
2087
+ | {
2088
+ key: string;
2089
+ record: SlackConversation;
2090
+ }
2091
+ | undefined
2092
+ > {
2093
+ const document = await this.store.load();
2094
+ return Object.entries(document.conversations)
2095
+ .map(([key, record]) => ({ key, record }))
2096
+ .filter(
2097
+ entry =>
2098
+ entry.record.sessionId === sessionId &&
2099
+ (includeInactive ||
2100
+ entry.record.state === "active" ||
2101
+ entry.record.state === "posting_root" ||
2102
+ entry.record.state === "error"),
2103
+ )
2104
+ .sort(
2105
+ (left, right) =>
2106
+ (right.record.endpointGeneration ?? -1) - (left.record.endpointGeneration ?? -1) ||
2107
+ right.record.generation - left.record.generation ||
2108
+ right.record.updatedAt - left.record.updatedAt,
2109
+ )[0];
2110
+ }
2111
+ #intentKey(sessionId: string): string {
2112
+ return slackConversationKey({
2113
+ teamId: this.options.teamId,
2114
+ channelId: this.options.channelId,
2115
+ rootTs: `intent:${sessionId}`,
2116
+ });
2117
+ }
2118
+ #requireEndpointGeneration(record: SlackConversation): number {
2119
+ const generation = record.endpointGeneration;
2120
+ if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation <= 0)
2121
+ throw new SlackEndpointBindingError("Slack conversation has no current endpoint generation.");
2122
+ return generation;
2123
+ }
2124
+ #leaseExpired(expiresAt: number | undefined, now: number): boolean {
2125
+ return expiresAt === undefined || expiresAt <= now;
2126
+ }
2127
+
2128
+ #isUncertainPostFailure(error: unknown): boolean {
2129
+ return (
2130
+ error instanceof SlackProviderError &&
2131
+ error.operation === "chat.postMessage" &&
2132
+ (error.code === "connection" || error.mayHaveBeenAccepted)
2133
+ );
2134
+ }
2135
+ #isDefiniteSdkPreSendFailure(error: unknown): boolean {
2136
+ if (error instanceof SlackEndpointBindingError) return true;
2137
+ if (error instanceof SdkClientError) return error.code === "connection_closed";
2138
+ return (
2139
+ error instanceof Error &&
2140
+ error.name === "ChatDeliveryError" &&
2141
+ (error as ChatDeliveryError).phase === "pre_send"
2142
+ );
2143
+ }
2144
+ }