@interactive-inc/claude-funnel 0.4.0 → 0.7.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 (39) hide show
  1. package/README.md +105 -5
  2. package/lib/api.ts +54 -0
  3. package/lib/funnel.ts +120 -33
  4. package/lib/modules/channels/funnel-channels.ts +5 -0
  5. package/lib/modules/claude/funnel-claude.ts +21 -9
  6. package/lib/modules/connectors/funnel-connector-stores.ts +32 -4
  7. package/lib/modules/connectors/funnel-connectors.ts +6 -0
  8. package/lib/modules/connectors/funnel-discord-listener.ts +9 -3
  9. package/lib/modules/connectors/funnel-discord-store.ts +5 -1
  10. package/lib/modules/connectors/funnel-gh-listener.ts +14 -5
  11. package/lib/modules/connectors/funnel-gh-store.ts +18 -1
  12. package/lib/modules/connectors/funnel-schedule-listener.ts +8 -2
  13. package/lib/modules/connectors/funnel-schedule-store.ts +21 -4
  14. package/lib/modules/connectors/funnel-slack-listener.ts +8 -2
  15. package/lib/modules/connectors/funnel-slack-store.ts +5 -1
  16. package/lib/modules/connectors/migrate-legacy-connectors.ts +5 -1
  17. package/lib/modules/fs/funnel-file-system.ts +5 -0
  18. package/lib/modules/gateway/daemon.ts +10 -143
  19. package/lib/modules/gateway/funnel-gateway-server.ts +241 -0
  20. package/lib/modules/gateway/funnel-gateway.ts +49 -20
  21. package/lib/modules/gateway/kill-competing-slack-gateways.ts +5 -1
  22. package/lib/modules/id/funnel-id-generator.ts +7 -0
  23. package/lib/modules/id/memory-funnel-id-generator.ts +20 -0
  24. package/lib/modules/id/node-funnel-id-generator.ts +7 -0
  25. package/lib/modules/logger/funnel-logger.ts +11 -0
  26. package/lib/modules/logger/memory-funnel-logger.ts +28 -0
  27. package/lib/modules/logger/node-funnel-logger.ts +49 -0
  28. package/lib/modules/logger/noop-funnel-logger.ts +9 -0
  29. package/lib/modules/mcp/funnel-mcp.ts +5 -0
  30. package/lib/modules/process/funnel-process-runner.ts +5 -0
  31. package/lib/modules/profiles/funnel-profiles.ts +5 -0
  32. package/lib/modules/repos/funnel-repositories.ts +5 -0
  33. package/lib/modules/schedule/funnel-schedule.ts +5 -0
  34. package/lib/modules/time/funnel-clock.ts +15 -0
  35. package/lib/modules/time/memory-funnel-clock.ts +26 -0
  36. package/lib/modules/time/node-funnel-clock.ts +7 -0
  37. package/lib/routes/gateway/logs.ts +3 -1
  38. package/package.json +16 -5
  39. package/lib/modules/logger.ts +0 -26
