@interactive-inc/claude-funnel 0.2.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/lib/factory.ts +10 -0
  4. package/lib/funnel.ts +51 -0
  5. package/lib/index.ts +86 -0
  6. package/lib/modules/agents/funnel-agents.ts +105 -0
  7. package/lib/modules/channels/funnel-channels.ts +113 -0
  8. package/lib/modules/claude/funnel-claude.ts +136 -0
  9. package/lib/modules/connectors/funnel-connector-adapter.ts +9 -0
  10. package/lib/modules/connectors/funnel-connector-listener.ts +5 -0
  11. package/lib/modules/connectors/funnel-connectors.ts +124 -0
  12. package/lib/modules/connectors/funnel-discord-adapter.ts +56 -0
  13. package/lib/modules/connectors/funnel-discord-event-processor.ts +48 -0
  14. package/lib/modules/connectors/funnel-discord-listener.ts +65 -0
  15. package/lib/modules/connectors/funnel-gh-adapter.ts +51 -0
  16. package/lib/modules/connectors/funnel-gh-listener.ts +102 -0
  17. package/lib/modules/connectors/funnel-slack-adapter.ts +31 -0
  18. package/lib/modules/connectors/funnel-slack-event-processor.ts +91 -0
  19. package/lib/modules/connectors/funnel-slack-listener.ts +72 -0
  20. package/lib/modules/connectors/resolve-listener.ts +13 -0
  21. package/lib/modules/fs/funnel-file-system.ts +14 -0
  22. package/lib/modules/fs/memory-funnel-file-system.ts +85 -0
  23. package/lib/modules/fs/node-funnel-file-system.ts +56 -0
  24. package/lib/modules/gateway/daemon.ts +190 -0
  25. package/lib/modules/gateway/funnel-broadcaster.ts +37 -0
  26. package/lib/modules/gateway/funnel-event-logger.ts +59 -0
  27. package/lib/modules/gateway/funnel-gateway.ts +166 -0
  28. package/lib/modules/gateway/kill-competing-slack-gateways.ts +52 -0
  29. package/lib/modules/http/funnel-http-client.ts +17 -0
  30. package/lib/modules/http/memory-funnel-http-client.ts +40 -0
  31. package/lib/modules/http/node-funnel-http-client.ts +27 -0
  32. package/lib/modules/logger.ts +26 -0
  33. package/lib/modules/mcp/channel-server.ts +77 -0
  34. package/lib/modules/mcp/funnel-mcp.ts +107 -0
  35. package/lib/modules/process/funnel-process-runner.ts +28 -0
  36. package/lib/modules/process/memory-funnel-process-runner.ts +88 -0
  37. package/lib/modules/process/node-funnel-process-runner.ts +100 -0
  38. package/lib/modules/repos/funnel-repositories.ts +107 -0
  39. package/lib/modules/router/query-to-cli-args.ts +20 -0
  40. package/lib/modules/router/to-request.ts +122 -0
  41. package/lib/modules/router/validator.ts +27 -0
  42. package/lib/modules/settings/funnel-settings-reader.ts +6 -0
  43. package/lib/modules/settings/funnel-settings-store.ts +57 -0
  44. package/lib/modules/settings/mock-funnel-settings-reader.ts +27 -0
  45. package/lib/modules/settings/settings-schema.ts +67 -0
  46. package/lib/modules/tui/app.tsx +44 -0
  47. package/lib/modules/tui/tui.tsx +13 -0
  48. package/lib/routes/agents/add.help.ts +3 -0
  49. package/lib/routes/agents/add.ts +33 -0
  50. package/lib/routes/agents/group.help.ts +13 -0
  51. package/lib/routes/agents/group.ts +25 -0
  52. package/lib/routes/agents/launch.help.ts +3 -0
  53. package/lib/routes/agents/launch.ts +35 -0
  54. package/lib/routes/agents/remove.help.ts +3 -0
  55. package/lib/routes/agents/remove.ts +17 -0
  56. package/lib/routes/agents/rename.help.ts +5 -0
  57. package/lib/routes/agents/rename.ts +17 -0
  58. package/lib/routes/agents/routes.ts +17 -0
  59. package/lib/routes/agents/set.help.ts +5 -0
  60. package/lib/routes/agents/set.ts +32 -0
  61. package/lib/routes/channels/add.help.ts +3 -0
  62. package/lib/routes/channels/add.ts +21 -0
  63. package/lib/routes/channels/connectors-attach.help.ts +3 -0
  64. package/lib/routes/channels/connectors-attach.ts +17 -0
  65. package/lib/routes/channels/connectors-detach.help.ts +3 -0
  66. package/lib/routes/channels/connectors-detach.ts +17 -0
  67. package/lib/routes/channels/group.help.ts +16 -0
  68. package/lib/routes/channels/group.ts +22 -0
  69. package/lib/routes/channels/remove.help.ts +3 -0
  70. package/lib/routes/channels/remove.ts +17 -0
  71. package/lib/routes/channels/rename.help.ts +5 -0
  72. package/lib/routes/channels/rename.ts +17 -0
  73. package/lib/routes/channels/routes.ts +19 -0
  74. package/lib/routes/channels/show.help.ts +1 -0
  75. package/lib/routes/channels/show.ts +26 -0
  76. package/lib/routes/claude/claude.help.ts +11 -0
  77. package/lib/routes/claude/claude.ts +39 -0
  78. package/lib/routes/claude/routes.ts +4 -0
  79. package/lib/routes/connectors/add.help.ts +22 -0
  80. package/lib/routes/connectors/add.ts +55 -0
  81. package/lib/routes/connectors/call.help.ts +17 -0
  82. package/lib/routes/connectors/call.ts +43 -0
  83. package/lib/routes/connectors/group.help.ts +14 -0
  84. package/lib/routes/connectors/group.ts +18 -0
  85. package/lib/routes/connectors/remove.help.ts +3 -0
  86. package/lib/routes/connectors/remove.ts +17 -0
  87. package/lib/routes/connectors/rename.help.ts +5 -0
  88. package/lib/routes/connectors/rename.ts +17 -0
  89. package/lib/routes/connectors/routes.ts +19 -0
  90. package/lib/routes/connectors/set.help.ts +8 -0
  91. package/lib/routes/connectors/set.ts +30 -0
  92. package/lib/routes/connectors/show.help.ts +1 -0
  93. package/lib/routes/connectors/show.ts +32 -0
  94. package/lib/routes/gateway/group.help.ts +15 -0
  95. package/lib/routes/gateway/group.ts +28 -0
  96. package/lib/routes/gateway/logs.help.ts +13 -0
  97. package/lib/routes/gateway/logs.ts +100 -0
  98. package/lib/routes/gateway/restart.help.ts +10 -0
  99. package/lib/routes/gateway/restart.ts +35 -0
  100. package/lib/routes/gateway/routes.ts +18 -0
  101. package/lib/routes/gateway/run.help.ts +12 -0
  102. package/lib/routes/gateway/run.ts +35 -0
  103. package/lib/routes/gateway/start.help.ts +15 -0
  104. package/lib/routes/gateway/start.ts +32 -0
  105. package/lib/routes/gateway/status.help.ts +9 -0
  106. package/lib/routes/gateway/status.ts +28 -0
  107. package/lib/routes/gateway/stop.help.ts +8 -0
  108. package/lib/routes/gateway/stop.ts +21 -0
  109. package/lib/routes/repos/add.help.ts +5 -0
  110. package/lib/routes/repos/add.ts +19 -0
  111. package/lib/routes/repos/group.help.ts +11 -0
  112. package/lib/routes/repos/group.ts +18 -0
  113. package/lib/routes/repos/remove.help.ts +3 -0
  114. package/lib/routes/repos/remove.ts +17 -0
  115. package/lib/routes/repos/rename.help.ts +5 -0
  116. package/lib/routes/repos/rename.ts +17 -0
  117. package/lib/routes/repos/routes.ts +17 -0
  118. package/lib/routes/repos/set.help.ts +5 -0
  119. package/lib/routes/repos/set.ts +21 -0
  120. package/lib/routes/repos/show.help.ts +1 -0
  121. package/lib/routes/repos/show.ts +19 -0
  122. package/lib/routes/status/routes.ts +4 -0
  123. package/lib/routes/status/status.help.ts +6 -0
  124. package/lib/routes/status/status.ts +77 -0
  125. package/lib/routes.ts +36 -0
  126. package/package.json +65 -0
