@interactive-inc/claude-funnel 0.4.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (349) hide show
  1. package/README.md +233 -111
  2. package/dist/bin.js +1417 -0
  3. package/dist/gateway/daemon.js +513 -0
  4. package/dist/highlights-eq9cgrbb.scm +604 -0
  5. package/dist/highlights-ghv9g403.scm +205 -0
  6. package/dist/highlights-hk7bwhj4.scm +284 -0
  7. package/dist/highlights-r812a2qc.scm +150 -0
  8. package/dist/highlights-x6tmsnaa.scm +115 -0
  9. package/dist/injections-73j83es3.scm +27 -0
  10. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  11. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  12. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  13. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  14. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  15. package/lib/bin.ts +78 -0
  16. package/lib/{modules → cli}/router/to-request.ts +13 -20
  17. package/lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts +27 -0
  18. package/lib/cli/routes/channels.$channel.connectors.$connector.request.ts +40 -0
  19. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts +41 -0
  20. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts +22 -0
  21. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.ts +23 -0
  22. package/lib/cli/routes/channels.$channel.connectors.$connector.ts +26 -0
  23. package/lib/cli/routes/channels.$channel.connectors.add.$connector.ts +92 -0
  24. package/lib/cli/routes/channels.$channel.connectors.remove.$connector.ts +22 -0
  25. package/lib/cli/routes/channels.$channel.connectors.set.$connector.ts +63 -0
  26. package/lib/cli/routes/channels.$channel.connectors.ts +26 -0
  27. package/lib/cli/routes/channels.$channel.rename.$newName.ts +22 -0
  28. package/lib/cli/routes/channels.$channel.set.delivery.$mode.ts +34 -0
  29. package/lib/cli/routes/channels.$channel.ts +34 -0
  30. package/lib/cli/routes/channels.add.$channel.ts +33 -0
  31. package/lib/cli/routes/channels.remove.$channel.ts +20 -0
  32. package/lib/cli/routes/channels.ts +39 -0
  33. package/lib/cli/routes/claude.ts +69 -0
  34. package/lib/cli/routes/gateway.listeners.ts +41 -0
  35. package/lib/cli/routes/gateway.logs.ts +123 -0
  36. package/lib/{routes/gateway/restart.ts → cli/routes/gateway.restart.ts} +20 -5
  37. package/lib/cli/routes/gateway.run.ts +41 -0
  38. package/lib/cli/routes/gateway.start.ts +50 -0
  39. package/lib/cli/routes/gateway.status.ts +19 -0
  40. package/lib/cli/routes/gateway.stop.ts +32 -0
  41. package/lib/cli/routes/gateway.ts +55 -0
  42. package/lib/cli/routes/index.ts +202 -0
  43. package/lib/cli/routes/profiles.$profile.as-default.ts +22 -0
  44. package/lib/cli/routes/profiles.$profile.rename.$newName.ts +22 -0
  45. package/lib/cli/routes/profiles.$profile.run.ts +36 -0
  46. package/lib/cli/routes/profiles.add.$profile.ts +46 -0
  47. package/lib/cli/routes/profiles.remove.$profile.ts +20 -0
  48. package/lib/cli/routes/profiles.set.$profile.ts +46 -0
  49. package/lib/cli/routes/profiles.ts +40 -0
  50. package/lib/cli/routes/status.ts +93 -0
  51. package/lib/cli/routes/update.ts +27 -0
  52. package/lib/connectors/connector-config-schema.ts +16 -0
  53. package/lib/connectors/connector-factory.ts +94 -0
  54. package/lib/connectors/connector-listener.ts +20 -0
  55. package/lib/{modules/connectors/funnel-discord-adapter.ts → connectors/discord-adapter.ts} +6 -11
  56. package/lib/{modules/connectors → connectors}/discord-connector-schema.ts +4 -1
  57. package/lib/connectors/discord-listener.ts +111 -0
  58. package/lib/{modules/connectors/funnel-gh-adapter.ts → connectors/gh-adapter.ts} +3 -6
  59. package/lib/{modules/connectors → connectors}/gh-connector-schema.ts +4 -1
  60. package/lib/{modules/connectors/funnel-gh-listener.ts → connectors/gh-listener.ts} +57 -22
  61. package/lib/{modules/connectors → connectors}/match-cron.ts +10 -4
  62. package/lib/connectors/schedule-connector-schema.ts +33 -0
  63. package/lib/connectors/schedule-listener.ts +207 -0
  64. package/lib/connectors/schedule-state-store.ts +54 -0
  65. package/lib/connectors/slack-adapter.ts +36 -0
  66. package/lib/{modules/connectors → connectors}/slack-connector-schema.ts +4 -1
  67. package/lib/{modules/connectors/funnel-slack-event-processor.ts → connectors/slack-event-processor.ts} +15 -9
  68. package/lib/{modules/connectors/funnel-slack-listener.ts → connectors/slack-listener.ts} +39 -14
  69. package/lib/engine/channels/channels.ts +520 -0
  70. package/lib/{modules/claude/funnel-claude.ts → engine/claude/claude.ts} +47 -62
  71. package/lib/engine/claude/gateway-controller.ts +4 -0
  72. package/lib/{modules/fs/funnel-file-system.ts → engine/fs/file-system.ts} +9 -0
  73. package/lib/{modules/fs/memory-funnel-file-system.ts → engine/fs/memory-file-system.ts} +20 -3
  74. package/lib/{modules/fs/node-funnel-file-system.ts → engine/fs/node-file-system.ts} +14 -2
  75. package/lib/{modules/http/memory-funnel-http-client.ts → engine/http/memory-http-client.ts} +1 -5
  76. package/lib/{modules/http/node-funnel-http-client.ts → engine/http/node-http-client.ts} +1 -5
  77. package/lib/engine/id/id-generator.ts +7 -0
  78. package/lib/engine/id/memory-id-generator.ts +20 -0
  79. package/lib/engine/id/node-id-generator.ts +7 -0
  80. package/lib/engine/logger/logger.ts +11 -0
  81. package/lib/engine/logger/memory-logger.ts +28 -0
  82. package/lib/engine/logger/node-logger.ts +49 -0
  83. package/lib/engine/logger/noop-logger.ts +9 -0
  84. package/lib/engine/mcp/channel-server.ts +204 -0
  85. package/lib/{modules/mcp/funnel-mcp.ts → engine/mcp/mcp.ts} +29 -10
  86. package/lib/{modules/process/memory-funnel-process-runner.ts → engine/process/memory-process-runner.ts} +1 -1
  87. package/lib/{modules/process/node-funnel-process-runner.ts → engine/process/node-process-runner.ts} +12 -21
  88. package/lib/{modules/process/funnel-process-runner.ts → engine/process/process-runner.ts} +5 -0
  89. package/lib/engine/profiles/profile-channel-checker.ts +7 -0
  90. package/lib/engine/profiles/profiles.ts +126 -0
  91. package/lib/{modules/settings/mock-funnel-settings-reader.ts → engine/settings/mock-settings-reader.ts} +4 -3
  92. package/lib/{modules/settings/funnel-settings-reader.ts → engine/settings/settings-reader.ts} +1 -1
  93. package/lib/engine/settings/settings-schema.ts +46 -0
  94. package/lib/engine/settings/settings-store.ts +110 -0
  95. package/lib/engine/time/clock.ts +15 -0
  96. package/lib/engine/time/memory-clock.ts +26 -0
  97. package/lib/engine/time/node-clock.ts +7 -0
  98. package/lib/funnel.ts +148 -56
  99. package/lib/gateway/auth-middleware.ts +44 -0
  100. package/lib/gateway/broadcaster.ts +319 -0
  101. package/lib/gateway/daemon.ts +47 -0
  102. package/lib/gateway/factory.ts +10 -0
  103. package/lib/gateway/funnel-event-store.ts +155 -0
  104. package/lib/gateway/gateway-server.ts +414 -0
  105. package/lib/gateway/gateway-token.ts +79 -0
  106. package/lib/{modules/gateway/funnel-gateway.ts → gateway/gateway.ts} +70 -27
  107. package/lib/{modules/gateway → gateway}/kill-competing-slack-gateways.ts +7 -3
  108. package/lib/gateway/listener-supervisor.ts +339 -0
  109. package/lib/gateway/listeners-client.ts +128 -0
  110. package/lib/gateway/resolve-daemon-script.ts +26 -0
  111. package/lib/gateway/routes/channels.connectors.call.ts +39 -0
  112. package/lib/gateway/routes/health.ts +13 -0
  113. package/lib/gateway/routes/index.ts +24 -0
  114. package/lib/gateway/routes/listeners.list.ts +6 -0
  115. package/lib/gateway/routes/listeners.restart.ts +15 -0
  116. package/lib/gateway/routes/listeners.start.ts +15 -0
  117. package/lib/gateway/routes/listeners.stop.ts +15 -0
  118. package/lib/gateway/routes/route-deps.ts +11 -0
  119. package/lib/gateway/routes/status.ts +15 -0
  120. package/lib/gateway/routes/validator.ts +17 -0
  121. package/lib/index.ts +50 -92
  122. package/lib/logger/leuco-human-file-writer.ts +65 -0
  123. package/lib/logger/leuco-human-logger.ts +98 -0
  124. package/lib/logger/leuco-human-record.ts +16 -0
  125. package/lib/logger/leuco-human-stdout-writer.ts +26 -0
  126. package/lib/logger/leuco-human-writer.ts +14 -0
  127. package/lib/logger/leuco-logger-memory-sink.ts +67 -0
  128. package/lib/logger/leuco-logger-record.ts +13 -0
  129. package/lib/logger/leuco-logger-sink.ts +33 -0
  130. package/lib/logger/leuco-logger-sqlite-sink.ts +355 -0
  131. package/lib/logger/leuco-logger.ts +135 -0
  132. package/lib/tui/app.tsx +357 -0
  133. package/lib/tui/components/add-row.tsx +18 -0
  134. package/lib/tui/components/brand.tsx +27 -0
  135. package/lib/tui/components/card.tsx +44 -0
  136. package/lib/tui/components/detail-bar.tsx +46 -0
  137. package/lib/tui/components/editable-field.tsx +33 -0
  138. package/lib/tui/components/empty-state.tsx +11 -0
  139. package/lib/tui/components/gateway-status.tsx +66 -0
  140. package/lib/tui/components/keymap.tsx +29 -0
  141. package/lib/tui/components/menu-item.tsx +73 -0
  142. package/lib/tui/components/menu.tsx +26 -0
  143. package/lib/tui/components/panel-header.tsx +22 -0
  144. package/lib/tui/components/readonly-field.tsx +18 -0
  145. package/lib/tui/components/section-header.tsx +25 -0
  146. package/lib/tui/components/selection-accent.tsx +32 -0
  147. package/lib/tui/components/session-item.tsx +33 -0
  148. package/lib/tui/components/session-list.tsx +33 -0
  149. package/lib/tui/components/ui/hascii/accordion-item.tsx +88 -0
  150. package/lib/tui/components/ui/hascii/accordion.tsx +96 -0
  151. package/lib/tui/components/ui/hascii/alert-dialog.tsx +43 -0
  152. package/lib/tui/components/ui/hascii/badge.tsx +51 -0
  153. package/lib/tui/components/ui/hascii/breadcrumb.tsx +58 -0
  154. package/lib/tui/components/ui/hascii/button.tsx +194 -0
  155. package/lib/tui/components/ui/hascii/card-content.tsx +14 -0
  156. package/lib/tui/components/ui/hascii/card-description.tsx +13 -0
  157. package/lib/tui/components/ui/hascii/card-footer.tsx +14 -0
  158. package/lib/tui/components/ui/hascii/card-header.tsx +14 -0
  159. package/lib/tui/components/ui/hascii/card-title.tsx +13 -0
  160. package/lib/tui/components/ui/hascii/card.tsx +27 -0
  161. package/lib/tui/components/ui/hascii/checkbox.tsx +65 -0
  162. package/lib/tui/components/ui/hascii/command.tsx +159 -0
  163. package/lib/tui/components/ui/hascii/dialog-content.tsx +14 -0
  164. package/lib/tui/components/ui/hascii/dialog-description.tsx +13 -0
  165. package/lib/tui/components/ui/hascii/dialog-footer.tsx +14 -0
  166. package/lib/tui/components/ui/hascii/dialog-header.tsx +14 -0
  167. package/lib/tui/components/ui/hascii/dialog-title.tsx +13 -0
  168. package/lib/tui/components/ui/hascii/dialog.tsx +27 -0
  169. package/lib/tui/components/ui/hascii/file-tree.tsx +142 -0
  170. package/lib/tui/components/ui/hascii/focus-group.tsx +62 -0
  171. package/lib/tui/components/ui/hascii/form-item.tsx +43 -0
  172. package/lib/tui/components/ui/hascii/input-otp.tsx +86 -0
  173. package/lib/tui/components/ui/hascii/input.tsx +130 -0
  174. package/lib/tui/components/ui/hascii/pagination.tsx +105 -0
  175. package/lib/tui/components/ui/hascii/progress.tsx +28 -0
  176. package/lib/tui/components/ui/hascii/select.tsx +131 -0
  177. package/lib/tui/components/ui/hascii/separator.tsx +35 -0
  178. package/lib/tui/components/ui/hascii/sidebar-content.tsx +23 -0
  179. package/lib/tui/components/ui/hascii/sidebar-header.tsx +14 -0
  180. package/lib/tui/components/ui/hascii/sidebar-menu-item.tsx +67 -0
  181. package/lib/tui/components/ui/hascii/sidebar.tsx +24 -0
  182. package/lib/tui/components/ui/hascii/skeleton.tsx +60 -0
  183. package/lib/tui/components/ui/hascii/slider.tsx +91 -0
  184. package/lib/tui/components/ui/hascii/snackbar.tsx +75 -0
  185. package/lib/tui/components/ui/hascii/sparkline.tsx +53 -0
  186. package/lib/tui/components/ui/hascii/spinner.tsx +47 -0
  187. package/lib/tui/components/ui/hascii/stepper.tsx +54 -0
  188. package/lib/tui/components/ui/hascii/switch.tsx +66 -0
  189. package/lib/tui/components/ui/hascii/table.tsx +95 -0
  190. package/lib/tui/components/ui/hascii/tabs.tsx +59 -0
  191. package/lib/tui/components/ui/hascii/toggle-group-item.tsx +45 -0
  192. package/lib/tui/components/ui/hascii/toggle-group.tsx +99 -0
  193. package/lib/tui/components/ui/hascii/tree.tsx +104 -0
  194. package/lib/tui/components/view-shell.tsx +44 -0
  195. package/lib/tui/filter-input.tsx +33 -0
  196. package/lib/tui/hooks/hascii/use-pressable.ts +54 -0
  197. package/lib/tui/parse-comma-list.ts +14 -0
  198. package/lib/tui/profile-launcher.tsx +61 -0
  199. package/lib/tui/scrollbar-options.ts +19 -0
  200. package/lib/tui/sidebar.tsx +50 -0
  201. package/lib/tui/theme.ts +40 -0
  202. package/lib/tui/tui.tsx +20 -0
  203. package/lib/tui/types.ts +38 -0
  204. package/lib/tui/unique-name.ts +18 -0
  205. package/lib/tui/use-event-stream.ts +133 -0
  206. package/lib/tui/use-snapshot.ts +99 -0
  207. package/lib/tui/utils/hascii/form-item-context.tsx +23 -0
  208. package/lib/tui/utils/hascii/input-focus-context.tsx +31 -0
  209. package/lib/tui/utils/hascii/theme-context.tsx +26 -0
  210. package/lib/tui/utils/hascii/theme.ts +176 -0
  211. package/lib/tui/views/channels-view.tsx +108 -0
  212. package/lib/tui/views/connectors-view.tsx +164 -0
  213. package/lib/tui/views/events-view.tsx +160 -0
  214. package/lib/tui/views/listeners-view.tsx +80 -0
  215. package/lib/tui/views/profiles-view.tsx +152 -0
  216. package/package.json +51 -34
  217. package/lib/modules/channels/channel-connector-ref-updater.ts +0 -4
  218. package/lib/modules/channels/funnel-channels.ts +0 -155
  219. package/lib/modules/connectors/connector-config-schema.ts +0 -16
  220. package/lib/modules/connectors/connector-existence-checker.ts +0 -3
  221. package/lib/modules/connectors/funnel-callable-connector-store.ts +0 -9
  222. package/lib/modules/connectors/funnel-connector-listener.ts +0 -5
  223. package/lib/modules/connectors/funnel-connector-stores.ts +0 -24
  224. package/lib/modules/connectors/funnel-connector-type-store.ts +0 -24
  225. package/lib/modules/connectors/funnel-connectors.ts +0 -145
  226. package/lib/modules/connectors/funnel-discord-listener.ts +0 -65
  227. package/lib/modules/connectors/funnel-discord-store.ts +0 -84
  228. package/lib/modules/connectors/funnel-gh-store.ts +0 -84
  229. package/lib/modules/connectors/funnel-json-connector-store.ts +0 -100
  230. package/lib/modules/connectors/funnel-schedule-listener.ts +0 -124
  231. package/lib/modules/connectors/funnel-schedule-store.ts +0 -178
  232. package/lib/modules/connectors/funnel-slack-adapter.ts +0 -31
  233. package/lib/modules/connectors/funnel-slack-store.ts +0 -86
  234. package/lib/modules/connectors/migrate-legacy-connectors.ts +0 -77
  235. package/lib/modules/connectors/schedule-connector-schema.ts +0 -18
  236. package/lib/modules/connectors/schedule-last-fired-store.ts +0 -48
  237. package/lib/modules/gateway/daemon.ts +0 -207
  238. package/lib/modules/gateway/funnel-broadcaster.ts +0 -37
  239. package/lib/modules/gateway/funnel-event-logger.ts +0 -59
  240. package/lib/modules/logger.ts +0 -26
  241. package/lib/modules/mcp/channel-server.ts +0 -76
  242. package/lib/modules/profiles/funnel-profiles.ts +0 -123
  243. package/lib/modules/profiles/profile-channel-checker.ts +0 -3
  244. package/lib/modules/profiles/profile-channel-ref-updater.ts +0 -3
  245. package/lib/modules/repos/funnel-repositories.ts +0 -107
  246. package/lib/modules/schedule/funnel-schedule.ts +0 -34
  247. package/lib/modules/settings/funnel-settings-store.ts +0 -56
  248. package/lib/modules/settings/settings-schema.ts +0 -33
  249. package/lib/modules/tui/app.tsx +0 -44
  250. package/lib/modules/tui/tui.tsx +0 -13
  251. package/lib/routes/channels/add.help.ts +0 -3
  252. package/lib/routes/channels/add.ts +0 -21
  253. package/lib/routes/channels/connectors-attach.help.ts +0 -3
  254. package/lib/routes/channels/connectors-attach.ts +0 -17
  255. package/lib/routes/channels/connectors-detach.help.ts +0 -3
  256. package/lib/routes/channels/connectors-detach.ts +0 -17
  257. package/lib/routes/channels/group.help.ts +0 -16
  258. package/lib/routes/channels/group.ts +0 -22
  259. package/lib/routes/channels/remove.help.ts +0 -3
  260. package/lib/routes/channels/remove.ts +0 -17
  261. package/lib/routes/channels/rename.help.ts +0 -5
  262. package/lib/routes/channels/rename.ts +0 -17
  263. package/lib/routes/channels/routes.ts +0 -19
  264. package/lib/routes/channels/show.help.ts +0 -1
  265. package/lib/routes/channels/show.ts +0 -26
  266. package/lib/routes/claude/claude.help.ts +0 -16
  267. package/lib/routes/claude/claude.ts +0 -76
  268. package/lib/routes/claude/routes.ts +0 -4
  269. package/lib/routes/connectors/add.help.ts +0 -28
  270. package/lib/routes/connectors/add.ts +0 -64
  271. package/lib/routes/connectors/group.help.ts +0 -14
  272. package/lib/routes/connectors/group.ts +0 -18
  273. package/lib/routes/connectors/remove.help.ts +0 -3
  274. package/lib/routes/connectors/remove.ts +0 -17
  275. package/lib/routes/connectors/rename.help.ts +0 -5
  276. package/lib/routes/connectors/rename.ts +0 -17
  277. package/lib/routes/connectors/routes.ts +0 -23
  278. package/lib/routes/connectors/schedules-add.help.ts +0 -11
  279. package/lib/routes/connectors/schedules-add.ts +0 -33
  280. package/lib/routes/connectors/schedules-group.help.ts +0 -1
  281. package/lib/routes/connectors/schedules-group.ts +0 -38
  282. package/lib/routes/connectors/schedules-remove.help.ts +0 -3
  283. package/lib/routes/connectors/schedules-remove.ts +0 -17
  284. package/lib/routes/connectors/set.help.ts +0 -8
  285. package/lib/routes/connectors/set.ts +0 -72
  286. package/lib/routes/connectors/show.help.ts +0 -1
  287. package/lib/routes/connectors/show.ts +0 -41
  288. package/lib/routes/gateway/group.help.ts +0 -15
  289. package/lib/routes/gateway/group.ts +0 -28
  290. package/lib/routes/gateway/logs.help.ts +0 -13
  291. package/lib/routes/gateway/logs.ts +0 -100
  292. package/lib/routes/gateway/restart.help.ts +0 -10
  293. package/lib/routes/gateway/routes.ts +0 -18
  294. package/lib/routes/gateway/run.help.ts +0 -12
  295. package/lib/routes/gateway/run.ts +0 -35
  296. package/lib/routes/gateway/start.help.ts +0 -15
  297. package/lib/routes/gateway/start.ts +0 -32
  298. package/lib/routes/gateway/status.help.ts +0 -9
  299. package/lib/routes/gateway/status.ts +0 -28
  300. package/lib/routes/gateway/stop.help.ts +0 -8
  301. package/lib/routes/gateway/stop.ts +0 -21
  302. package/lib/routes/profiles/add.help.ts +0 -3
  303. package/lib/routes/profiles/add.ts +0 -33
  304. package/lib/routes/profiles/group.help.ts +0 -16
  305. package/lib/routes/profiles/group.ts +0 -25
  306. package/lib/routes/profiles/launch.help.ts +0 -4
  307. package/lib/routes/profiles/launch.ts +0 -36
  308. package/lib/routes/profiles/remove.help.ts +0 -3
  309. package/lib/routes/profiles/remove.ts +0 -17
  310. package/lib/routes/profiles/rename.help.ts +0 -5
  311. package/lib/routes/profiles/rename.ts +0 -17
  312. package/lib/routes/profiles/routes.ts +0 -18
  313. package/lib/routes/profiles/set.help.ts +0 -5
  314. package/lib/routes/profiles/set.ts +0 -32
  315. package/lib/routes/repos/add.help.ts +0 -6
  316. package/lib/routes/repos/add.ts +0 -20
  317. package/lib/routes/repos/group.help.ts +0 -11
  318. package/lib/routes/repos/group.ts +0 -18
  319. package/lib/routes/repos/remove.help.ts +0 -3
  320. package/lib/routes/repos/remove.ts +0 -17
  321. package/lib/routes/repos/rename.help.ts +0 -5
  322. package/lib/routes/repos/rename.ts +0 -17
  323. package/lib/routes/repos/routes.ts +0 -17
  324. package/lib/routes/repos/set.help.ts +0 -5
  325. package/lib/routes/repos/set.ts +0 -21
  326. package/lib/routes/repos/show.help.ts +0 -1
  327. package/lib/routes/repos/show.ts +0 -19
  328. package/lib/routes/request/discord-help.ts +0 -9
  329. package/lib/routes/request/discord.help.ts +0 -19
  330. package/lib/routes/request/discord.ts +0 -65
  331. package/lib/routes/request/group.help.ts +0 -15
  332. package/lib/routes/request/group.ts +0 -9
  333. package/lib/routes/request/routes.ts +0 -14
  334. package/lib/routes/request/slack-help.ts +0 -9
  335. package/lib/routes/request/slack.help.ts +0 -19
  336. package/lib/routes/request/slack.ts +0 -61
  337. package/lib/routes/status/routes.ts +0 -4
  338. package/lib/routes/status/status.help.ts +0 -6
  339. package/lib/routes/status/status.ts +0 -77
  340. package/lib/routes/update/routes.ts +0 -4
  341. package/lib/routes/update/update.help.ts +0 -5
  342. package/lib/routes/update/update.ts +0 -21
  343. package/lib/routes.ts +0 -40
  344. /package/lib/{factory.ts → cli/factory.ts} +0 -0
  345. /package/lib/{modules → cli}/router/query-to-cli-args.ts +0 -0
  346. /package/lib/{modules → cli}/router/validator.ts +0 -0
  347. /package/lib/{modules/connectors/funnel-connector-adapter.ts → connectors/connector-adapter.ts} +0 -0
  348. /package/lib/{modules/connectors/funnel-discord-event-processor.ts → connectors/discord-event-processor.ts} +0 -0
  349. /package/lib/{modules/http/funnel-http-client.ts → engine/http/http-client.ts} +0 -0