@@ -0,0 +1,241 @@
1
+ import type { Server, ServerWebSocket } from "bun"
2
+ import { Hono } from "hono"
3
+ import type { FunnelConnectors } from "@/modules/connectors/funnel-connectors"
4
+ import type { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
5
+ import { FunnelBroadcaster } from "@/modules/gateway/funnel-broadcaster"
6
+ import { FunnelEventLogger } from "@/modules/gateway/funnel-event-logger"
7
+ import { killCompetingSlackGateways } from "@/modules/gateway/kill-competing-slack-gateways"
8
+ import { FunnelLogger } from "@/modules/logger/funnel-logger"
9
+ import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
10
+ import type { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
11
+ import type { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
12
+ import type { FunnelClock } from "@/modules/time/funnel-clock"
13
+
14
+ const DEFAULT_PORT = 9742
15
+ const DEFAULT_LOG_DIR = "/tmp/funnel/events"
16
+
17
+ type Deps = {
18
+ connectors: FunnelConnectors
19
+ settings: FunnelSettingsReader
20
+ port?: number
21
+ logDir?: string
22
+ fs?: FunnelFileSystem
23
+ process?: FunnelProcessRunner
24
+ clock?: FunnelClock
25
+ logger?: FunnelLogger
26
+ selfPid?: number
27
+ killCompetingSlack?: boolean
28
+ }
29
+
30
+ type WsData = { channel: string; connectors: string[] }
31
+
32
+ const defaultLogger = new NodeFunnelLogger()
33
+
34
+ /**
35
+ * In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots all
36
+ * connector listeners, fans events out via FunnelBroadcaster, and persists
37
+ * them via FunnelEventLogger. Useful for embedding the gateway in a custom
38
+ * host or driving it from tests.
39
+ */
40
+ export class FunnelGatewayServer {
41
+ private readonly connectors: FunnelConnectors
42
+ private readonly settings: FunnelSettingsReader
43
+ private readonly port: number
44
+ private readonly logDir: string
45
+ private readonly fs?: FunnelFileSystem
46
+ private readonly process?: FunnelProcessRunner
47
+ private readonly logger: FunnelLogger
48
+ private readonly selfPid: number
49
+ private readonly killCompetingSlack: boolean
50
+ private readonly broadcaster: FunnelBroadcaster
51
+ private readonly eventLogger: FunnelEventLogger
52
+ private server: Server<WsData> | null = null
53
+
54
+ constructor(deps: Deps) {
55
+ this.connectors = deps.connectors
56
+ this.settings = deps.settings
57
+ this.port = deps.port ?? DEFAULT_PORT
58
+ this.logDir = deps.logDir ?? DEFAULT_LOG_DIR
59
+ this.fs = deps.fs
60
+ this.process = deps.process
61
+ this.logger = deps.logger ?? defaultLogger
62
+ this.selfPid = deps.selfPid ?? globalThis.process.pid
63
+ this.killCompetingSlack = deps.killCompetingSlack ?? true
64
+ this.broadcaster = new FunnelBroadcaster()
65
+ this.eventLogger = new FunnelEventLogger({
66
+ logDir: this.logDir,
67
+ fs: this.fs,
68
+ now: deps.clock ? () => deps.clock!.millis() : undefined,
69
+ })
70
+ }
71
+
72
+ async start(): Promise<Server<WsData>> {
73
+ if (this.server) return this.server
74
+
75
+ const app = this.buildApp()
76
+
77
+ this.server = Bun.serve<WsData>({
78
+ port: this.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 ? this.resolveConnectors(channelName) : []
85
+ const data: WsData = { channel: channelName, connectors }
86
+ const upgraded = server.upgrade(request, { data })
87
+
88
+ if (upgraded) return undefined
89
+
90
+ return new Response("WebSocket upgrade failed", { status: 400 })
91
+ }
92
+
93
+ return app.fetch(request)
94
+ },
95
+ websocket: {
96
+ open: (ws: ServerWebSocket<WsData>) => {
97
+ const data = ws.data
98
+
99
+ this.broadcaster.addClient(ws, data)
100
+ this.eventLogger.log("channel connected", {
101
+ event_type: "system",
102
+ action: "channel_connect",
103
+ channel: data.channel,
104
+ connectors: data.connectors.join(","),
105
+ total: String(this.broadcaster.getClientCount()),
106
+ })
107
+ },
108
+ close: (ws: ServerWebSocket<WsData>) => {
109
+ this.broadcaster.removeClient(ws)
110
+ this.eventLogger.log("channel disconnected", {
111
+ event_type: "system",
112
+ action: "channel_disconnect",
113
+ total: String(this.broadcaster.getClientCount()),
114
+ })
115
+ },
116
+ message() {
117
+ // future: client → gateway messages
118
+ },
119
+ },
120
+ })
121
+
122
+ this.eventLogger.log("gateway started", {
123
+ event_type: "system",
124
+ action: "gateway_start",
125
+ port: String(this.port),
126
+ pid: String(this.selfPid),
127
+ })
128
+
129
+ this.logger.info("funnel gateway listening", {
130
+ url: `http://localhost:${this.port}`,
131
+ websocket: `ws://localhost:${this.port}/ws`,
132
+ health: `http://localhost:${this.port}/health`,
133
+ })
134
+
135
+ await this.bootListeners()
136
+
137
+ return this.server
138
+ }
139
+
140
+ stop(): void {
141
+ if (!this.server) return
142
+
143
+ this.server.stop()
144
+ this.server = null
145
+ }
146
+
147
+ getStatus(): { clients: number; channels: { channel: string; connectors: string[] }[] } {
148
+ return {
149
+ clients: this.broadcaster.getClientCount(),
150
+ channels: this.broadcaster.listChannels(),
151
+ }
152
+ }
153
+
154
+ getBroadcaster(): FunnelBroadcaster {
155
+ return this.broadcaster
156
+ }
157
+
158
+ private buildApp(): Hono {
159
+ const app = new Hono()
160
+
161
+ app.get("/health", (c) =>
162
+ c.json({
163
+ ok: true,
164
+ pid: this.selfPid,
165
+ clients: this.broadcaster.getClientCount(),
166
+ }),
167
+ )
168
+
169
+ app.get("/status", (c) =>
170
+ c.json({
171
+ ok: true,
172
+ clients: this.broadcaster.listChannels(),
173
+ }),
174
+ )
175
+
176
+ return app
177
+ }
178
+
179
+ private resolveConnectors(channelName: string): string[] {
180
+ const settings = this.settings.read()
181
+ const channel = settings?.channels.find((c) => c.name === channelName)
182
+
183
+ return channel?.connectors ?? []
184
+ }
185
+
186
+ private async bootListeners(): Promise<void> {
187
+ const allConnectors = this.connectors.list()
188
+
189
+ if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
190
+ const killed = await killCompetingSlackGateways({
191
+ selfPid: this.selfPid,
192
+ process: this.process,
193
+ logger: this.logger,
194
+ })
195
+
196
+ if (killed.length > 0) {
197
+ this.eventLogger.log("killed competing Slack gateway processes", {
198
+ event_type: "system",
199
+ action: "kill_competing",
200
+ pids: killed.join(","),
201
+ })
202
+ }
203
+ }
204
+
205
+ for (const { config, listener } of this.connectors.createListeners()) {
206
+ const bind = (content: string, meta?: Record<string, string>) =>
207
+ this.notify(config.name, content, meta)
208
+
209
+ try {
210
+ await listener.start(bind)
211
+
212
+ this.eventLogger.log(`${config.type} listener started: ${config.name}`, {
213
+ event_type: "system",
214
+ action: `${config.type}_connect`,
215
+ connector: config.name,
216
+ })
217
+
218
+ this.logger.info(`${config.type} listener started`, { connector: config.name })
219
+ } catch (error) {
220
+ this.logger.error(`${config.type} listener failed`, {
221
+ connector: config.name,
222
+ error: error instanceof Error ? error.message : String(error),
223
+ })
224
+ }
225
+ }
226
+
227
+ this.logger.info(`event logs: ${this.logDir}`)
228
+ this.logger.info("funnel gateway running")
229
+ }
230
+
231
+ private async notify(
232
+ connectorName: string,
233
+ content: string,
234
+ meta?: Record<string, string>,
235
+ ): Promise<void> {
236
+ const withConnector: Record<string, string> = { ...meta, connector: connectorName }
237
+
238
+ this.eventLogger.log(content, withConnector)
239
+ this.broadcaster.broadcast(content, withConnector)
240
+ }
241
+ }
@@ -4,28 +4,57 @@ import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
4
4
  import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