@@ -0,0 +1,85 @@
1
+ import { type FileStat, FunnelFileSystem } from "@/modules/fs/funnel-file-system"
2
+
3
+ type Props = {
4
+ dirs?: string[]
5
+ files?: Record<string, string>
6
+ mtimes?: Record<string, number>
7
+ now?: () => number
8
+ }
9
+
10
+ export class MemoryFunnelFileSystem extends FunnelFileSystem {
11
+ private readonly dirs: Set<string>
12
+ private readonly files: Map<string, string>
13
+ private readonly mtimes: Map<string, number>
14
+ private readonly now: () => number
15
+
16
+ constructor(props: Props = {}) {
17
+ super()
18
+ this.dirs = new Set(props.dirs ?? [])
19
+ this.files = new Map(Object.entries(props.files ?? {}))
20
+ this.mtimes = new Map(Object.entries(props.mtimes ?? {}))
21
+ this.now = props.now ?? (() => Date.now())
22
+ }
23
+
24
+ existsSync(path: string): boolean {
25
+ return this.dirs.has(path) || this.files.has(path)
26
+ }
27
+
28
+ readFileSync(path: string): string {
29
+ return this.files.get(path) ?? ""
30
+ }
31
+
32
+ writeFileSync(path: string, data: string): void {
33
+ this.files.set(path, data)
34
+ this.touch(path)
35
+ }
36
+
37
+ appendFileSync(path: string, data: string): void {
38
+ const prev = this.files.get(path) ?? ""
39
+ this.files.set(path, prev + data)
40
+ this.touch(path)
41
+ }
42
+
43
+ unlink(path: string): void {
44
+ this.files.delete(path)
45
+ this.mtimes.delete(path)
46
+ }
47
+
48
+ mkdirSync(path: string): void {
49
+ this.dirs.add(path)
50
+ }
51
+
52
+ readdirSync(path: string): string[] {
53
+ const prefix = path.endsWith("/") ? path : `${path}/`
54
+ const names: string[] = []
55
+
56
+ for (const file of this.files.keys()) {
57
+ if (!file.startsWith(prefix)) continue
58
+
59
+ const rest = file.slice(prefix.length)
60
+
61
+ if (!rest.includes("/")) names.push(rest)
62
+ }
63
+
64
+ return names
65
+ }
66
+
67
+ statSync(path: string): FileStat {
68
+ const mtimeMs = this.mtimes.get(path)
69
+
70
+ if (mtimeMs === undefined) {
71
+ throw new Error(`not found: ${path}`)
72
+ }
73
+
74
+ return { mtimeMs }
75
+ }
76
+
77
+ setMtime(path: string, mtimeMs: number): void {
78
+ this.mtimes.set(path, mtimeMs)
79
+ }
80
+
81
+ private touch(path: string): void {
82
+ if (!this.mtimes.has(path)) this.mtimes.set(path, this.now())
83
+ else this.mtimes.set(path, this.now())
84
+ }
85
+ }
@@ -0,0 +1,56 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ statSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs"
11
+ import { type FileStat, FunnelFileSystem } from "@/modules/fs/funnel-file-system"
12
+
13
+ export class NodeFunnelFileSystem extends FunnelFileSystem {
14
+ constructor() {
15
+ super()
16
+ Object.freeze(this)
17
+ }
18
+
19
+ existsSync(path: string): boolean {
20
+ return existsSync(path)
21
+ }
22
+
23
+ readFileSync(path: string): string {
24
+ return readFileSync(path, "utf-8")
25
+ }
26
+
27
+ writeFileSync(path: string, data: string): void {
28
+ writeFileSync(path, data)
29
+ }
30
+
31
+ appendFileSync(path: string, data: string): void {
32
+ appendFileSync(path, data)
33
+ }
34
+
35
+ unlink(path: string): void {
36
+ try {
37
+ unlinkSync(path)
38
+ } catch {
39
+ // ignore
40
+ }
41
+ }
42
+
43
+ mkdirSync(path: string, options?: { recursive?: boolean }): void {
44
+ mkdirSync(path, { recursive: options?.recursive ?? false })
45
+ }
46
+
47
+ readdirSync(path: string): string[] {
48
+ return readdirSync(path)
49
+ }
50
+
51
+ statSync(path: string): FileStat {
52
+ const stat = statSync(path)
53
+
54
+ return { mtimeMs: stat.mtimeMs }
55
+ }
56
+ }
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import type { ServerWebSocket } from "bun"
5
+ import { Hono } from "hono"
6
+ import { logger } from "@/modules/logger"
7
+ import { resolveListener } from "@/modules/connectors/resolve-listener"
8
+ import { FunnelBroadcaster } from "@/modules/gateway/funnel-broadcaster"
9
+ import { FunnelEventLogger } from "@/modules/gateway/funnel-event-logger"
10
+ import { killCompetingSlackGateways } from "@/modules/gateway/kill-competing-slack-gateways"
11
+ import { FUNNEL_DIR, FunnelSettingsStore } from "@/modules/settings/funnel-settings-store"
12
+
13
+ const PORT = Number(process.env.FUNNEL_PORT) || 9742
14
+ const PID_FILE = join(FUNNEL_DIR, "gateway.pid")
15
+ const LOG_DIR = "/tmp/funnel/events"
16
+
17
+ mkdirSync(FUNNEL_DIR, { recursive: true })
18
+
19
+ if (existsSync(PID_FILE)) {
20
+ const existing = Number(readFileSync(PID_FILE, "utf-8").trim())
21
+
22
+ if (existing > 0) {
23
+ const check = Bun.spawnSync(["ps", "-p", String(existing), "-o", "state="], {
24
+ stdout: "pipe",
25
+ stderr: "pipe",
26
+ })
27
+
28
+ if (check.exitCode === 0 && check.stdout.toString().trim()) {
29
+ logger.error(`funnel gateway already running`, { pid: existing })
30
+ process.exit(1)
31
+ }
32
+ }
33
+ }
34
+
35
+ writeFileSync(PID_FILE, String(process.pid))
36
+
37
+ process.on("exit", () => {
38
+ try {
39
+ unlinkSync(PID_FILE)
40
+ } catch {
41
+ // ignore
42
+ }
43
+ })
44
+ process.on("SIGINT", () => process.exit(130))
45
+ process.on("SIGTERM", () => process.exit(143))
46
+
47
+ const store = new FunnelSettingsStore()
48
+
49
+ const eventLogger = new FunnelEventLogger({ logDir: LOG_DIR })
50
+ const broadcaster = new FunnelBroadcaster()
51
+ const app = new Hono()
52
+
53
+ app.get("/health", (c) =>
54
+ c.json({
55
+ ok: true,
56
+ pid: process.pid,
57
+ clients: broadcaster.getClientCount(),
58
+ }),
59
+ )
60
+
61
+ app.get("/status", (c) =>
62
+ c.json({
63
+ ok: true,
64
+ clients: broadcaster.listChannels(),
65
+ }),
66
+ )
67
+
68
+ const resolveConnectors = (channelName: string): string[] => {
69
+ const settings = store.read()
70
+ const channel = settings?.channels.find((c) => c.name === channelName)
71
+
72
+ return channel?.connectors ?? []
73
+ }
74
+
75
+ type WsData = { channel: string; connectors: string[] }
76
+
77
+ Bun.serve<WsData>({
78
+ port: PORT,
79
+ fetch(request, server) {
80
+ const url = new URL(request.url)
81
+
82
+ if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
83
+ const channelName = url.searchParams.get("channel") ?? ""
84
+ const connectors = channelName ? resolveConnectors(channelName) : []
85
+ const data: WsData = { channel: channelName, connectors }
86
+
87
+ const upgraded = server.upgrade(request, { data })
88
+
89
+ if (upgraded) return undefined
90
+
91
+ return new Response("WebSocket upgrade failed", { status: 400 })
92
+ }
93
+
94
+ return app.fetch(request)
95
+ },
96
+ websocket: {
97
+ open(ws: ServerWebSocket<WsData>) {
98
+ const data = ws.data
99
+
100
+ broadcaster.addClient(ws, data)
101
+
102
+ eventLogger.log("channel connected", {
103
+ event_type: "system",
104
+ action: "channel_connect",
105
+ channel: data.channel,
106
+ connectors: data.connectors.join(","),
107
+ total: String(broadcaster.getClientCount()),
108
+ })
109
+ },
110
+ close(ws: ServerWebSocket<WsData>) {
111
+ broadcaster.removeClient(ws)
112
+
113
+ eventLogger.log("channel disconnected", {
114
+ event_type: "system",
115
+ action: "channel_disconnect",
116
+ total: String(broadcaster.getClientCount()),
117
+ })
118
+ },
119
+ message(_ws: ServerWebSocket<WsData>, _message: string | Buffer) {
120
+ // Future: channel → gateway messages
121
+ },
122
+ },
123
+ })
124
+
125
+ eventLogger.log("gateway started", {
126
+ event_type: "system",
127
+ action: "gateway_start",
128
+ port: String(PORT),
129
+ pid: String(process.pid),
130
+ })
131
+
132
+ logger.info(`funnel gateway listening`, {
133
+ url: `http://localhost:${PORT}`,
134
+ websocket: `ws://localhost:${PORT}/ws`,
135
+ health: `http://localhost:${PORT}/health`,
136
+ })
137
+
138
+ const notify = async (
139
+ connectorName: string,
140
+ content: string,
141
+ meta?: Record<string, string>,
142
+ ): Promise<void> => {
143
+ const withConnector: Record<string, string> = { ...meta, connector: connectorName }
144
+
145
+ eventLogger.log(content, withConnector)
146
+ broadcaster.broadcast(content, withConnector)
147
+ }
148
+
149
+ const settings = store.read()
150
+
151
+ // Multiple Slack Socket Mode connections sharing one App Token steal DMs/mentions
152
+ // from each other. Terminate other bun + gateway/bolt/slack processes first.
153
+ if (settings.connectors.some((c) => c.type === "slack")) {
154
+ const killed = await killCompetingSlackGateways({ selfPid: process.pid })
155
+
156
+ if (killed.length > 0) {
157
+ eventLogger.log("killed competing Slack gateway processes", {
158
+ event_type: "system",
159
+ action: "kill_competing",
160
+ pids: killed.join(","),
161
+ })
162
+ }
163
+ }
164
+
165
+ for (const connector of settings.connectors) {
166
+ const bind = (content: string, meta?: Record<string, string>) =>
167
+ notify(connector.name, content, meta)
168
+
169
+ try {
170
+ const listener = resolveListener(connector)
171
+
172
+ await listener.start(bind)
173
+
174
+ eventLogger.log(`${connector.type} listener started: ${connector.name}`, {
175
+ event_type: "system",
176
+ action: `${connector.type}_connect`,
177
+ connector: connector.name,
178
+ })
179
+
180
+ logger.info(`${connector.type} listener started`, { connector: connector.name })
181
+ } catch (error) {
182
+ logger.error(`${connector.type} listener failed`, {
183
+ connector: connector.name,
184
+ error: error instanceof Error ? error.message : String(error),
185
+ })
186
+ }
187
+ }
188
+
189
+ logger.info(`event logs: ${LOG_DIR}`)
190
+ logger.info("funnel gateway running")
@@ -0,0 +1,37 @@
1
+ import type { ServerWebSocket } from "bun"
2
+
3
+ type ClientData = {
4
+ channel: string
5
+ connectors: string[]
6
+ }
7
+
8
+ export class FunnelBroadcaster {
9
+ private readonly clients: Map<ServerWebSocket<unknown>, ClientData> = new Map()
10
+
11
+ addClient(ws: ServerWebSocket<unknown>, data: ClientData): void {
12
+ this.clients.set(ws, data)
13
+ }
14
+
15
+ removeClient(ws: ServerWebSocket<unknown>): void {
16
+ this.clients.delete(ws)
17
+ }
18
+
19
+ getClientCount(): number {
20
+ return this.clients.size
21
+ }
22
+
23
+ listChannels(): { channel: string; connectors: string[] }[] {
24
+ return [...this.clients.values()].map((d) => ({ ...d }))
25
+ }
26
+
27
+ broadcast(content: string, meta?: Record<string, string>): void {
28
+ const payload = JSON.stringify({ content, meta })
29
+ const connector = meta?.connector
30
+
31
+ for (const [ws, data] of this.clients) {
32
+ if (connector && !data.connectors.includes(connector)) continue
33
+
34
+ ws.send(payload)
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,59 @@
1
+ import { join } from "node:path"
2
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
3
+ import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
4
+
5
+ const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
6
+
7
+ type Deps = {
8
+ logDir: string
9
+ fs?: FunnelFileSystem
10
+ now?: () => number
11
+ }
12
+
13
+ const defaultFs = new NodeFunnelFileSystem()
14
+
15
+ export class FunnelEventLogger {
16
+ private readonly logDir: string
17
+ private readonly fs: FunnelFileSystem
18
+ private readonly now: () => number
19
+
20
+ constructor(deps: Deps) {
21
+ this.logDir = deps.logDir
22
+ this.fs = deps.fs ?? defaultFs
23
+ this.now = deps.now ?? (() => Date.now())
24
+ this.fs.mkdirSync(this.logDir, { recursive: true })
25
+ this.rotate()
26
+ Object.freeze(this)
27
+ }
28
+
29
+ log(content: string, meta?: Record<string, string>): void {
30
+ const entry = {
31
+ timestamp: new Date(this.now()).toISOString(),
32
+ eventType: meta?.event_type ?? "unknown",
33
+ content: content.length > 2000 ? `${content.slice(0, 2000)}...` : content,
34
+ meta,
35
+ }
36
+ const dateStr = new Date(this.now()).toISOString().slice(0, 10)
37
+ const logFile = join(this.logDir, `${dateStr}.jsonl`)
38
+
39
+ this.fs.appendFileSync(logFile, `${JSON.stringify(entry)}\n`)
40
+ }
41
+
42
+ private rotate(): void {
43
+ const now = this.now()
44
+
45
+ for (const name of this.fs.readdirSync(this.logDir)) {
46
+ if (!name.endsWith(".jsonl")) continue
47
+
48
+ const path = join(this.logDir, name)
49
+
50
+ try {
51
+ const stat = this.fs.statSync(path)
52
+
53
+ if (now - stat.mtimeMs > MAX_AGE_MS) this.fs.unlink(path)
54
+ } catch {
55
+ // ignore
56
+ }
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,166 @@
1
+ import { join, resolve } from "node:path"
2
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
3
+ import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
4
+ import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
5
+ import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
6
+ import { FUNNEL_DIR } from "@/modules/settings/funnel-settings-store"
7
+
8
+ const DEFAULT_PORT = 9742
9
+ const PID_FILE = join(FUNNEL_DIR, "gateway.pid")
10
+ const LOG_DIR = "/tmp/funnel/events"
11
+ const GATEWAY_LOG = "/tmp/funnel/gateway.log"
12
+ const TMP_DIR = "/tmp/funnel"
13
+
14
+ type Deps = {
15
+ process?: FunnelProcessRunner
16
+ fs?: FunnelFileSystem
17
+ }
18
+
19
+ const defaultProcess = new NodeFunnelProcessRunner()
20
+ const defaultFs = new NodeFunnelFileSystem()
21
+
22
+ export class FunnelGateway {
23
+ private readonly process: FunnelProcessRunner
24
+ private readonly fs: FunnelFileSystem
25
+
26
+ constructor(deps: Deps = {}) {
27
+ this.process = deps.process ?? defaultProcess
28
+ this.fs = deps.fs ?? defaultFs
29
+ Object.freeze(this)
30
+ }
31
+
32
+ isRunning(): boolean {
33
+ const pid = this.readPid()
34
+
35
+ if (!pid) return false
36
+
37
+ return this.isProcessAlive(pid)
38
+ }
39
+
40
+ getStatus(): { running: boolean; pid: number | null; port: number } {
41
+ const pid = this.readPid()
42
+ const running = pid !== null && this.isProcessAlive(pid)
43
+
44
+ return { running, pid: running ? pid : null, port: DEFAULT_PORT }
45
+ }
46
+
47
+ async start(options: { caffeinate?: boolean } = {}): Promise<boolean> {
48
+ if (this.isRunning()) return true
49
+
50
+ this.fs.mkdirSync(TMP_DIR, { recursive: true })
51
+
52
+ const gatewayScript = resolve(import.meta.dir, "./daemon.ts")
53
+ const command = this.buildStartCommand(gatewayScript, options)
54
+
55
+ this.process.detach(["bash", "-c", command])
56
+
57
+ await Bun.sleep(800)
58
+
59
+ return this.isRunning()
60
+ }
61
+
62
+ buildStartCommand(gatewayScript: string, options: { caffeinate?: boolean } = {}): string {
63
+ const useCaffeinate = options.caffeinate !== false && globalThis.process.platform === "darwin"
64
+ const prefix = useCaffeinate ? "caffeinate -i " : ""
65
+
66
+ return `nohup ${prefix}bun ${gatewayScript} >> ${GATEWAY_LOG} 2>&1 &`
67
+ }
68
+
69
+ async stop(): Promise<boolean> {
70
+ const pid = this.readPid()
71
+
72
+ if (!pid) return true
73
+
74
+ if (!this.isProcessAlive(pid)) {
75
+ this.removePid()
76
+ return true
77
+ }
78
+
79
+ try {
80
+ globalThis.process.kill(pid, "SIGTERM")
81
+ } catch {
82
+ return false
83
+ }
84
+
85
+ const deadline = Date.now() + 2000
86
+
87
+ while (Date.now() < deadline) {
88
+ if (!this.isProcessAlive(pid)) {
89
+ this.removePid()
90
+ return true
91
+ }
92
+
93
+ await Bun.sleep(100)
94
+ }
95
+
96
+ try {
97
+ globalThis.process.kill(pid, "SIGKILL")
98
+ } catch {
99
+ // ignore
100
+ }
101
+
102
+ await Bun.sleep(200)
103
+ this.removePid()
104
+
105
+ return !this.isProcessAlive(pid)
106
+ }
107
+
108
+ async restart(
109
+ options: { onlyIfRunning?: boolean; caffeinate?: boolean } = {},
110
+ ): Promise<{ ok: boolean; wasRunning: boolean; stopped: boolean; started: boolean }> {
111
+ const wasRunning = this.isRunning()
112
+
113
+ if (options.onlyIfRunning && !wasRunning) {
114
+ return { ok: true, wasRunning: false, stopped: false, started: false }
115
+ }
116
+
117
+ const stopped = wasRunning ? await this.stop() : true
118
+
119
+ if (!stopped) {
120
+ return { ok: false, wasRunning, stopped: false, started: false }
121
+ }
122
+
123
+ const started = await this.start({ caffeinate: options.caffeinate })
124
+
125
+ return { ok: started, wasRunning, stopped, started }
126
+ }
127
+
128
+ getLogDir(): string {
129
+ return LOG_DIR
130
+ }
131
+
132
+ getGatewayLog(): string {
133
+ return GATEWAY_LOG
134
+ }
135
+
136
+ private readPid(): number | null {
137
+ if (!this.fs.existsSync(PID_FILE)) return null
138
+
139
+ try {
140
+ const content = this.fs.readFileSync(PID_FILE).trim()
141
+ const pid = Number(content)
142
+
143
+ if (!pid || pid <= 0) return null
144
+
145
+ return pid
146
+ } catch {
147
+ return null
148
+ }
149
+ }
150
+
151
+ private removePid(): void {
152
+ this.fs.unlink(PID_FILE)
153
+ }
154
+
155
+ private isProcessAlive(pid: number): boolean {
156
+ const result = this.process.runSync(["ps", "-p", String(pid), "-o", "state="])
157
+
158
+ if (result.exitCode !== 0) return false
159
+
160
+ const state = result.stdout.trim()
161
+
162
+ if (!state) return false
163
+
164
+ return !state.startsWith("Z")
165
+ }
166
+ }
@@ -0,0 +1,52 @@
1
+ import { logger } from "@/modules/logger"
2
+ import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
3
+ import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
4
+
5
+ type Props = {
6
+ selfPid: number
7
+ process?: FunnelProcessRunner
8
+ }
9
+
10
+ const defaultProcess = new NodeFunnelProcessRunner()
11
+
12
+ const isBun = (args: string): boolean => {
13
+ return args.includes("bun ") || /\/bun(\s|$)/.test(args)
14
+ }
15
+
16
+ const looksLikeSlackGateway = (args: string): boolean => {
17
+ return /(gateway|bolt|slack)/i.test(args)
18
+ }
19
+
20
+ export const killCompetingSlackGateways = async (props: Props): Promise<number[]> => {
21
+ const runner = props.process ?? defaultProcess
22
+ const result = await runner.run(["ps", "-e", "-o", "pid=,args="])
23
+
24
+ if (result.exitCode !== 0) return []
25
+
26
+ const killed: number[] = []
27
+
28
+ for (const raw of result.stdout.split("\n")) {
29
+ const line = raw.trim()
30
+
31
+ if (!line) continue
32
+
33
+ const match = /^(\d+)\s+(.+)$/.exec(line)
34
+
35
+ if (!match) continue
36
+
37
+ const pid = Number(match[1])
38
+ const args = match[2]!
39
+
40
+ if (!Number.isInteger(pid) || pid <= 0) continue
41
+ if (pid === props.selfPid) continue
42
+ if (!isBun(args)) continue
43
+ if (!looksLikeSlackGateway(args)) continue
44
+
45
+ runner.kill(pid, "SIGTERM")
46
+ killed.push(pid)
47
+
48
+ logger.info("killed competing Slack gateway process", { pid, args: args.slice(0, 160) })
49
+ }
50
+
51
+ return killed
52
+ }
@@ -0,0 +1,17 @@
1
+ export type HttpRequest = {
2
+ method: string
3
+ url: string
4
+ headers?: Record<string, string>
5
+ body?: string
6
+ }
7
+
8
+ export type HttpResponse = {
9
+ status: number
10
+ ok: boolean
11
+ text(): Promise<string>
12
+ json(): Promise<unknown>
13
+ }
14
+
15
+ export abstract class FunnelHttpClient {
16
+ abstract fetch(request: HttpRequest): Promise<HttpResponse>
17
+ }
@@ -0,0 +1,40 @@
1
+ import {
2
+ FunnelHttpClient,
3
+ type HttpRequest,
4
+ type HttpResponse,
5
+ } from "@/modules/http/funnel-http-client"
6
+
7
+ export type MemoryHttpResponse = {
8
+ status?: number
9
+ body?: string
10
+ }
11
+
12
+ export type MemoryHttpHandler = (
13
+ request: HttpRequest,
14
+ ) => MemoryHttpResponse | Promise<MemoryHttpResponse>
15
+
16
+ export class MemoryFunnelHttpClient extends FunnelHttpClient {
17
+ readonly calls: HttpRequest[] = []
18
+ private handler: MemoryHttpHandler = () => ({ status: 200, body: "" })
19
+
20
+ on(handler: MemoryHttpHandler): this {
21
+ this.handler = handler
22
+
23
+ return this
24
+ }
25
+
26
+ async fetch(request: HttpRequest): Promise<HttpResponse> {
27
+ this.calls.push(request)
28
+
29
+ const response = await this.handler(request)
30
+ const status = response.status ?? 200
31
+ const body = response.body ?? ""
32
+
33
+ return {
34
+ status,
35
+ ok: status >= 200 && status < 300,
36
+ text: async () => body,
37
+ json: async () => JSON.parse(body),
38
+ }
39
+ }
40
+ }