@@ -0,0 +1,26 @@
1
+ import { FunnelClock } from "@/engine/time/clock"
2
+
3
+ type Props = {
4
+ start?: Date
5
+ }
6
+
7
+ export class MemoryFunnelClock extends FunnelClock {
8
+ private current: Date
9
+
10
+ constructor(props: Props = {}) {
11
+ super()
12
+ this.current = props.start ?? new Date(0)
13
+ }
14
+
15
+ now(): Date {
16
+ return new Date(this.current.getTime())
17
+ }
18
+
19
+ set(date: Date): void {
20
+ this.current = date
21
+ }
22
+
23
+ advance(ms: number): void {
24
+ this.current = new Date(this.current.getTime() + ms)
25
+ }
26
+ }
@@ -0,0 +1,7 @@
1
+ import { FunnelClock } from "@/engine/time/clock"
2
+
3
+ export class NodeFunnelClock extends FunnelClock {
4
+ now(): Date {
5
+ return new Date()
6
+ }
7
+ }
package/lib/funnel.ts CHANGED
@@ -1,95 +1,187 @@
1
- import { FunnelChannels } from "@/modules/channels/funnel-channels"
2
- import { FunnelClaude } from "@/modules/claude/funnel-claude"
3
- import {
4
- type ConnectorStoresBundle,
5
- createConnectorStores,
6
- } from "@/modules/connectors/funnel-connector-stores"
7
- import { FunnelConnectors } from "@/modules/connectors/funnel-connectors"
8
- import type { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
9
- import { FunnelGateway } from "@/modules/gateway/funnel-gateway"
10
- import { FunnelMcp } from "@/modules/mcp/funnel-mcp"
11
- import { FunnelProfiles } from "@/modules/profiles/funnel-profiles"
12
- import { FunnelRepositories } from "@/modules/repos/funnel-repositories"
13
- import { FunnelSchedule } from "@/modules/schedule/funnel-schedule"
14
- import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
1
+ import { join } from "node:path"
2
+ import { FunnelConnectorFactory } from "@/connectors/connector-factory"
3
+ import { FunnelChannels } from "@/engine/channels/channels"
4
+ import { FunnelClaude } from "@/engine/claude/claude"
5
+ import type { FunnelFileSystem } from "@/engine/fs/file-system"
6
+ import type { FunnelIdGenerator } from "@/engine/id/id-generator"
7
+ import { FunnelLogger } from "@/engine/logger/logger"
8
+ import { NodeFunnelLogger } from "@/engine/logger/node-logger"
9
+ import { FunnelMcp } from "@/engine/mcp/mcp"
10
+ import { FunnelProcessRunner } from "@/engine/process/process-runner"
11
+ import { NodeFunnelProcessRunner } from "@/engine/process/node-process-runner"
12
+ import { FunnelProfiles } from "@/engine/profiles/profiles"
13
+ import { FunnelSettingsReader } from "@/engine/settings/settings-reader"
14
+ import { FUNNEL_DIR, FunnelSettingsStore } from "@/engine/settings/settings-store"
15
+ import type { FunnelClock } from "@/engine/time/clock"
16
+ import { FunnelGateway } from "@/gateway/gateway"
17
+ import { FunnelGatewayServer } from "@/gateway/gateway-server"
18
+ import { FunnelGatewayToken } from "@/gateway/gateway-token"
19
+ import { FunnelListenersClient } from "@/gateway/listeners-client"
15
20
 
16
21
  type Props = {
17
- store: FunnelSettingsReader
22
+ /** Settings persistence (channels with nested connectors / profiles). Defaults to a FunnelSettingsStore rooted at `dir`. */
23
+ store?: FunnelSettingsReader
24
+ /** Filesystem boundary. Replace with MemoryFunnelFileSystem to sandbox all disk I/O. */
18
25
  fs?: FunnelFileSystem
26
+ /** Process runner used by gateway / claude / gh listener. Replace with MemoryFunnelProcessRunner for tests. */
27
+ process?: FunnelProcessRunner
28
+ /** Logger flowed into every facet. Replace with MemoryFunnelLogger or NoopFunnelLogger to silence/inspect. */
29
+ logger?: FunnelLogger
30
+ /** Clock used by schedule listener, gh poll watermarks, and gateway timeouts. */
31
+ clock?: FunnelClock
32
+ /** ID generator for channel and connector ids. Use MemoryFunnelIdGenerator for deterministic tests. */
33
+ idGenerator?: FunnelIdGenerator
34
+ /** Funnel home directory (settings.json + per-channel/per-connector dirs). Defaults to ~/.funnel. */
19
35
  dir?: string
20
- connectorStores?: ConnectorStoresBundle
36
+ /** Temp / runtime directory (gateway logs and PID adjacent files). Defaults to /tmp/funnel. */
37
+ tmpDir?: string
21
38
  }
22
39
 
40
+ /**
41
+ * Facade exposing every funnel facet as a getter.
42
+ *
43
+ * The same `Funnel` is used by the CLI, the TUI, and as a programmable library.
44
+ * All side-effecting boundaries (filesystem, process, logger, clock, id, paths) are
45
+ * injectable via `Props` — passing memory implementations gives a fully sandboxed
46
+ * Funnel that touches no real disk, processes, or wall-clock time.
47
+ *
48
+ * Connectors live nested inside their owning channel (channels[].connectors[]),
49
+ * so connector CRUD is reached via `funnel.channels.addConnector(...)` etc.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * const funnel = new Funnel({})
54
+ * const channel = funnel.channels.add({ name: "inbox" })
55
+ * funnel.channels.addConnector("inbox", { type: "slack", name: "ops", botToken, appToken })
56
+ * await funnel.gatewayServer({ port: 9742 }).start()
57
+ * ```
58
+ */
23
59
  export class Funnel {
24
- constructor(private readonly props: Props) {
60
+ constructor(private readonly props: Props = {}) {
25
61
  Object.freeze(this)
26
62
  }
27
63
 
28
- get stores(): ConnectorStoresBundle {
64
+ /** Settings reader. If not injected, a FunnelSettingsStore rooted at `dir` is created. */
65
+ get store(): FunnelSettingsReader {
29
66
  return (
30
- this.props.connectorStores ??
31
- createConnectorStores({ fs: this.props.fs, dir: this.props.dir })
67
+ this.props.store ??
68
+ new FunnelSettingsStore({
69
+ path: join(this.props.dir ?? FUNNEL_DIR, "settings.json"),
70
+ fs: this.props.fs,
71
+ })
32
72
  )
33
73
  }
34
74
 
35
- get connectors(): FunnelConnectors {
36
- const stores = this.stores
37
- const profiles = this.profiles
38
- const channels: FunnelChannels = new FunnelChannels({
39
- store: this.props.store,
40
- connectorChecker: { has: (name) => connectors.has(name) },
41
- profileChecker: profiles,
42
- profileRefUpdater: profiles,
43
- })
44
- const connectors: FunnelConnectors = new FunnelConnectors({
45
- ...stores,
46
- refUpdater: channels,
47
- })
48
- return connectors
75
+ /** Process runner boundary. Defaults to NodeFunnelProcessRunner. */
76
+ get process(): FunnelProcessRunner {
77
+ return this.props.process ?? new NodeFunnelProcessRunner()
49
78
  }
50
79
 
51
- get channels(): FunnelChannels {
52
- const stores = this.stores
53
- const profiles = this.profiles
54
- const channels: FunnelChannels = new FunnelChannels({
55
- store: this.props.store,
56
- connectorChecker: { has: (name) => connectors.has(name) },
57
- profileChecker: profiles,
58
- profileRefUpdater: profiles,
59
- })
60
- const connectors: FunnelConnectors = new FunnelConnectors({
61
- ...stores,
62
- refUpdater: channels,
80
+ /** Logger boundary. Defaults to NodeFunnelLogger. */
81
+ get logger(): FunnelLogger {
82
+ return this.props.logger ?? new NodeFunnelLogger()
83
+ }
84
+
85
+ /** Pure factory that constructs per-type listeners and adapters from connector configs. */
86
+ get factory(): FunnelConnectorFactory {
87
+ return new FunnelConnectorFactory({
88
+ fs: this.props.fs,
89
+ process: this.props.process,
90
+ logger: this.props.logger,
91
+ dir: this.props.dir,
63
92
  })
64
- return channels
65
93
  }
66
94
 
67
- get schedule(): FunnelSchedule {
68
- return new FunnelSchedule({ store: this.stores.schedule })
95
+ /** Channel CRUD + nested connector CRUD + schedule entries + listener/adapter dispatch. */
96
+ get channels(): FunnelChannels {
97
+ return new FunnelChannels({
98
+ store: this.store,
99
+ factory: this.factory,
100
+ profileChecker: this.profiles,
101
+ clock: this.props.clock,
102
+ idGenerator: this.props.idGenerator,
103
+ })
69
104
  }
70
105
 
106
+ /** Launch profiles (named presets for `fnl claude`: path + sub-agent + channel id). */
71
107
  get profiles(): FunnelProfiles {
72
- return new FunnelProfiles({ store: this.props.store })
108
+ return new FunnelProfiles({ store: this.store })
73
109
  }
74
110
 
75
- get repositories(): FunnelRepositories {
76
- return new FunnelRepositories({ store: this.props.store, mcp: this.mcp })
111
+ /** funnel MCP installer (writes/removes `.mcp.json` entries in target repos). */
112
+ get mcp(): FunnelMcp {
113
+ return new FunnelMcp({ fs: this.props.fs })
77
114
  }
78
115
 
116
+ /** Launch Claude Code with a channel injected via env, MCP installed, gateway ensured. */
79
117
  get claude(): FunnelClaude {
80
118
  return new FunnelClaude({
81
119
  channels: this.channels,
82
- repositories: this.repositories,
83
120
  mcp: this.mcp,
84
121
  gateway: this.gateway,
122
+ fs: this.props.fs,
123
+ process: this.props.process,
124
+ logger: this.props.logger,
125
+ dir: this.props.dir,
85
126
  })
86
127
  }
87
128
 
129
+ /** Gateway daemon controller (PID-file, start/stop the separate `bun daemon.ts` process). */
88
130
  get gateway(): FunnelGateway {
89
- return new FunnelGateway()
131
+ return new FunnelGateway({
132
+ fs: this.props.fs,
133
+ process: this.props.process,
134
+ clock: this.props.clock,
135
+ dir: this.props.dir,
136
+ tmpDir: this.props.tmpDir,
137
+ })
90
138
  }
91
139
 
92
- get mcp(): FunnelMcp {
93
- return new FunnelMcp()
140
+ /** Read / generate the daemon's gateway token (mode 0600 file under `dir`). */
141
+ get gatewayToken(): FunnelGatewayToken {
142
+ return new FunnelGatewayToken({ fs: this.props.fs, dir: this.props.dir })
143
+ }
144
+
145
+ /**
146
+ * HTTP client for listener operations on the running gateway daemon.
147
+ * Returns `{ state: "offline" }` when the daemon is offline so hot-reload
148
+ * paths stay write-only without parsing strings.
149
+ */
150
+ get listeners(): FunnelListenersClient {
151
+ const gateway = this.gateway
152
+ const token = this.gatewayToken
153
+
154
+ return new FunnelListenersClient({
155
+ port: gateway.getPort(),
156
+ isDaemonRunning: () => gateway.isRunning(),
157
+ getToken: () => token.read(),
158
+ })
159
+ }
160
+
161
+ /**
162
+ * In-process gateway server. Unlike `gateway.start()` (which spawns a daemon),
163
+ * this returns a class that runs `Bun.serve` + listeners inside the current process —
164
+ * useful for tests, embedding, or custom hosts.
165
+ */
166
+ gatewayServer(
167
+ options: {
168
+ port?: number
169
+ logDir?: string
170
+ killCompetingSlack?: boolean
171
+ /** Override the auth token. Defaults to the persisted gateway.token. Pass "" to disable auth (tests). */
172
+ token?: string
173
+ } = {},
174
+ ): FunnelGatewayServer {
175
+ return new FunnelGatewayServer({
176
+ channels: this.channels,
177
+ settings: this.store,
178
+ port: options.port,
179
+ logDir: options.logDir,
180
+ process: this.props.process,
181
+ clock: this.props.clock,
182
+ logger: this.props.logger,
183
+ killCompetingSlack: options.killCompetingSlack,
184
+ token: options.token ?? this.gatewayToken.ensure(),
185
+ })
94
186
  }
95
187
  }
@@ -0,0 +1,44 @@
1
+ import { timingSafeEqual } from "node:crypto"
2
+ import type { MiddlewareHandler } from "hono"
3
+ import type { Env } from "@/gateway/factory"
4
+
5
+ type Deps = {
6
+ expected: string
7
+ }
8
+
9
+ /**
10
+ * Verifies `Authorization: Bearer <token>` against the daemon's gateway token.
11
+ * Mounted on the routes that mutate listener state or expose detailed status.
12
+ * `/health` is intentionally left unauthenticated so the daemon manager can
13
+ * probe liveness without needing the token.
14
+ */
15
+ export const requireBearerToken = (deps: Deps): MiddlewareHandler<Env> => {
16
+ return async (c, next) => {
17
+ const header = c.req.header("authorization") ?? ""
18
+ const match = header.match(/^Bearer\s+(.+)$/i)
19
+ const presented = match?.[1] ?? ""
20
+
21
+ if (!constantTimeEqual(presented, deps.expected)) {
22
+ return c.text("unauthorized", 401)
23
+ }
24
+
25
+ return await next()
26
+ }
27
+ }
28
+
29
+ export const constantTimeEqual = (a: string, b: string): boolean => {
30
+ const bufA = Buffer.from(a, "utf-8")
31
+ const bufB = Buffer.from(b, "utf-8")
32
+ const maxLen = Math.max(bufA.length, bufB.length, 1)
33
+ const padA = Buffer.alloc(maxLen)
34
+ const padB = Buffer.alloc(maxLen)
35
+
36
+ bufA.copy(padA)
37
+ bufB.copy(padB)
38
+
39
+ // timingSafeEqual on equal-length padded buffers, then AND with length match
40
+ // so a length-only probe still requires the full comparison time.
41
+ const equal = timingSafeEqual(padA, padB)
42
+
43
+ return equal && bufA.length === bufB.length
44
+ }
@@ -0,0 +1,319 @@
1
+ import type { ServerWebSocket } from "bun"
2
+ import { FunnelLogger } from "@/engine/logger/logger"
3
+ import { NoopFunnelLogger } from "@/engine/logger/noop-logger"
4
+
5
+ const byteLengthOf = (event: { content: string; meta?: Record<string, string> }): number => {
6
+ let bytes = Buffer.byteLength(event.content, "utf-8")
7
+
8
+ if (event.meta) {
9
+ for (const [k, v] of Object.entries(event.meta)) {
10
+ bytes += Buffer.byteLength(k, "utf-8") + Buffer.byteLength(v, "utf-8")
11
+ }
12
+ }
13
+
14
+ return bytes
15
+ }
16
+
17
+ type ClientData = {
18
+ /** Stable channel id (uuid) that the WS client subscribed to. */
19
+ channel: string
20
+ /** Human-facing channel name resolved at upgrade time, kept for log readability. */
21
+ channelName?: string | null
22
+ /** Connector names belonging to that channel; used by tap-all replay filtering. */
23
+ connectors: string[]
24
+ tapAll?: boolean
25
+ /** Routing mode resolved from channel config at upgrade time. Defaults to fanout. */
26
+ delivery?: "fanout" | "exclusive"
27
+ }
28
+
29
+ export type BroadcastEvent = {
30
+ content: string
31
+ meta?: Record<string, string>
32
+ }
33
+
34
+ export type ReplayableEvent = BroadcastEvent & { offset: number }
35
+
36
+ export type BroadcastSubscriber = (event: ReplayableEvent) => void
37
+
38
+ /**
39
+ * Optional persistent replay source. Wired in by the gateway-server with
40
+ * `FunnelEventStore` (SQLite-backed) so reconnects across daemon restarts
41
+ * can recover events older than the in-memory buffer via an indexed
42
+ * `seq > since` range scan.
43
+ */
44
+ type ReplaySource = {
45
+ loadSince(since: number): ReplayableEvent[]
46
+ }
47
+
48
+ type Deps = {
49
+ logger?: FunnelLogger
50
+ maxBufferedBytes?: number
51
+ now?: () => number
52
+ /** Number of recent events kept in the in-memory replay buffer. */
53
+ replayBufferSize?: number
54
+ /** Hard byte cap on replay buffer payloads. Older events are evicted FIFO until under this cap. */
55
+ replayBufferMaxBytes?: number
56
+ /** Persistent replay source consulted when the in-memory buffer cannot satisfy `since`. */
57
+ persistentReplay?: ReplaySource
58
+ }
59
+
60
+ const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024
61
+ const DEFAULT_REPLAY_BUFFER_SIZE = 200
62
+ const DEFAULT_REPLAY_BUFFER_MAX_BYTES = 4 * 1024 * 1024
63
+ const defaultLogger = new NoopFunnelLogger()
64
+
65
+ type BroadcasterMetrics = {
66
+ clients: number
67
+ subscribers: number
68
+ eventsBroadcast: number
69
+ droppedSlowClients: number
70
+ lastBroadcastAt: string | null
71
+ /** Latest emitted offset. Clients can `?since=<offset>` to ask for events strictly after this point. */
72
+ latestOffset: number
73
+ /** Oldest offset still held in the replay buffer. Older values cannot be replayed and trigger a full resync. */
74
+ oldestReplayableOffset: number | null
75
+ }
76
+
77
+ /**
78
+ * In-process pub/sub for connector events.
79
+ *
80
+ * Two outbound paths:
81
+ * - WS clients connected via the gateway's `/ws` endpoint, scoped per channel
82
+ * - In-process subscribers registered via `subscribe()` (programmable API)
83
+ *
84
+ * Backpressure: if a WS client's `bufferedAmount` exceeds `maxBufferedBytes`
85
+ * (default 1 MiB), the client is closed with code 1009 and dropped from the
86
+ * registry to keep one slow consumer from blocking the daemon.
87
+ *
88
+ * Replay: every emitted event gets a strictly increasing `offset`. The latest
89
+ * `replayBufferSize` events are kept in memory; reconnecting WS clients can
90
+ * pass `?since=<offset>` and the broadcaster resends matching events before
91
+ * resuming the live stream. The in-memory ring covers short reconnects;
92
+ * older history is served from the SQLite event store wired in as
93
+ * `persistentReplay`.
94
+ */
95
+ export class FunnelBroadcaster {
96
+ private readonly clients: Map<ServerWebSocket<unknown>, ClientData> = new Map()
97
+ private readonly subscribers: Set<BroadcastSubscriber> = new Set()
98
+ private readonly logger: FunnelLogger
99
+ private readonly maxBufferedBytes: number
100
+ private readonly now: () => number
101
+ private readonly replayBufferSize: number
102
+ private readonly replayBufferMaxBytes: number
103
+ private readonly replayBuffer: ReplayableEvent[] = []
104
+ private readonly persistentReplay: ReplaySource | null
105
+ private readonly exclusiveCursor = new Map<string, number>()
106
+ private replayBufferBytes = 0
107
+ private eventsBroadcast = 0
108
+ private droppedSlowClients = 0
109
+ private lastBroadcastAt: number | null = null
110
+ private latestOffset = 0
111
+
112
+ constructor(deps: Deps = {}) {
113
+ this.logger = deps.logger ?? defaultLogger
114
+ this.maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES
115
+ this.now = deps.now ?? (() => Date.now())
116
+ this.replayBufferSize = Math.max(0, deps.replayBufferSize ?? DEFAULT_REPLAY_BUFFER_SIZE)
117
+ this.replayBufferMaxBytes = Math.max(
118
+ 0,
119
+ deps.replayBufferMaxBytes ?? DEFAULT_REPLAY_BUFFER_MAX_BYTES,
120
+ )
121
+ this.persistentReplay = deps.persistentReplay ?? null
122
+ }
123
+
124
+ getMetrics(): BroadcasterMetrics {
125
+ return {
126
+ clients: this.clients.size,
127
+ subscribers: this.subscribers.size,
128
+ eventsBroadcast: this.eventsBroadcast,
129
+ droppedSlowClients: this.droppedSlowClients,
130
+ lastBroadcastAt: this.lastBroadcastAt ? new Date(this.lastBroadcastAt).toISOString() : null,
131
+ latestOffset: this.latestOffset,
132
+ oldestReplayableOffset: this.replayBuffer[0]?.offset ?? null,
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Returns events with offset > since, filtered by the connector subscription
138
+ * rules of `data`. Used at WS upgrade time when the client passes `?since=<offset>`.
139
+ *
140
+ * Two-tier lookup:
141
+ * 1. The in-memory ring buffer (covers short reconnects, last `replayBufferSize` events).
142
+ * 2. If `since` predates the oldest in-memory entry and a persistent replay source
143
+ * is wired in (SQLite), the gap is filled from disk. This covers reconnects across
144
+ * daemon restarts where the in-memory buffer was lost.
145
+ *
146
+ * Result is sorted ascending by offset and de-duplicated against the in-memory buffer.
147
+ */
148
+ replaySince(since: number, data: ClientData): ReplayableEvent[] {
149
+ const oldestInMemory = this.replayBuffer[0]?.offset
150
+ const needFallback =
151
+ this.persistentReplay && (oldestInMemory === undefined || since < oldestInMemory - 1)
152
+ const fromMemory = this.replayBuffer.filter(
153
+ (event) => event.offset > since && this.matchesClient(event, data),
154
+ )
155
+
156
+ if (!needFallback) return fromMemory
157
+
158
+ const persisted = this.persistentReplay
159
+ ? this.persistentReplay.loadSince(since).filter((event) => this.matchesClient(event, data))
160
+ : []
161
+ const cutoff = oldestInMemory ?? Number.POSITIVE_INFINITY
162
+ const beforeMemory = persisted.filter((event) => event.offset < cutoff)
163
+
164
+ return [...beforeMemory, ...fromMemory]
165
+ }
166
+
167
+ private matchesClient(event: BroadcastEvent, data: ClientData): boolean {
168
+ if (data.tapAll) return true
169
+
170
+ const channelId = event.meta?.channelId
171
+
172
+ if (channelId && channelId !== data.channel) return false
173
+
174
+ const connector = event.meta?.connector
175
+
176
+ if (!connector) return true
177
+
178
+ return data.connectors.includes(connector)
179
+ }
180
+
181
+ /**
182
+ * Returns the list of WS clients that should receive `event`. Tap=all clients always
183
+ * receive (passive observation). For each per-channel group:
184
+ * - fanout → every matching client receives
185
+ * - exclusive → exactly one client receives, picked round-robin per channel
186
+ */
187
+ private pickRecipients(event: BroadcastEvent): ServerWebSocket<unknown>[] {
188
+ const exclusiveByChannel = new Map<string, ServerWebSocket<unknown>[]>()
189
+ const recipients: ServerWebSocket<unknown>[] = []
190
+
191
+ for (const [ws, data] of this.clients) {
192
+ if (!this.matchesClient(event, data)) continue
193
+
194
+ if (data.tapAll) {
195
+ recipients.push(ws)
196
+ continue
197
+ }
198
+
199
+ if (data.delivery === "exclusive") {
200
+ const list = exclusiveByChannel.get(data.channel) ?? []
201
+
202
+ list.push(ws)
203
+ exclusiveByChannel.set(data.channel, list)
204
+ continue
205
+ }
206
+
207
+ recipients.push(ws)
208
+ }
209
+
210
+ for (const [channel, candidates] of exclusiveByChannel) {
211
+ if (candidates.length === 0) continue
212
+
213
+ const cursor = this.exclusiveCursor.get(channel) ?? 0
214
+ const picked = candidates[cursor % candidates.length]
215
+
216
+ if (picked) recipients.push(picked)
217
+
218
+ this.exclusiveCursor.set(channel, cursor + 1)
219
+ }
220
+
221
+ return recipients
222
+ }
223
+
224
+ addClient(ws: ServerWebSocket<unknown>, data: ClientData): void {
225
+ this.clients.set(ws, data)
226
+ }
227
+
228
+ removeClient(ws: ServerWebSocket<unknown>): void {
229
+ this.clients.delete(ws)
230
+ }
231
+
232
+ getClientCount(): number {
233
+ return this.clients.size
234
+ }
235
+
236
+ listChannels(): { channel: string; connectors: string[] }[] {
237
+ return [...this.clients.values()].map((d) => ({ ...d }))
238
+ }
239
+
240
+ subscribe(handler: BroadcastSubscriber): () => void {
241
+ this.subscribers.add(handler)
242
+
243
+ return () => {
244
+ this.subscribers.delete(handler)
245
+ }
246
+ }
247
+
248
+ broadcast(content: string, meta?: Record<string, string>): ReplayableEvent {
249
+ this.latestOffset += 1
250
+ const event: ReplayableEvent = { content, meta, offset: this.latestOffset }
251
+ const payload = JSON.stringify(event)
252
+ const connector = meta?.connector
253
+
254
+ this.eventsBroadcast += 1
255
+ this.lastBroadcastAt = this.now()
256
+
257
+ if (this.replayBufferSize > 0) {
258
+ const eventBytes = byteLengthOf(event)
259
+
260
+ this.replayBuffer.push(event)
261
+ this.replayBufferBytes += eventBytes
262
+
263
+ while (
264
+ (this.replayBuffer.length > this.replayBufferSize ||
265
+ this.replayBufferBytes > this.replayBufferMaxBytes) &&
266
+ this.replayBuffer.length > 0
267
+ ) {
268
+ const dropped = this.replayBuffer.shift()
269
+
270
+ if (dropped) this.replayBufferBytes -= byteLengthOf(dropped)
271
+ }
272
+ }
273
+
274
+ const recipients = this.pickRecipients(event)
275
+
276
+ for (const ws of recipients) {
277
+ const buffered = ws.getBufferedAmount()
278
+
279
+ if (buffered > this.maxBufferedBytes) {
280
+ const data = this.clients.get(ws)
281
+
282
+ this.logger.warn("dropping slow WS client (backpressure)", {
283
+ channel: data?.channel,
284
+ buffered,
285
+ max: this.maxBufferedBytes,
286
+ })
287
+
288
+ try {
289
+ ws.close(1009, "backpressure")
290
+ } catch {
291
+ // ignore
292
+ }
293
+
294
+ this.clients.delete(ws)
295
+ this.droppedSlowClients += 1
296
+ continue
297
+ }
298
+
299
+ ws.send(payload)
300
+ }
301
+
302
+ for (const handler of this.subscribers) {
303
+ try {
304
+ handler(event)
305
+ } catch (error) {
306
+ this.logger.error("broadcast subscriber threw", {
307
+ error: error instanceof Error ? error.message : String(error),
308
+ })
309
+ }
310
+ }
311
+
312
+ return event
313
+ }
314
+
315
+ /** Forward-seed the offset counter (used at startup from the persisted event store). */
316
+ seedLatestOffset(offset: number): void {
317
+ if (offset > this.latestOffset) this.latestOffset = offset
318
+ }
319
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { FUNNEL_DIR } from "@/engine/settings/settings-store"
5
+ import { Funnel } from "@/funnel"
6
+ import { NodeFunnelLogger } from "@/engine/logger/node-logger"
7
+
8
+ const PORT = Number(process.env.FUNNEL_PORT) || 9742
9
+ const PID_FILE = join(FUNNEL_DIR, "gateway.pid")
10
+ const LOG_DIR = "/tmp/funnel/events"
11
+
12
+ const logger = new NodeFunnelLogger()
13
+
14
+ mkdirSync(FUNNEL_DIR, { recursive: true })
15
+
16
+ if (existsSync(PID_FILE)) {
17
+ const existing = Number(readFileSync(PID_FILE, "utf-8").trim())
18
+
19
+ if (existing > 0) {
20
+ const check = Bun.spawnSync(["ps", "-p", String(existing), "-o", "state="], {
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ })
24
+
25
+ if (check.exitCode === 0 && check.stdout.toString().trim()) {
26
+ logger.error("funnel gateway already running", { pid: existing })
27
+ process.exit(1)
28
+ }
29
+ }
30
+ }
31
+
32
+ writeFileSync(PID_FILE, String(process.pid))
33
+
34
+ process.on("exit", () => {
35
+ try {
36
+ unlinkSync(PID_FILE)
37
+ } catch {
38
+ // ignore
39
+ }
40
+ })
41
+ process.on("SIGINT", () => process.exit(130))
42
+ process.on("SIGTERM", () => process.exit(143))
43
+
44
+ const funnel = new Funnel({ logger })
45
+ const server = funnel.gatewayServer({ port: PORT, logDir: LOG_DIR })
46
+
47
+ await server.start()