5
5
  import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
6
6
  import { FUNNEL_DIR } from "@/modules/settings/funnel-settings-store"
7
+ import { FunnelClock } from "@/modules/time/funnel-clock"
8
+ import { NodeFunnelClock } from "@/modules/time/node-funnel-clock"
7
9
 
8
10
  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"
11
+ const DEFAULT_TMP_DIR = "/tmp/funnel"
13
12
 
14
13
  type Deps = {
15
14
  process?: FunnelProcessRunner
16
15
  fs?: FunnelFileSystem
16
+ clock?: FunnelClock
17
+ dir?: string
18
+ tmpDir?: string
19
+ port?: number
20
+ sleep?: (ms: number) => Promise<void>
17
21
  }
18
22
 
19
23
  const defaultProcess = new NodeFunnelProcessRunner()
20
24
  const defaultFs = new NodeFunnelFileSystem()
21
-
25
+ const defaultClock = new NodeFunnelClock()
26
+ const defaultSleep = (ms: number): Promise<void> =>
27
+ new Promise((r) => {
28
+ setTimeout(r, ms)
29
+ })
30
+
31
+ /**
32
+ * Manages the gateway daemon as a separate process via PID file.
33
+ * Use `start()` to spawn `bun daemon.ts` in the background and `stop()` to
34
+ * terminate it. For an in-process gateway, use `Funnel.gatewayServer` instead.
35
+ */
22
36
  export class FunnelGateway {
23
37
  private readonly process: FunnelProcessRunner
24
38
  private readonly fs: FunnelFileSystem
39
+ private readonly clock: FunnelClock
40
+ private readonly pidFile: string
41
+ private readonly logDir: string
42
+ private readonly gatewayLog: string
43
+ private readonly tmpDir: string
44
+ private readonly port: number
45
+ private readonly sleep: (ms: number) => Promise<void>
25
46
 
26
47
  constructor(deps: Deps = {}) {
27
48
  this.process = deps.process ?? defaultProcess
28
49
  this.fs = deps.fs ?? defaultFs
50
+ this.clock = deps.clock ?? defaultClock
51
+ const baseDir = deps.dir ?? FUNNEL_DIR
52
+ this.tmpDir = deps.tmpDir ?? DEFAULT_TMP_DIR
53
+ this.pidFile = join(baseDir, "gateway.pid")
54
+ this.logDir = join(this.tmpDir, "events")
55
+ this.gatewayLog = join(this.tmpDir, "gateway.log")
56
+ this.port = deps.port ?? DEFAULT_PORT
57
+ this.sleep = deps.sleep ?? defaultSleep
29
58
  Object.freeze(this)
30
59
  }
31
60
 
@@ -41,20 +70,20 @@ export class FunnelGateway {
41
70
  const pid = this.readPid()
42
71
  const running = pid !== null && this.isProcessAlive(pid)
43
72
 
44
- return { running, pid: running ? pid : null, port: DEFAULT_PORT }
73
+ return { running, pid: running ? pid : null, port: this.port }
45
74
  }
46
75
 
47
76
  async start(options: { caffeinate?: boolean } = {}): Promise<boolean> {
48
77
  if (this.isRunning()) return true
49
78
 
50
- this.fs.mkdirSync(TMP_DIR, { recursive: true })
79
+ this.fs.mkdirSync(this.tmpDir, { recursive: true })
51
80
 
52
81
  const gatewayScript = resolve(import.meta.dir, "./daemon.ts")
53
82
  const command = this.buildStartCommand(gatewayScript, options)
54
83
 
55
84
  this.process.detach(["bash", "-c", command])
56
85
 
57
- await Bun.sleep(800)
86
+ await this.sleep(800)
58
87
 
59
88
  return this.isRunning()
60
89
  }
@@ -63,7 +92,7 @@ export class FunnelGateway {
63
92
  const useCaffeinate = options.caffeinate !== false && globalThis.process.platform === "darwin"
64
93
  const prefix = useCaffeinate ? "caffeinate -i " : ""
65
94
 
66
- return `nohup ${prefix}bun ${gatewayScript} >> ${GATEWAY_LOG} 2>&1 &`
95
+ return `nohup ${prefix}bun ${gatewayScript} >> ${this.gatewayLog} 2>&1 &`
67
96
  }
68
97
 
69
98
  async stop(): Promise<boolean> {
@@ -77,29 +106,29 @@ export class FunnelGateway {
77
106
  }
78
107
 
79
108
  try {
80
- globalThis.process.kill(pid, "SIGTERM")
109
+ this.process.kill(pid, "SIGTERM")
81
110
  } catch {
82
111
  return false
83
112
  }
84
113
 
85
- const deadline = Date.now() + 2000
114
+ const deadline = this.clock.millis() + 2000
86
115
 
87
- while (Date.now() < deadline) {
116
+ while (this.clock.millis() < deadline) {
88
117
  if (!this.isProcessAlive(pid)) {
89
118
  this.removePid()
90
119
  return true
91
120
  }
92
121
 
93
- await Bun.sleep(100)
122
+ await this.sleep(100)
94
123
  }
95
124
 
96
125
  try {
97
- globalThis.process.kill(pid, "SIGKILL")
126
+ this.process.kill(pid, "SIGKILL")
98
127
  } catch {
99
128
  // ignore
100
129
  }
101
130
 
102
- await Bun.sleep(200)
131
+ await this.sleep(200)
103
132
  this.removePid()
104
133
 
105
134
  return !this.isProcessAlive(pid)
@@ -126,18 +155,18 @@ export class FunnelGateway {
126
155
  }
127
156
 
128
157
  getLogDir(): string {
129
- return LOG_DIR
158
+ return this.logDir
130
159
  }
131
160
 
132
161
  getGatewayLog(): string {
133
- return GATEWAY_LOG
162
+ return this.gatewayLog
134
163
  }
135
164
 
136
165
  private readPid(): number | null {
137
- if (!this.fs.existsSync(PID_FILE)) return null
166
+ if (!this.fs.existsSync(this.pidFile)) return null
138
167
 
139
168
  try {
140
- const content = this.fs.readFileSync(PID_FILE).trim()
169
+ const content = this.fs.readFileSync(this.pidFile).trim()
141
170
  const pid = Number(content)
142
171
 
143
172
  if (!pid || pid <= 0) return null
@@ -149,7 +178,7 @@ export class FunnelGateway {
149
178
  }
150
179
 
151
180
  private removePid(): void {
152
- this.fs.unlink(PID_FILE)
181
+ this.fs.unlink(this.pidFile)
153
182
  }
154
183
 
155
184
  private isProcessAlive(pid: number): boolean {
@@ -1,13 +1,16 @@
1
- import { logger } from "@/modules/logger"
1
+ import { FunnelLogger } from "@/modules/logger/funnel-logger"
2
+ import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
2
3
  import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
3
4
  import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
4
5
 
5
6
  type Props = {
6
7
  selfPid: number
7
8
  process?: FunnelProcessRunner
9
+ logger?: FunnelLogger
8
10
  }
9
11
 
10
12
  const defaultProcess = new NodeFunnelProcessRunner()
13
+ const defaultLogger = new NodeFunnelLogger()
11
14
 
12
15
  const isBun = (args: string): boolean => {
13
16
  return args.includes("bun ") || /\/bun(\s|$)/.test(args)
@@ -19,6 +22,7 @@ const looksLikeSlackGateway = (args: string): boolean => {
19
22
 
20
23
  export const killCompetingSlackGateways = async (props: Props): Promise<number[]> => {
21
24
  const runner = props.process ?? defaultProcess
25
+ const logger = props.logger ?? defaultLogger
22
26
  const result = await runner.run(["ps", "-e", "-o", "pid=,args="])
23
27
 
24
28
  if (result.exitCode !== 0) return []
@@ -0,0 +1,7 @@
1
+ /**
2
+ * ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
3
+ * MemoryFunnelIdGenerator emits `<prefix>-1, <prefix>-2, ...` for deterministic tests.
4
+ */
5
+ export abstract class FunnelIdGenerator {
6
+ abstract generate(): string
7
+ }
@@ -0,0 +1,20 @@
1
+ import { FunnelIdGenerator } from "@/modules/id/funnel-id-generator"
2
+
3
+ type Props = {
4
+ prefix?: string
5
+ }
6
+
7
+ export class MemoryFunnelIdGenerator extends FunnelIdGenerator {
8
+ private counter = 0
9
+ private readonly prefix: string
10
+
11
+ constructor(props: Props = {}) {
12
+ super()
13
+ this.prefix = props.prefix ?? "id"
14
+ }
15
+
16
+ generate(): string {
17
+ this.counter++
18
+ return `${this.prefix}-${this.counter}`
19
+ }
20
+ }
@@ -0,0 +1,7 @@
1
+ import { FunnelIdGenerator } from "@/modules/id/funnel-id-generator"
2
+
3
+ export class NodeFunnelIdGenerator extends FunnelIdGenerator {
4
+ generate(): string {
5
+ return crypto.randomUUID()
6
+ }
7
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Structured logger with three levels and an optional log-file path.
3
+ * Defaults to NodeFunnelLogger (appends to /tmp/funnel/funnel.log);
4
+ * MemoryFunnelLogger captures entries in memory and NoopFunnelLogger silences output.
5
+ */
6
+ export abstract class FunnelLogger {
7
+ abstract info(message: string, meta?: Record<string, unknown>): void
8
+ abstract warn(message: string, meta?: Record<string, unknown>): void
9
+ abstract error(message: string, meta?: Record<string, unknown>): void
10
+ abstract readonly file: string | null
11
+ }
@@ -0,0 +1,28 @@
1
+ import { FunnelLogger } from "@/modules/logger/funnel-logger"
2
+
3
+ export type LogEntry = {
4
+ level: "info" | "warn" | "error"
5
+ message: string
6
+ meta?: Record<string, unknown>
7
+ }
8
+
9
+ export class MemoryFunnelLogger extends FunnelLogger {
10
+ readonly file = null
11
+ readonly entries: LogEntry[] = []
12
+
13
+ info(message: string, meta?: Record<string, unknown>): void {
14
+ this.entries.push({ level: "info", message, meta })
15
+ }
16
+
17
+ warn(message: string, meta?: Record<string, unknown>): void {
18
+ this.entries.push({ level: "warn", message, meta })
19
+ }
20
+
21
+ error(message: string, meta?: Record<string, unknown>): void {
22
+ this.entries.push({ level: "error", message, meta })
23
+ }
24
+
25
+ clear(): void {
26
+ this.entries.length = 0
27
+ }
28
+ }
@@ -0,0 +1,49 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs"
2
+ import { dirname, join } from "node:path"
3
+ import { FunnelLogger } from "@/modules/logger/funnel-logger"
4
+
5
+ const DEFAULT_LOG_FILE = join("/tmp/funnel", "funnel.log")
6
+
7
+ type Level = "info" | "warn" | "error"
8
+
9
+ type Props = {
10
+ file?: string
11
+ now?: () => Date
12
+ }
13
+
14
+ export class NodeFunnelLogger extends FunnelLogger {
15
+ readonly file: string
16
+ private readonly now: () => Date
17
+
18
+ constructor(props: Props = {}) {
19
+ super()
20
+ this.file = props.file ?? DEFAULT_LOG_FILE
21
+ this.now = props.now ?? (() => new Date())
22
+ Object.freeze(this)
23
+ }
24
+
25
+ info(message: string, meta?: Record<string, unknown>): void {
26
+ this.write("info", message, meta)
27
+ }
28
+
29
+ warn(message: string, meta?: Record<string, unknown>): void {
30
+ this.write("warn", message, meta)
31
+ }
32
+
33
+ error(message: string, meta?: Record<string, unknown>): void {
34
+ this.write("error", message, meta)
35
+ }
36
+
37
+ private write(level: Level, message: string, meta?: Record<string, unknown>): void {
38
+ mkdirSync(dirname(this.file), { recursive: true })
39
+
40
+ const entry = {
41
+ time: this.now().toISOString(),
42
+ level,
43
+ message,
44
+ ...(meta ? { meta } : {}),
45
+ }
46
+
47
+ appendFileSync(this.file, `${JSON.stringify(entry)}\n`)
48
+ }
49
+ }
@@ -0,0 +1,9 @@
1
+ import { FunnelLogger } from "@/modules/logger/funnel-logger"
2
+
3
+ export class NoopFunnelLogger extends FunnelLogger {
4
+ readonly file = null
5
+
6
+ info(): void {}
7
+ warn(): void {}
8
+ error(): void {}
9
+ }
@@ -20,6 +20,11 @@ type Deps = {
20
20
 
21
21
  const defaultFs = new NodeFunnelFileSystem()
22
22
 
23
+ /**
24
+ * Installs/uninstalls the funnel MCP entry into a target repository's
25
+ * `.mcp.json`. Detects an existing entry by command match so renaming is
26
+ * preserved across re-installs.
27
+ */
23
28
  export class FunnelMcp {
24
29
  private readonly fs: FunnelFileSystem
25
30
 
@@ -19,6 +19,11 @@ export type DetachOptions = {
19
19
  env?: Record<string, string>
20
20
  }
21
21
 
22
+ /**
23
+ * Process boundary covering one-shot runs, sync runs, foreground attach, and
24
+ * detached background spawns. Default is NodeFunnelProcessRunner (Bun.spawn);
25
+ * MemoryFunnelProcessRunner records calls and lets tests stub responses.
26
+ */
22
27
  export abstract class FunnelProcessRunner {
23
28
  abstract run(command: string[], options?: RunOptions): Promise<RunResult>
24
29
  abstract runSync(command: string[]): RunResult
@@ -5,6 +5,11 @@ type Deps = {
5
5
  store: FunnelSettingsReader
6
6
  }
7
7
 
8
+ /**
9
+ * Named launch presets for `fnl claude` (channel + repo + sub-agent + env files).
10
+ * Implements ProfileChannelChecker / ProfileChannelRefUpdater so FunnelChannels
11
+ * can validate references without depending on the FunnelProfiles concrete type.
12
+ */
8
13
  export class FunnelProfiles {
9
14
  private readonly store: FunnelSettingsReader
10
15
 
@@ -7,6 +7,11 @@ type Deps = {
7
7
  mcp: FunnelMcp
8
8
  }
9
9
 
10
+ /**
11
+ * Repository registry. Each entry has a name + filesystem path; add/remove
12
+ * also writes/removes the funnel MCP entry in `<path>/.mcp.json` so Claude Code
13
+ * picks it up automatically when launched in that directory.
14
+ */
10
15
  export class FunnelRepositories {
11
16
  private readonly store: FunnelSettingsReader
12
17
  private readonly mcp: FunnelMcp
@@ -5,6 +5,11 @@ type Deps = {
5
5
  store: FunnelScheduleStore
6
6
  }
7
7
 
8
+ /**
9
+ * Cron entry CRUD for a schedule connector. The schedule connector itself
10
+ * is created via `connectors.add({ type: "schedule", ... })`; this class
11
+ * manages the JSONL entries inside it.
12
+ */
8
13
  export class FunnelSchedule {
9
14
  private readonly store: FunnelScheduleStore
10
15
 
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Time boundary. Default NodeFunnelClock returns `new Date()`; MemoryFunnelClock
3
+ * is settable and `advance(ms)`-able for deterministic schedule / timeout tests.
4
+ */
5
+ export abstract class FunnelClock {
6
+ abstract now(): Date
7
+
8
+ millis(): number {
9
+ return this.now().getTime()
10
+ }
11
+
12
+ iso(): string {
13
+ return this.now().toISOString()
14
+ }
15
+ }
@@ -0,0 +1,26 @@
1
+ import { FunnelClock } from "@/modules/time/funnel-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 "@/modules/time/funnel-clock"
2
+
3
+ export class NodeFunnelClock extends FunnelClock {
4
+ now(): Date {
5
+ return new Date()
6
+ }
7
+ }